feat: add i18n for multi languages

This commit is contained in:
2025-09-30 15:53:17 +08:00
parent 99a97139df
commit cf266880bb
13 changed files with 343 additions and 49 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1,34 @@
import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES, type SupportedLanguage } from '../../i18n';
const LABEL_LOOKUP: Record<SupportedLanguage, string> = {
'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 (
<label className="flex items-center gap-2 text-sm text-slate-500">
<span>{t('language.label')}</span>
<select
className="rounded border border-slate-300 bg-white px-2 py-1 text-sm"
value={currentLanguage}
onChange={(event) => {
const selected = event.target.value as SupportedLanguage;
void i18n.changeLanguage(selected);
}}
>
{SUPPORTED_LANGUAGES.map((language) => (
<option key={language} value={language}>
{t(LABEL_LOOKUP[language])}
</option>
))}
</select>
</label>
);
}

View File

@@ -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 (
<div className="flex h-screen flex-col">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<div>
<h1 className="text-2xl font-bold">Traveling Around The World</h1>
<p className="text-sm text-slate-500"></p>
<h1 className="text-2xl font-bold">{t('common.appName')}</h1>
<p className="text-sm text-slate-500">{t('common.tagline')}</p>
</div>
<div className="flex items-center gap-4">
<nav className="flex items-center gap-3 text-sm text-slate-500">
<a href="https://www.openstreetmap.org/" target="_blank" rel="noreferrer" className="hover:text-primary">
{t('nav.openStreetMap')}
</a>
<a href="https://react-leaflet.js.org/" target="_blank" rel="noreferrer" className="hover:text-primary">
{t('nav.reactLeaflet')}
</a>
</nav>
<LanguageSwitcher />
</div>
<nav className="flex items-center gap-3 text-sm text-slate-500">
<a href="https://www.openstreetmap.org/" target="_blank" rel="noreferrer" className="hover:text-primary">
OpenStreetMap
</a>
<a href="https://react-leaflet.js.org/" target="_blank" rel="noreferrer" className="hover:text-primary">
React Leaflet
</a>
</nav>
</div>
</header>
<main className="flex flex-1 overflow-hidden">

View File

@@ -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<Visit | null>(null);
const { t } = useTranslation();
useEffect(() => {
if (!isOpen) {
@@ -19,15 +21,15 @@ export function VisitSidebar() {
<aside className="flex h-full w-full flex-col gap-4 overflow-y-auto bg-slate-50 p-4">
<header className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold"></h2>
<p className="text-sm text-slate-500"></p>
<h2 className="text-xl font-semibold">{t('sidebar.title')}</h2>
<p className="text-sm text-slate-500">{t('sidebar.subtitle')}</p>
</div>
<button
type="button"
className="rounded bg-primary px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary/90"
onClick={() => open(initialLocation)}
>
{t('sidebar.add')}
</button>
</header>
@@ -45,7 +47,9 @@ export function VisitSidebar() {
{isOpen ? (
<section className="rounded-lg bg-white p-4 shadow-md">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">{editingVisit ? '編輯足跡' : '新增足跡'}</h3>
<h3 className="text-lg font-semibold">
{t(editingVisit ? 'sidebar.drawerTitleEdit' : 'sidebar.drawerTitleCreate')}
</h3>
<button
type="button"
className="rounded px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
@@ -54,7 +58,7 @@ export function VisitSidebar() {
close();
}}
>
{t('sidebar.close')}
</button>
</div>
<VisitForm

View File

@@ -5,12 +5,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
import type { Visit, VisitCreateInput } from '../types';
import { useCreateVisitMutation, useUpdateVisitMutation } from '../hooks/useVisitQueries';
import { useVisitDrawerStore } from '../../../state/useVisitDrawerStore';
import { useTranslation } from 'react-i18next';
const visitFormSchema = z
.object({
country: z.string().min(1, '請填寫國家'),
country: z.string().min(1, 'validation.countryRequired'),
city: z.string().optional(),
start: z.string().min(1, '請選擇開始日期'),
start: z.string().min(1, 'validation.startDateRequired'),
end: z.string().optional(),
notes: z.string().optional(),
lat: z.number(),
@@ -21,7 +22,7 @@ const visitFormSchema = z
if (!data.end) return true;
return new Date(data.end) >= new Date(data.start);
},
{ message: '結束日期需晚於開始日期', path: ['end'] }
{ message: 'validation.endAfterStart', path: ['end'] }
);
type VisitFormSchema = z.infer<typeof visitFormSchema>;
@@ -34,6 +35,7 @@ interface VisitFormProps {
export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
const closeDrawer = useVisitDrawerStore((state) => state.close);
const initialLocation = useVisitDrawerStore((state) => state.initialLocation);
const { t } = useTranslation();
const createMutation = useCreateVisitMutation();
const updateMutation = useUpdateVisitMutation();
@@ -104,63 +106,65 @@ export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="country">
{t('form.country')}
</label>
<input
id="country"
className="rounded border border-slate-300 px-3 py-2"
placeholder="例如:日本"
placeholder={t('form.countryPlaceholder')}
{...register('country')}
/>
{errors.country ? <p className="text-sm text-rose-500">{errors.country.message}</p> : null}
{errors.country ? (
<p className="text-sm text-rose-500">{t(errors.country.message ?? '')}</p>
) : null}
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="city">
{t('form.city')}
</label>
<input
id="city"
className="rounded border border-slate-300 px-3 py-2"
placeholder="例如:東京"
placeholder={t('form.cityPlaceholder')}
{...register('city')}
/>
{errors.city ? <p className="text-sm text-rose-500">{errors.city.message}</p> : null}
{errors.city ? <p className="text-sm text-rose-500">{t(errors.city.message ?? '')}</p> : null}
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="start">
{t('form.startDate')}
</label>
<input id="start" type="date" className="rounded border border-slate-300 px-3 py-2" {...register('start')} />
{errors.start ? <p className="text-sm text-rose-500">{errors.start.message}</p> : null}
{errors.start ? <p className="text-sm text-rose-500">{t(errors.start.message ?? '')}</p> : null}
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="end">
{t('form.endDate')}
</label>
<input id="end" type="date" className="rounded border border-slate-300 px-3 py-2" {...register('end')} />
{errors.end ? <p className="text-sm text-rose-500">{errors.end.message}</p> : null}
{errors.end ? <p className="text-sm text-rose-500">{t(errors.end.message ?? '')}</p> : null}
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="notes">
{t('form.notes')}
</label>
<textarea
id="notes"
className="min-h-[96px] rounded border border-slate-300 px-3 py-2"
placeholder="留下旅程的感想或提醒"
placeholder={t('form.notesPlaceholder')}
{...register('notes')}
/>
{errors.notes ? <p className="text-sm text-rose-500">{errors.notes.message}</p> : null}
{errors.notes ? <p className="text-sm text-rose-500">{t(errors.notes.message ?? '')}</p> : null}
</div>
<div className="grid gap-2 md:grid-cols-2">
<div>
<label className="text-sm font-medium" htmlFor="lat">
{t('form.latitude')}
</label>
<input
id="lat"
@@ -169,11 +173,11 @@ export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
className="w-full rounded border border-slate-300 px-3 py-2"
{...register('lat', { valueAsNumber: true })}
/>
{errors.lat ? <p className="text-sm text-rose-500">{errors.lat.message}</p> : null}
{errors.lat ? <p className="text-sm text-rose-500">{t(errors.lat.message ?? '')}</p> : null}
</div>
<div>
<label className="text-sm font-medium" htmlFor="lng">
{t('form.longitude')}
</label>
<input
id="lng"
@@ -182,7 +186,7 @@ export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
className="w-full rounded border border-slate-300 px-3 py-2"
{...register('lng', { valueAsNumber: true })}
/>
{errors.lng ? <p className="text-sm text-rose-500">{errors.lng.message}</p> : null}
{errors.lng ? <p className="text-sm text-rose-500">{t(errors.lng.message ?? '')}</p> : null}
</div>
</div>
@@ -192,14 +196,14 @@ export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
className="rounded border border-slate-300 px-4 py-2"
onClick={() => closeDrawer()}
>
{t('form.cancel')}
</button>
<button
type="submit"
disabled={isSubmitting}
className="rounded bg-primary px-4 py-2 font-semibold text-white hover:bg-primary/90 disabled:opacity-60"
>
{editingVisit ? '儲存變更' : '新增足跡'}
{editingVisit ? t('form.update') : t('form.create')}
</button>
</div>
</form>

View File

@@ -1,7 +1,5 @@
import {
useDeleteVisitMutation,
useVisitsQuery
} from '../hooks/useVisitQueries';
import { useTranslation } from 'react-i18next';
import { useDeleteVisitMutation, useVisitsQuery } from '../hooks/useVisitQueries';
import type { Visit } from '../types';
interface VisitListProps {
@@ -11,13 +9,14 @@ interface VisitListProps {
export function VisitList({ onEdit }: VisitListProps) {
const { data: visits = [], isLoading } = useVisitsQuery();
const deleteMutation = useDeleteVisitMutation();
const { t } = useTranslation();
if (isLoading) {
return <p className="text-sm text-slate-500">...</p>;
return <p className="text-sm text-slate-500">{t('list.loading')}</p>;
}
if (!visits.length) {
return <p className="text-sm text-slate-500"></p>;
return <p className="text-sm text-slate-500">{t('list.empty')}</p>;
}
return (
@@ -42,14 +41,14 @@ export function VisitList({ onEdit }: VisitListProps) {
className="rounded bg-slate-100 px-2 py-1 hover:bg-slate-200"
onClick={() => onEdit(visit)}
>
{t('list.edit')}
</button>
<button
type="button"
className="rounded bg-rose-100 px-2 py-1 text-rose-600 hover:bg-rose-200"
onClick={() => deleteMutation.mutate(visit.id)}
>
{t('list.delete')}
</button>
</div>
</div>

View File

@@ -1,8 +1,10 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useVisitsQuery } from '../hooks/useVisitQueries';
export function VisitSummary() {
const { data: visits = [] } = useVisitsQuery();
const { t } = useTranslation();
const stats = useMemo(() => {
const countries = new Set<string>();
@@ -15,14 +17,14 @@ export function VisitSummary() {
return (
<div className="rounded-lg bg-white p-4 shadow-sm">
<h3 className="text-sm font-semibold text-slate-500"></h3>
<h3 className="text-sm font-semibold text-slate-500">{t('summary.title')}</h3>
<dl className="mt-3 grid grid-cols-2 gap-3 text-center">
<div className="rounded bg-slate-100 p-3">
<dt className="text-xs uppercase tracking-wide text-slate-500"></dt>
<dt className="text-xs uppercase tracking-wide text-slate-500">{t('summary.totalVisits')}</dt>
<dd className="text-xl font-bold">{stats.totalVisits}</dd>
</div>
<div className="rounded bg-slate-100 p-3">
<dt className="text-xs uppercase tracking-wide text-slate-500"></dt>
<dt className="text-xs uppercase tracking-wide text-slate-500">{t('summary.countries')}</dt>
<dd className="text-xl font-bold">{stats.uniqueCountries}</dd>
</div>
</dl>

60
client/src/i18n.ts Normal file
View File

@@ -0,0 +1,60 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import enTranslation from './locales/en/translation.json';
import zhHantTranslation from './locales/zh-Hant/translation.json';
export const SUPPORTED_LANGUAGES = ['zh-Hant', 'en'] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
const STORAGE_KEY = 'traveling-around-the-world::language';
const resources = {
'zh-Hant': { translation: zhHantTranslation },
en: { translation: enTranslation }
} as const;
function isSupportedLanguage(language: string): language is SupportedLanguage {
return (SUPPORTED_LANGUAGES as readonly string[]).includes(language);
}
function getInitialLanguage(): SupportedLanguage {
if (typeof window === 'undefined') {
return 'zh-Hant';
}
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored && isSupportedLanguage(stored)) {
return stored;
}
const browserLanguage = window.navigator.language.toLowerCase();
if (browserLanguage.startsWith('zh')) {
return 'zh-Hant';
}
return 'en';
}
void i18n
.use(initReactI18next)
.init({
resources,
lng: getInitialLanguage(),
fallbackLng: 'zh-Hant',
supportedLngs: [...SUPPORTED_LANGUAGES],
interpolation: {
escapeValue: false
}
})
.catch((error) => {
console.error('Failed to initialize i18n', error);
});
if (typeof window !== 'undefined') {
i18n.on('languageChanged', (language) => {
if (isSupportedLanguage(language)) {
window.localStorage.setItem(STORAGE_KEY, language);
}
});
}
export default i18n;

View File

@@ -0,0 +1,54 @@
{
"common": {
"appName": "Traveling Around The World",
"tagline": "Track every place you have been"
},
"nav": {
"openStreetMap": "OpenStreetMap",
"reactLeaflet": "React Leaflet"
},
"language": {
"label": "Language",
"zhHant": "繁體中文",
"en": "English"
},
"sidebar": {
"title": "My Journeys",
"subtitle": "Click anywhere on the map to start recording",
"add": "Add Visit",
"drawerTitleCreate": "Add Visit",
"drawerTitleEdit": "Edit Visit",
"close": "Close"
},
"summary": {
"title": "Stats",
"totalVisits": "Trips",
"countries": "Countries"
},
"list": {
"loading": "Loading...",
"empty": "No trips recorded yet.",
"edit": "Edit",
"delete": "Delete"
},
"form": {
"country": "Country",
"countryPlaceholder": "e.g. Japan",
"city": "City",
"cityPlaceholder": "e.g. Tokyo",
"startDate": "Start Date",
"endDate": "End Date",
"notes": "Notes",
"notesPlaceholder": "Capture thoughts or reminders about the trip",
"latitude": "Latitude",
"longitude": "Longitude",
"cancel": "Cancel",
"create": "Add Visit",
"update": "Save Changes"
},
"validation": {
"countryRequired": "Country is required",
"startDateRequired": "Please choose a start date",
"endAfterStart": "End date must be after the start date"
}
}

View File

@@ -0,0 +1,54 @@
{
"common": {
"appName": "Traveling Around The World",
"tagline": "紀錄你走過的每一步足跡"
},
"nav": {
"openStreetMap": "OpenStreetMap",
"reactLeaflet": "React Leaflet"
},
"language": {
"label": "語言",
"zhHant": "繁體中文",
"en": "English"
},
"sidebar": {
"title": "我的旅程",
"subtitle": "點擊地圖任意位置開始記錄",
"add": "新增足跡",
"drawerTitleCreate": "新增足跡",
"drawerTitleEdit": "編輯足跡",
"close": "關閉"
},
"summary": {
"title": "統計",
"totalVisits": "旅程數",
"countries": "國家數"
},
"list": {
"loading": "讀取中...",
"empty": "目前尚未新增任何旅遊記錄。",
"edit": "編輯",
"delete": "刪除"
},
"form": {
"country": "國家",
"countryPlaceholder": "例如:日本",
"city": "城市",
"cityPlaceholder": "例如:東京",
"startDate": "開始日期",
"endDate": "結束日期",
"notes": "備註",
"notesPlaceholder": "留下旅程的感想或提醒",
"latitude": "緯度",
"longitude": "經度",
"cancel": "取消",
"create": "新增足跡",
"update": "儲存變更"
},
"validation": {
"countryRequired": "請填寫國家",
"startDateRequired": "請選擇開始日期",
"endAfterStart": "結束日期需晚於開始日期"
}
}

View File

@@ -1,6 +1,7 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import './i18n';
import './styles/global.css';
const container = document.getElementById('root');