mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7
be/apps/dashboard/src/@types/constants.ts
Normal file
7
be/apps/dashboard/src/@types/constants.ts
Normal 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
|
||||
10
be/apps/dashboard/src/@types/i18next.d.ts
vendored
Normal file
10
be/apps/dashboard/src/@types/i18next.d.ts
vendored
Normal 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']
|
||||
}
|
||||
}
|
||||
13
be/apps/dashboard/src/@types/resources.ts
Normal file
13
be/apps/dashboard/src/@types/resources.ts
Normal 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>>>
|
||||
@@ -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>
|
||||
|
||||
35
be/apps/dashboard/src/i18n.ts
Normal file
35
be/apps/dashboard/src/i18n.ts
Normal 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)
|
||||
44
be/apps/dashboard/src/lib/event-bus.ts
Normal file
44
be/apps/dashboard/src/lib/event-bus.ts
Normal 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>()
|
||||
32
be/apps/dashboard/src/providers/i18n-provider.tsx
Normal file
32
be/apps/dashboard/src/providers/i18n-provider.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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/**/*"
|
||||
]
|
||||
}
|
||||
@@ -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
13
locales/dashboard/en.json
Normal 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"
|
||||
}
|
||||
13
locales/dashboard/zh-CN.json
Normal file
13
locales/dashboard/zh-CN.json
Normal 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
23
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user