feat(i18n): integrate internationalization support into dashboard

- Added i18next and react-i18next for localization support.
- Created i18n configuration in `src/i18n.ts` and set up language detection.
- Introduced `I18nProvider` to manage i18n context and resource updates.
- Added localization files for English and Chinese (Simplified) with necessary translations.
- Updated `ErrorElement` component to utilize translations for error messages.
- Enhanced dashboard documentation with i18n usage guidelines.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-18 01:31:06 +08:00
parent c8b7fcc882
commit d7084a3201
16 changed files with 236 additions and 24 deletions

View File

@@ -192,6 +192,7 @@ class PhotoLoader {
2. Keep components focused - use hooks and component composition.
3. Follow React best practices - proper Context usage, state management.
4. Use TypeScript strictly - leverage type safety throughout.
5. Build React features out of small, atomic components. Push data fetching, stores, and providers down to the feature or tab that actually needs them so switching views unmounts unused logic and prevents runaway updates instead of centralizing everything in a mega component.
### i18n Guidelines

View File

@@ -47,11 +47,20 @@ Providers:
- LazyMotion + MotionConfig
- TanStack QueryClientProvider
- Jotai Provider with a global store
- I18nProvider (react-i18next)
- Event, Context menu, and settings sync providers
- StableRouterProvider to stabilize routing data and navigation
- ModalContainer and Toaster
- Add new cross-cutting providers here, keeping order and side effects in mind.
### i18n usage
- Localization lives under `locales/dashboard/*.json`. Follow the same flat-key rules documented in the repo root AGENTS instructions (no nested parents vs. leaf conflicts). Update English first before translating other languages.
- Resource metadata/types live in `src/@types/constants.ts`, `src/@types/resources.ts`, and `src/@types/i18next.d.ts`. Keep these files in sync when adding new locales or namespaces.
- `src/i18n.ts` configures `i18next` with `react-i18next` + `i18next-browser-languagedetector`. The singleton is stored in a jotai atom for hot-refresh support.
- Use `useTranslation()` from `react-i18next` inside components. Example: `const { t } = useTranslation(); <span>{t('nav.overview')}</span>`.
- Trigger `EventBus.dispatch('I18N_UPDATE')` in development when you need to reload resources without a full refresh.
Animation rules:
- Always use m.\* components imported from motion/react.

View File

@@ -42,6 +42,8 @@
"clsx": "2.1.1",
"es-toolkit": "1.41.0",
"foxact": "0.2.49",
"i18next": "25.6.2",
"i18next-browser-languagedetector": "8.2.0",
"immer": "10.2.0",
"jotai": "2.15.1",
"lucide-react": "0.553.0",
@@ -51,6 +53,7 @@
"radix-ui": "1.4.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-i18next": "16.3.1",
"react-router": "7.9.5",
"react-scan": "0.4.3",
"sonner": "2.0.7",
@@ -105,4 +108,4 @@
"eslint --fix"
]
}
}
}

View File

@@ -0,0 +1,7 @@
const langs = ['en', 'zh-CN'] as const
export const currentSupportedLanguages = [...langs].sort() as string[]
export type DashboardSupportedLanguages = (typeof langs)[number]
export const ns = ['dashboard'] as const
export const defaultNS = 'dashboard' as const

View File

@@ -0,0 +1,10 @@
import type { defaultNS, ns } from './constants'
import type { resources } from './resources'
declare module 'i18next' {
interface CustomTypeOptions {
ns: typeof ns
defaultNS: typeof defaultNS
resources: (typeof resources)['en']
}
}

View File

@@ -0,0 +1,13 @@
import en from '@locales/dashboard/en.json'
import zhCn from '@locales/dashboard/zh-CN.json'
import type { DashboardSupportedLanguages, ns } from './constants'
export const resources = {
en: {
dashboard: en,
},
'zh-CN': {
dashboard: zhCn,
},
} satisfies Record<DashboardSupportedLanguages, Record<(typeof ns)[number], Record<string, string>>>

View File

@@ -1,12 +1,14 @@
import { Button } from '@afilmory/ui'
import { repository } from '@pkg'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { isRouteErrorResponse, useRouteError } from 'react-router'
import { attachOpenInEditor } from '~/lib/dev'
export function ErrorElement() {
const error = useRouteError()
const { t } = useTranslation()
const message = isRouteErrorResponse(error)
? `${error.status} ${error.statusText}`
: error instanceof Error
@@ -55,8 +57,8 @@ export function ErrorElement() {
/>
</svg>
</div>
<h1 className="text-text mb-2 text-3xl font-medium">Something went wrong</h1>
<p className="text-text-secondary text-lg">We encountered an unexpected error</p>
<h1 className="text-text mb-2 text-3xl font-medium">{t('error.boundary.title')}</h1>
<p className="text-text-secondary text-lg">{t('error.boundary.description')}</p>
</div>
{/* Error message */}
@@ -81,19 +83,19 @@ export function ErrorElement() {
onClick={() => (window.location.href = '/')}
className="bg-material-opaque text-text-vibrant hover:bg-control-enabled/90 h-10 flex-1 border-0 font-medium transition-colors"
>
Reload Application
{t('error.boundary.reload')}
</Button>
<Button
onClick={() => window.history.back()}
className="bg-material-thin text-text border-fill-tertiary hover:bg-fill-tertiary h-10 flex-1 border font-medium transition-colors"
>
Go Back
{t('error.boundary.go-back')}
</Button>
</div>
{/* Help text */}
<div className="text-center">
<p className="text-text-secondary mb-3 text-sm">If this problem persists, please report it to our team.</p>
<p className="text-text-secondary mb-3 text-sm">{t('error.boundary.help')}</p>
<a
href={`${repository.url}/issues/new?title=${encodeURIComponent(
`Error: ${message}`,
@@ -107,7 +109,7 @@ export function ErrorElement() {
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0C5.374 0 0 5.373 0 12 0 17.302 3.438 21.8 8.207 23.387c.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z" />
</svg>
Report on GitHub
{t('error.boundary.report')}
</a>
</div>
</div>

View File

@@ -0,0 +1,35 @@
import i18next from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import { atom } from 'jotai'
import { initReactI18next } from 'react-i18next'
import { currentSupportedLanguages, defaultNS, ns } from './@types/constants'
import { resources } from './@types/resources'
import { jotaiStore } from './lib/jotai'
const i18n = i18next.createInstance()
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: {
default: ['en'],
},
supportedLngs: currentSupportedLanguages,
defaultNS,
ns,
resources,
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator', 'htmlTag'],
caches: ['localStorage'],
},
returnNull: false,
})
export const i18nAtom = atom(i18n)
export const getI18n = () => jotaiStore.get(i18nAtom)

View File

@@ -0,0 +1,44 @@
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface CustomEvent {}
export interface EventBusMap extends CustomEvent {}
class EventBusEvent extends Event {
static type = 'EventBusEvent'
constructor(
public _type: string,
public data: any,
) {
super(EventBusEvent.type)
}
}
type IDispatcher<E> = <T extends keyof E>(...args: E[T] extends never ? [event: T] : [event: T, data: E[T]]) => void
type AnyObject = Record<string, any>
class EventBusStatic<E extends AnyObject> {
constructor() {
this.dispatch = this.dispatch.bind(this)
this.subscribe = this.subscribe.bind(this)
this.unsubscribe = this.unsubscribe.bind(this)
}
dispatch: IDispatcher<E> = <T extends keyof E>(event: T, data?: E[T]) => {
window.dispatchEvent(new EventBusEvent(event as string, data))
}
subscribe<T extends keyof E>(event: T, callback: (data: E[T]) => void) {
const handler = (e: any) => {
if (e instanceof EventBusEvent && e._type === event) {
callback(e.data)
}
}
window.addEventListener(EventBusEvent.type, handler)
return this.unsubscribe.bind(this, event as string, handler)
}
unsubscribe(_event: string, handler: (e: any) => void) {
window.removeEventListener(EventBusEvent.type, handler)
}
}
export const EventBus = new EventBusStatic<EventBusMap>()
export const createEventBus = <E extends AnyObject>() => new EventBusStatic<E>()

View File

@@ -0,0 +1,32 @@
import i18next from 'i18next'
import { useAtom } from 'jotai'
import type { FC, PropsWithChildren } from 'react'
import { useEffect } from 'react'
import { I18nextProvider } from 'react-i18next'
import { EventBus } from '~/lib/event-bus'
import { i18nAtom } from '../i18n'
export const I18nProvider: FC<PropsWithChildren> = ({ children }) => {
const [currentI18nInstance, setInstance] = useAtom(i18nAtom)
useEffect(() => {
if (!import.meta.env.DEV) {
return
}
return EventBus.subscribe('I18N_UPDATE', () => {
const nextI18n = i18next.cloneInstance({})
setInstance(nextI18n)
})
}, [setInstance])
return <I18nextProvider i18n={currentI18nInstance}>{children}</I18nextProvider>
}
declare module '~/lib/event-bus' {
interface CustomEvent {
I18N_UPDATE: string
}
}

View File

@@ -11,6 +11,7 @@ import { queryClient } from '~/lib/query-client'
import { ContextMenuProvider } from './context-menu-provider'
import { EventProvider } from './event-provider'
import { I18nProvider } from './i18n-provider'
import { StableRouterProvider } from './stable-router-provider'
const loadFeatures = () => import('../framer-lazy-feature').then((res) => res.default)
@@ -19,12 +20,14 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
<MotionConfig transition={Spring.presets.smooth}>
<QueryClientProvider client={queryClient}>
<Provider store={jotaiStore}>
<EventProvider />
<StableRouterProvider />
<I18nProvider>
<EventProvider />
<StableRouterProvider />
<ContextMenuProvider />
<ModalContainer />
{children}
<ContextMenuProvider />
<ModalContainer />
{children}
</I18nProvider>
</Provider>
</QueryClientProvider>
</MotionConfig>

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
@@ -17,9 +21,18 @@
"noEmit": true,
"jsx": "preserve",
"paths": {
"~/*": ["./src/*"],
"@pkg": ["./package.json"]
"~/*": [
"./src/*"
],
"@pkg": [
"./package.json"
],
"@locales/*": [
"../../../locales/*"
]
}
},
"include": ["./src/**/*"]
}
"include": [
"./src/**/*"
]
}

View File

@@ -46,6 +46,11 @@ export default defineConfig({
APP_DEV_CWD: JSON.stringify(process.cwd()),
APP_NAME: JSON.stringify(PKG.name),
},
resolve: {
alias: {
'@locales': resolve(ROOT, '../../../locales'),
},
},
server: {
cors: {
origin: true,

13
locales/dashboard/en.json Normal file
View File

@@ -0,0 +1,13 @@
{
"app.name": "Afilmory Dashboard",
"error.boundary.description": "We encountered an unexpected error",
"error.boundary.go-back": "Go Back",
"error.boundary.help": "If this problem persists, please report it to our team.",
"error.boundary.reload": "Reload Application",
"error.boundary.report": "Report on GitHub",
"error.boundary.title": "Something went wrong",
"nav.library": "Library",
"nav.overview": "Overview",
"nav.settings": "Settings",
"nav.users": "Users"
}

View File

@@ -0,0 +1,13 @@
{
"app.name": "Afilmory 管理后台",
"error.boundary.description": "我们遇到了一个意料之外的错误",
"error.boundary.go-back": "返回上一页",
"error.boundary.help": "如果问题持续出现,请反馈给我们的团队。",
"error.boundary.reload": "重新加载",
"error.boundary.report": "在 GitHub 上反馈",
"error.boundary.title": "系统出现问题",
"nav.library": "图库",
"nav.overview": "概览",
"nav.settings": "设置",
"nav.users": "成员"
}

23
pnpm-lock.yaml generated
View File

@@ -1085,6 +1085,12 @@ importers:
foxact:
specifier: 0.2.49
version: 0.2.49(react@19.2.0)
i18next:
specifier: 25.6.2
version: 25.6.2(typescript@5.9.3)
i18next-browser-languagedetector:
specifier: 8.2.0
version: 8.2.0
immer:
specifier: 10.2.0
version: 10.2.0
@@ -1112,6 +1118,9 @@ importers:
react-dom:
specifier: 19.2.0
version: 19.2.0(react@19.2.0)
react-i18next:
specifier: 16.3.1
version: 16.3.1(i18next@25.6.2(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react-router:
specifier: 7.9.5
version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -12303,7 +12312,7 @@ snapshots:
'@ant-design/cssinjs@1.23.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@babel/runtime': 7.28.2
'@babel/runtime': 7.28.4
'@emotion/hash': 0.8.0
'@emotion/unitless': 0.7.5
classnames: 2.5.1
@@ -13777,7 +13786,7 @@ snapshots:
'@emotion/babel-plugin@11.13.5':
dependencies:
'@babel/helper-module-imports': 7.27.1
'@babel/runtime': 7.28.2
'@babel/runtime': 7.28.4
'@emotion/hash': 0.9.2
'@emotion/memoize': 0.9.0
'@emotion/serialize': 1.3.3
@@ -13816,7 +13825,7 @@ snapshots:
'@emotion/react@11.14.0(@types/react@19.2.3)(react@19.2.0)':
dependencies:
'@babel/runtime': 7.28.2
'@babel/runtime': 7.28.4
'@emotion/babel-plugin': 11.13.5
'@emotion/cache': 11.14.0
'@emotion/serialize': 1.3.3
@@ -18193,7 +18202,7 @@ snapshots:
babel-plugin-macros@3.1.0:
dependencies:
'@babel/runtime': 7.28.2
'@babel/runtime': 7.28.4
cosmiconfig: 7.1.0
resolve: 1.22.11
@@ -20517,7 +20526,7 @@ snapshots:
i18next-browser-languagedetector@8.2.0:
dependencies:
'@babel/runtime': 7.28.2
'@babel/runtime': 7.28.4
i18next@25.6.2(typescript@5.9.3):
dependencies:
@@ -22791,7 +22800,7 @@ snapshots:
rc-util@5.44.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@babel/runtime': 7.28.2
'@babel/runtime': 7.28.4
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
react-is: 18.3.1
@@ -23489,7 +23498,7 @@ snapshots:
rtl-css-js@1.16.1:
dependencies:
'@babel/runtime': 7.28.2
'@babel/runtime': 7.28.4
run-parallel@1.2.0:
dependencies: