diff --git a/README.md b/README.md index 637aa8b..6094aef 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,10 @@ traveling-around-the-world/ - 改為 `mode="api"` 並設定 `apiBaseUrl` 後,就會改用 `VisitApiClient` 透過 REST API 存取。 - Vite `proxy` 已轉發 `/api` 至 `http://localhost:4000`,未來後端啟動後無需改動前端呼叫。 +### 多語系支援 +- 透過 `react-i18next` 提供繁體中文與英文,語系檔位於 `client/src/locales/`。 +- 右上角的語言選單 (`LanguageSwitcher`) 可即時切換語言,並記錄在 LocalStorage 方便下次載入。 + ### 開發啟動 1. 安裝相依套件:`cd client && npm install` 2. 啟動開發伺服器:`npm run dev` diff --git a/client/package-lock.json b/client/package-lock.json index 351fa13..ba56764 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,11 +10,13 @@ "dependencies": { "@hookform/resolvers": "^3.3.4", "@tanstack/react-query": "^5.29.0", + "i18next": "^23.11.5", "leaflet": "^1.9.4", "nanoid": "^5.0.7", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.51.3", + "react-i18next": "^14.1.1", "react-leaflet": "^4.2.1", "react-router-dom": "^6.22.3", "zod": "^3.22.4", @@ -52,6 +54,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -3260,6 +3270,36 @@ "node": ">= 0.4" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "23.16.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", + "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4619,6 +4659,27 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "14.1.3", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.3.tgz", + "integrity": "sha512-wZnpfunU6UIAiJ+bxwOiTmBOAaB14ha97MjOEnLGac2RJ+h/maIYXZuTHlmyqQVX1UVHmU1YDTQ5vxLmwfXTjw==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -5738,6 +5799,14 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/client/package.json b/client/package.json index e2f2b81..997821e 100644 --- a/client/package.json +++ b/client/package.json @@ -20,7 +20,9 @@ "zustand": "^4.5.2", "zod": "^3.22.4", "nanoid": "^5.0.7", - "@hookform/resolvers": "^3.3.4" + "@hookform/resolvers": "^3.3.4", + "i18next": "^23.11.5", + "react-i18next": "^14.1.1" }, "devDependencies": { "@types/leaflet": "^1.9.12", diff --git a/client/src/components/layout/LanguageSwitcher.tsx b/client/src/components/layout/LanguageSwitcher.tsx new file mode 100644 index 0000000..86962a1 --- /dev/null +++ b/client/src/components/layout/LanguageSwitcher.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next'; +import { SUPPORTED_LANGUAGES, type SupportedLanguage } from '../../i18n'; + +const LABEL_LOOKUP: Record = { + 'zh-Hant': 'language.zhHant', + en: 'language.en' +}; + +export function LanguageSwitcher() { + const { t, i18n } = useTranslation(); + + const currentLanguage = + SUPPORTED_LANGUAGES.find((language) => i18n.language.startsWith(language)) ?? 'zh-Hant'; + + return ( + + ); +} diff --git a/client/src/components/layout/PrimaryLayout.tsx b/client/src/components/layout/PrimaryLayout.tsx index 74477cc..0d20068 100644 --- a/client/src/components/layout/PrimaryLayout.tsx +++ b/client/src/components/layout/PrimaryLayout.tsx @@ -1,4 +1,6 @@ import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LanguageSwitcher } from './LanguageSwitcher'; interface PrimaryLayoutProps { sidebar: ReactNode; @@ -6,22 +8,27 @@ interface PrimaryLayoutProps { } export function PrimaryLayout({ sidebar, content }: PrimaryLayoutProps) { + const { t } = useTranslation(); + return (
-

Traveling Around The World

-

紀錄你走過的每一步足跡

+

{t('common.appName')}

+

{t('common.tagline')}

+
+
+ +
-
diff --git a/client/src/components/layout/VisitSidebar.tsx b/client/src/components/layout/VisitSidebar.tsx index 5537731..de135ab 100644 --- a/client/src/components/layout/VisitSidebar.tsx +++ b/client/src/components/layout/VisitSidebar.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import type { Visit } from '../../features/visits/types'; import { VisitList } from '../../features/visits/components/VisitList'; import { VisitForm } from '../../features/visits/components/VisitForm'; @@ -8,6 +9,7 @@ import { useVisitDrawerStore } from '../../state/useVisitDrawerStore'; export function VisitSidebar() { const { isOpen, initialLocation, open, close } = useVisitDrawerStore(); const [editingVisit, setEditingVisit] = useState(null); + const { t } = useTranslation(); useEffect(() => { if (!isOpen) { @@ -19,15 +21,15 @@ export function VisitSidebar() {