From aa0df45ed605f4d8240693d02b4c4997d7af54d1 Mon Sep 17 00:00:00 2001 From: Chengahao Date: Mon, 3 Aug 2026 21:18:55 +0800 Subject: [PATCH] feat(i18n): multi-language support with header language switcher Add full internationalization using react-i18next: - Language switcher in the top-right corner of the header - Fully translated locales: English, Simplified Chinese, Traditional Chinese (Taiwan); Japanese, Korean, French, German, Spanish are selectable placeholders that fall back to English - Docker-configurable default language via the DEFAULT_LANGUAGE environment variable, served from GET /config - Language preference persists in localStorage; browser locale is used when no preference is set - All pages, navigation, tables, charts and shared UI components are translated Also: - Add .gitignore entries for pnpm-generated artifacts (.pnpm-store/, pnpm-lock.yaml, .pytest_cache/) - Add pnpm-workspace.yaml to allow the esbuild postinstall under pnpm 11 Testing: - New pytest suite (server/tests): locale key parity between en/zh-CN/ zh-TW, placeholder locales, and DEFAULT_LANGUAGE in GET /config - tsc --noEmit and vite build pass; live server smoke test passed (login, /config, /api/flights) AI-generated parts: - i18n infrastructure and locale files (client/i18n.ts, client/locales/) - language switcher component and the translation pass across pages and components - DEFAULT_LANGUAGE wiring (server/environment.py, server/main.py, client/api.ts, Dockerfile, README) - locale/config test suite (server/tests/) --- .gitignore | 3 + Dockerfile | 1 + README.md | 9 +- client/api.ts | 7 +- client/components/Toast.tsx | 5 +- client/components/flights/FlightDetail.tsx | 109 +++---- client/components/flights/FlightFilters.tsx | 30 +- client/components/flights/FlightsTable.tsx | 6 +- client/components/settings/ColumnsManager.tsx | 22 +- client/components/shell/AppShell.tsx | 38 +-- client/components/shell/BoardClock.tsx | 4 +- client/components/shell/LanguageSwitcher.tsx | 29 ++ client/components/shell/NavSheet.tsx | 32 ++- client/components/stats/Charts.tsx | 25 +- client/components/ui/Combobox.tsx | 20 +- client/components/ui/Dialog.tsx | 5 +- client/components/ui/Sheet.tsx | 4 +- client/components/ui/Spinner.tsx | 5 +- client/i18n.ts | 68 +++++ client/lib/flightColumns.ts | 44 +-- client/locales/de.json | 1 + client/locales/en.json | 265 ++++++++++++++++++ client/locales/es.json | 1 + client/locales/fr.json | 1 + client/locales/index.ts | 33 +++ client/locales/ja.json | 1 + client/locales/ko.json | 1 + client/locales/zh-CN.json | 265 ++++++++++++++++++ client/locales/zh-TW.json | 265 ++++++++++++++++++ client/main.tsx | 1 + client/pages/AllFlights.tsx | 18 +- client/pages/Home.tsx | 31 +- client/pages/Login.tsx | 14 +- client/pages/New.tsx | 88 +++--- client/pages/Settings.tsx | 177 ++++++------ client/pages/Statistics.tsx | 81 +++--- package.json | 2 + pnpm-workspace.yaml | 2 + server/environment.py | 1 + server/main.py | 5 +- server/tests/__init__.py | 1 + server/tests/conftest.py | 51 ++++ server/tests/test_config.py | 7 + server/tests/test_locales.py | 51 ++++ 44 files changed, 1494 insertions(+), 335 deletions(-) create mode 100644 client/components/shell/LanguageSwitcher.tsx create mode 100644 client/i18n.ts create mode 100644 client/locales/de.json create mode 100644 client/locales/en.json create mode 100644 client/locales/es.json create mode 100644 client/locales/fr.json create mode 100644 client/locales/index.ts create mode 100644 client/locales/ja.json create mode 100644 client/locales/ko.json create mode 100644 client/locales/zh-CN.json create mode 100644 client/locales/zh-TW.json create mode 100644 pnpm-workspace.yaml create mode 100644 server/tests/__init__.py create mode 100644 server/tests/conftest.py create mode 100644 server/tests/test_config.py create mode 100644 server/tests/test_locales.py diff --git a/.gitignore b/.gitignore index 2938463..1331fcd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ dist __pycache__ package-lock.json .parcel-cache +.pnpm-store/ +pnpm-lock.yaml +.pytest_cache/ *.old data/*.db* diff --git a/Dockerfile b/Dockerfile index 4ce9fa5..095aa68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,7 @@ ENV DATA_PATH=/data ENV JETLOG_PORT=3000 ENV TOKEN_DURATION=7 ENV ENABLE_EXTERNAL_APIS=true +ENV DEFAULT_LANGUAGE=en ENV USE_IPV6=false RUN mkdir -p ${APP_PATH} diff --git a/README.md b/README.md index 490944d..44a8ef0 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,11 @@ services: image: pbogre/jetlog:latest volumes: - /your/data/path:/data - environment: - JETLOG_PORT: 3000 # optional, default is 3000 - SECRET_KEY: yourLongAndRandomStringOfCharacters123! - restart: unless-stopped + environment: + JETLOG_PORT: 3000 # optional, default is 3000 + SECRET_KEY: yourLongAndRandomStringOfCharacters123! + DEFAULT_LANGUAGE: en # optional default UI language: en, zh-CN, zh-TW, ja, ko, fr, de, es + restart: unless-stopped ports: - 3000:3000 ``` diff --git a/client/api.ts b/client/api.ts index 0d17656..9510817 100644 --- a/client/api.ts +++ b/client/api.ts @@ -3,10 +3,15 @@ import TokenStorage from './storage/tokenStorage'; import { showToast } from './components/Toast'; const config = await fetch('./config').then((response) => response.json()) - .catch(() => ({ BASE_URL: '/', ENABLE_EXTERNAL_APIS: true })); + .catch(() => ({ + BASE_URL: '/', + ENABLE_EXTERNAL_APIS: true, + DEFAULT_LANGUAGE: undefined, + })); export const BASE_URL = config.BASE_URL == '/' ? '' : config.BASE_URL; export const ENABLE_EXTERNAL_APIS = config.ENABLE_EXTERNAL_APIS; +export const DEFAULT_LANGUAGE: string | undefined = config.DEFAULT_LANGUAGE ?? undefined; // TODO improve this because there's a lot of repetition (get, post, delete are pretty much exactly the same) // perhaps one method for each endpoint? i.e. API.getFlights(), ... diff --git a/client/components/Toast.tsx b/client/components/Toast.tsx index 76a3fec..7e63202 100644 --- a/client/components/Toast.tsx +++ b/client/components/Toast.tsx @@ -1,4 +1,5 @@ import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; export type ToastType = 'success' | 'error' | 'info'; @@ -38,6 +39,8 @@ interface ToastItemProps { } function ToastItem({ toast, onRemove }: ToastItemProps) { + const { t } = useTranslation(); + useEffect(() => { const timer = setTimeout(() => { onRemove(toast.id); @@ -67,7 +70,7 @@ function ToastItem({ toast, onRemove }: ToastItemProps) { diff --git a/client/components/flights/FlightDetail.tsx b/client/components/flights/FlightDetail.tsx index 1baf558..9e9080b 100644 --- a/client/components/flights/FlightDetail.tsx +++ b/client/components/flights/FlightDetail.tsx @@ -2,6 +2,7 @@ import { useState, type FormEvent, lazy, Suspense } from 'react' import { Link, useNavigate } from 'react-router-dom' import { ArrowLeft, ArrowRight, Pencil, Trash2, X, Check } from 'lucide-react' import { useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' import API from '@/api' import { @@ -40,6 +41,7 @@ const PURPOSES = ['', 'leisure', 'business', 'crew', 'other'] export function FlightDetail({ flightId }: FlightDetailProps) { const navigate = useNavigate() + const { t } = useTranslation() const metric = ConfigStorage.getSetting('metricUnits') !== 'false' const localAirportTime = ConfigStorage.getSetting('localAirportTime') === 'true' const { data: flight, isLoading } = useFlight(flightId) @@ -113,7 +115,7 @@ export function FlightDetail({ flightId }: FlightDetailProps) { } const handleDelete = () => { - if (!confirm('Delete this flight?')) return + if (!confirm(t('flightDetail.deleteConfirm'))) return deleteMut.mutate(flightId, { onSuccess: () => navigate('/flights'), }) @@ -127,13 +129,13 @@ export function FlightDetail({ flightId }: FlightDetailProps) { to="/flights" className="board-label flex items-center gap-1.5 text-ink-muted hover:text-ink" > - All flights + {t('flightDetail.allFlights')}
{isOwner && !editing && ( <> )} @@ -154,7 +156,7 @@ export function FlightDetail({ flightId }: FlightDetailProps) { size="sm" onClick={() => setEditing(false)} > - Cancel + {t('common.cancel')} )} @@ -196,17 +198,17 @@ export function FlightDetail({ flightId }: FlightDetailProps) {
@@ -225,14 +227,14 @@ export function FlightDetail({ flightId }: FlightDetailProps) { {/* Airports */}
- - + +
- Route + {t('newFlight.route')} {flight.distance ? ( ) : ( - No route data + + {t('flightDetail.noRouteData')} + )} - {editing ? 'Edit details' : 'Details'} + + {editing ? t('flightDetail.editDetails') : t('flightDetail.details')} + {editing ? ( @@ -270,7 +276,7 @@ export function FlightDetail({ flightId }: FlightDetailProps) { {flight.notes && !editing && ( - Notes + {t('newFlight.notes')}

@@ -284,6 +290,8 @@ export function FlightDetail({ flightId }: FlightDetailProps) { } function AirportPanel({ title, airport }: { title: string; airport: any }) { + const { t } = useTranslation() + if (!airport || typeof airport === 'string') { return ( @@ -291,7 +299,7 @@ function AirportPanel({ title, airport }: { title: string; airport: any }) { {title} - {typeof airport === 'string' ? airport : 'No airport data'} + {typeof airport === 'string' ? airport : t('flightDetail.noAirportData')} ) @@ -305,39 +313,41 @@ function AirportPanel({ title, airport }: { title: string; airport: any }) { - - - - - - - + + + + + + + ) } function ReadFields({ flight }: { flight: Flight }) { + const { t } = useTranslation() + return (

- + - - - - + + + + {flight.connection && (
- Connection + {t('flightDetail.connection')} - Linked flight #{flight.connection} + {t('flightDetail.linkedFlight', { id: flight.connection })}
)} @@ -351,6 +361,7 @@ interface EditFieldsProps { } function EditFields({ draft, setDraft }: EditFieldsProps) { + const { t } = useTranslation() const set = (k: K, v: Flight[K] | undefined) => setDraft({ ...draft, [k]: v as any }) @@ -375,7 +386,7 @@ function EditFields({ draft, setDraft }: EditFieldsProps) { return (
- +
- + displayValue={ draft.origin @@ -396,7 +407,7 @@ function EditFields({ draft, setDraft }: EditFieldsProps) { />
- + displayValue={ draft.destination @@ -409,7 +420,7 @@ function EditFields({ draft, setDraft }: EditFieldsProps) {
- +
- +
- +
- +
- +
- + displayValue={ draft.airline @@ -468,7 +479,7 @@ function EditFields({ draft, setDraft }: EditFieldsProps) {
- +
- + set('ticketClass', e.target.value)} @@ -505,7 +516,7 @@ function EditFields({ draft, setDraft }: EditFieldsProps) {
- + set('airplane', e.target.value)} />
- + set('tailNumber', e.target.value)} @@ -531,7 +542,7 @@ function EditFields({ draft, setDraft }: EditFieldsProps) {
- + set('flightNumber', e.target.value)} @@ -539,7 +550,7 @@ function EditFields({ draft, setDraft }: EditFieldsProps) {
- +