From 0eee9aacccee09b8e7b18ac308f2255ef986475c Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 21 Sep 2026 08:33:59 +0000 Subject: [PATCH 01/12] refactor(i18n): centralize language metadata --- src/components/LanguageSwitcher.tsx | 23 ++++----- src/i18n.ts | 10 ++++ src/lib/languages.ts | 19 +++++++ src/lib/stores/settings-store.ts | 28 ++++++----- src/pages/SettingsPage.tsx | 17 ++++--- src/test/i18n.test.ts | 32 ++++++++++++ .../stores/settings-store-branches.test.ts | 49 +++++++++++++++++++ 7 files changed, 144 insertions(+), 34 deletions(-) create mode 100644 src/lib/languages.ts diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx index 19b7bf99a..14cd245e6 100644 --- a/src/components/LanguageSwitcher.tsx +++ b/src/components/LanguageSwitcher.tsx @@ -5,12 +5,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; - -const languages = [ - { value: 'en', label: 'English' }, - { value: 'es', label: 'Español' }, - { value: 'hu', label: 'Magyar' }, -] as const; +import { APP_LANGUAGES, isAppLanguage } from '@/lib/languages'; export function LanguageSwitcher() { const { t } = useTranslation(); @@ -31,14 +26,14 @@ export function LanguageSwitcher() {
- isAppLanguage(value) && setLanguage(value)}> - {languages.map((lang) => ( - - {lang.label} + {Object.entries(APP_LANGUAGES).map(([value, language]) => ( + + {language.label} ))} @@ -46,14 +41,14 @@ export function LanguageSwitcher() {
- isAppLanguage(value) && setOutputLanguage(value)}> - {languages.map((lang) => ( - - {lang.label} + {Object.entries(APP_LANGUAGES).map(([value, language]) => ( + + {language.label} ))} diff --git a/src/i18n.ts b/src/i18n.ts index d372ebdc9..90dca2839 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -3,6 +3,7 @@ import { initReactI18next } from 'react-i18next'; import en from './locales/en.json'; import es from './locales/es.json'; import hu from './locales/hu.json'; +import { languageName, type AppLanguage } from './lib/languages'; i18n.use(initReactI18next).init({ resources: { en: { translation: en }, es: { translation: es }, hu: { translation: hu } }, @@ -11,4 +12,13 @@ i18n.use(initReactI18next).init({ interpolation: { escapeValue: false }, }); +export async function applyAppLanguage(language: AppLanguage): Promise { + await i18n.changeLanguage(language); + if (typeof document === 'undefined') return; + + document.documentElement.lang = language; + document.querySelector('meta[name="language"]')?.setAttribute('content', languageName(language)); + document.title = i18n.t('home.title'); +} + export default i18n; diff --git a/src/lib/languages.ts b/src/lib/languages.ts new file mode 100644 index 000000000..04950d4bf --- /dev/null +++ b/src/lib/languages.ts @@ -0,0 +1,19 @@ +export const APP_LANGUAGES = { + en: { label: 'English', intlLocale: 'en-GB', aiName: 'English' }, + es: { label: 'Español', intlLocale: 'es-ES', aiName: 'Spanish' }, + hu: { label: 'Magyar', intlLocale: 'hu-HU', aiName: 'Hungarian' }, +} as const; + +export type AppLanguage = keyof typeof APP_LANGUAGES; + +export function isAppLanguage(value: unknown): value is AppLanguage { + return typeof value === 'string' && Object.prototype.hasOwnProperty.call(APP_LANGUAGES, value); +} + +export function toIntlLocale(language: AppLanguage): string { + return APP_LANGUAGES[language].intlLocale; +} + +export function languageName(language: AppLanguage): string { + return APP_LANGUAGES[language].aiName; +} diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index d226621ea..b54007787 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import i18n from '@/i18n'; +import { applyAppLanguage } from '@/i18n'; +import { isAppLanguage, type AppLanguage } from '@/lib/languages'; import { DEFAULT_MODEL, DEFAULT_PRO_MODEL } from '../ai/providers/types'; import type { ProviderConfig } from '../ai/providers/types'; import { @@ -14,15 +15,15 @@ type CoreFeatureTab = Exclude; type FeatureConfigs = Record & Partial>; interface SettingsState { - language: 'en' | 'es' | 'hu'; - outputLanguage: 'en' | 'es' | 'hu'; + language: AppLanguage; + outputLanguage: AppLanguage; theme: 'light' | 'dark' | 'system'; customSystemPrompt?: string; featureConfigs: FeatureConfigs; wikiResearchEnabled: boolean; - setLanguage: (lang: 'en' | 'es' | 'hu') => void; - setOutputLanguage: (lang: 'en' | 'es' | 'hu') => void; + setLanguage: (lang: AppLanguage) => void; + setOutputLanguage: (lang: AppLanguage) => void; setTheme: (theme: 'light' | 'dark' | 'system') => void; setFeatureConfig: (feature: FeatureTab, config: Partial) => void; getFeatureConfig: (feature: FeatureTab) => ProviderConfig; @@ -205,7 +206,7 @@ export const useSettingsStore = create()( setLanguage: (language) => { set({ language }); - void i18n.changeLanguage(language); + void applyAppLanguage(language); void upsertSettings({ language, output_language: get().outputLanguage, @@ -291,11 +292,11 @@ export const useSettingsStore = create()( ); const configsNeedMigration = !featureConfigsAreCanonical(data.feature_configs, mergedConfigs); - const language = (data.language as 'en' | 'es' | 'hu') || 'en'; - const outputLanguage = (data.output_language as 'en' | 'es' | 'hu') || get().outputLanguage || 'en'; + const language = isAppLanguage(data.language) ? data.language : 'en'; + const outputLanguage = isAppLanguage(data.output_language) ? data.output_language : 'en'; const theme = (data.theme as 'light' | 'dark' | 'system') || 'system'; const customSystemPrompt = data.custom_system_prompt || undefined; - await i18n.changeLanguage(language); + await applyAppLanguage(language); set({ language, outputLanguage, @@ -317,6 +318,7 @@ export const useSettingsStore = create()( featureConfigs: getDefaultFeatureConfigs(), wikiResearchEnabled: false, }); + void applyAppLanguage('en'); }, }), { @@ -333,17 +335,19 @@ export const useSettingsStore = create()( } return { ...saved, + language: isAppLanguage(saved.language) ? saved.language : 'en', + outputLanguage: isAppLanguage(saved.outputLanguage) ? saved.outputLanguage : 'en', featureConfigs, }; }, onRehydrateStorage: () => (state) => { if (!state) return; state.featureConfigs = normalizeFeatureConfigs(state.featureConfigs, true); - if (!state.language) state.language = 'en'; - if (!state.outputLanguage) state.outputLanguage = 'en'; + if (!isAppLanguage(state.language)) state.language = 'en'; + if (!isAppLanguage(state.outputLanguage)) state.outputLanguage = 'en'; if (!state.theme) state.theme = 'system'; if (typeof state.wikiResearchEnabled !== 'boolean') state.wikiResearchEnabled = false; - void i18n.changeLanguage(state.language); + void applyAppLanguage(state.language); }, } ) diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 33eeaf12e..5e84d6666 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -27,6 +27,7 @@ import { PricingModal } from '@/components/features/subscription/PricingModal'; import { formatEuropeanDate } from '@/lib/date-format'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; +import { APP_LANGUAGES, isAppLanguage } from '@/lib/languages'; const tierLabels: Record = { @@ -191,27 +192,27 @@ export default function SettingsPage() {
- isAppLanguage(value) && setLanguage(value)}> - English - Español - Magyar + {Object.entries(APP_LANGUAGES).map(([value, languageOption]) => ( + {languageOption.label} + ))}
- isAppLanguage(value) && setOutputLanguage(value)}> - English - Español - Magyar + {Object.entries(APP_LANGUAGES).map(([value, languageOption]) => ( + {languageOption.label} + ))}
diff --git a/src/test/i18n.test.ts b/src/test/i18n.test.ts index 12979e362..09cfbbc02 100644 --- a/src/test/i18n.test.ts +++ b/src/test/i18n.test.ts @@ -3,6 +3,13 @@ import i18n from '@/i18n'; import en from '@/locales/en.json'; import es from '@/locales/es.json'; import hu from '@/locales/hu.json'; +import { + APP_LANGUAGES, + isAppLanguage, + languageName, + toIntlLocale, +} from '@/lib/languages'; +import { applyAppLanguage } from '@/i18n'; function flattenKeys(obj: Record, prefix = ''): string[] { return Object.entries(obj).flatMap(([key, value]) => { @@ -83,6 +90,31 @@ describe('i18n locale files', () => { }); describe('i18n runtime', () => { + it('defines the complete typed language catalog', () => { + expect(Object.keys(APP_LANGUAGES)).toEqual(['en', 'es', 'hu']); + expect(isAppLanguage('hu')).toBe(true); + expect(isAppLanguage('zz')).toBe(false); + expect(isAppLanguage(undefined)).toBe(false); + expect(toIntlLocale('hu')).toBe('hu-HU'); + expect(languageName('hu')).toBe('Hungarian'); + }); + + it('applies Hungarian to i18n and browser metadata', async () => { + const languageMeta = document.createElement('meta'); + languageMeta.setAttribute('name', 'language'); + document.head.append(languageMeta); + + await applyAppLanguage('hu'); + + expect(i18n.language).toBe('hu'); + expect(document.documentElement.lang).toBe('hu'); + expect(languageMeta.content).toBe('Hungarian'); + expect(document.title).toBe(hu.home.title); + + languageMeta.remove(); + await applyAppLanguage('en'); + }); + it('i18n.t resolves English nav values', () => { expect(i18n.t('nav.home')).toBe('Home'); expect(i18n.t('nav.generate')).toBe('Generate'); diff --git a/src/test/stores/settings-store-branches.test.ts b/src/test/stores/settings-store-branches.test.ts index ce9d33ce8..afbbb01bd 100644 --- a/src/test/stores/settings-store-branches.test.ts +++ b/src/test/stores/settings-store-branches.test.ts @@ -41,6 +41,38 @@ describe('settings-store branch coverage', () => { expect(state.featureConfigs.generate.model).toBe('openai/gpt-5.6-luna'); }); + it('hydrateFromServer accepts Hungarian language settings', async () => { + setMockTableResponse('user_settings', { + single: { + language: 'hu', + output_language: 'hu', + feature_configs: undefined, + }, + }); + + await useSettingsStore.getState().hydrateFromServer(); + + expect(useSettingsStore.getState().language).toBe('hu'); + expect(useSettingsStore.getState().outputLanguage).toBe('hu'); + expect(document.documentElement.lang).toBe('hu'); + }); + + it('hydrateFromServer rejects unsupported language settings', async () => { + setMockTableResponse('user_settings', { + single: { + language: 'zz', + output_language: 'zz', + feature_configs: undefined, + }, + }); + + await useSettingsStore.getState().hydrateFromServer(); + + expect(useSettingsStore.getState().language).toBe('en'); + expect(useSettingsStore.getState().outputLanguage).toBe('en'); + expect(document.documentElement.lang).toBe('en'); + }); + it('normalizes persisted state in onRehydrateStorage callback', () => { const onRehydrateStorage = useSettingsStore.persist.getOptions().onRehydrateStorage; expect(onRehydrateStorage).toBeTypeOf('function'); @@ -63,6 +95,23 @@ describe('settings-store branch coverage', () => { expect(partialState.wikiResearchEnabled).toBe(false); }); + it('normalizes unsupported locally persisted languages', () => { + const onRehydrateStorage = useSettingsStore.persist.getOptions().onRehydrateStorage; + const afterRehydrate = onRehydrateStorage?.(useSettingsStore.getState()); + if (!afterRehydrate) throw new Error('Expected a rehydration callback'); + + const partialState = { + featureConfigs: undefined, + language: 'zz', + outputLanguage: 'zz', + } as any; + + afterRehydrate(partialState); + + expect(partialState.language).toBe('en'); + expect(partialState.outputLanguage).toBe('en'); + }); + it('canonicalizes legacy local configs and removes client credentials', () => { const onRehydrateStorage = useSettingsStore.persist.getOptions().onRehydrateStorage; const afterRehydrate = onRehydrateStorage?.(useSettingsStore.getState()); From 826215ddf287c8c109e289ff2acba7b77f4df4f8 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 21 Sep 2026 08:42:52 +0000 Subject: [PATCH 02/12] feat(i18n): complete shared Hungarian interface copy --- src/App.tsx | 29 ++++++-- .../features/sparring/SparringRound.tsx | 2 +- src/components/landing/Footer.tsx | 2 +- src/components/landing/Navbar.tsx | 3 +- src/components/landing/UseCases.tsx | 2 +- src/components/layout/AppLayout.tsx | 6 +- src/components/ui/dialog.tsx | 10 ++- src/components/ui/sheet.tsx | 10 ++- src/components/ui/streaming-indicator.tsx | 4 +- src/components/ui/toast.tsx | 10 ++- src/locales/en.json | 22 +++++- src/locales/es.json | 74 ++++++++++++------- src/locales/hu.json | 74 ++++++++++++------- src/pages/CrossfirePage.tsx | 3 +- src/pages/SourcesPage.tsx | 11 +-- src/pages/TimerPage.tsx | 2 +- ...pp-route-analytics-error-boundary.test.tsx | 29 +++++++- src/test/components/AppLayout.test.tsx | 15 +++- .../components/StreamingIndicator.test.tsx | 25 ++++++- src/test/i18n.test.ts | 30 ++++++++ src/test/pages/SourcesPage.test.tsx | 13 ++++ 21 files changed, 284 insertions(+), 92 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index a4c1880c7..10237c792 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -121,7 +121,13 @@ export function getRouteFallback(pathname: string): RouteFallback { return ROUTE_FALLBACKS[normalizedPathname] ?? DEFAULT_ROUTE_FALLBACK; } -export class RouteErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { +interface RouteErrorBoundaryProps { + children: ReactNode; + heading?: string; + reloadLabel?: string; +} + +export class RouteErrorBoundary extends Component { state = { failed: false }; static getDerivedStateFromError() { @@ -137,9 +143,9 @@ export class RouteErrorBoundary extends Component<{ children: ReactNode }, { fai if (this.state.failed) { return (
-

Unable to load this page

+

{this.props.heading ?? 'Unable to load this page'}

); @@ -148,6 +154,15 @@ export class RouteErrorBoundary extends Component<{ children: ReactNode }, { fai } } +export function LocalizedRouteErrorBoundary({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + return ( + + {children} + + ); +} + export function ToolRouteAnalytics(): null { const location = useLocation(); @@ -167,11 +182,11 @@ function RouteContent({ children }: { children: ReactNode }) { const statusText = t("common.openingPage", { page: label }); return ( - + }> {children} - + ); } @@ -249,9 +264,9 @@ const App = () => ( - + - + diff --git a/src/components/features/sparring/SparringRound.tsx b/src/components/features/sparring/SparringRound.tsx index e08af424f..c4647bf99 100644 --- a/src/components/features/sparring/SparringRound.tsx +++ b/src/components/features/sparring/SparringRound.tsx @@ -188,7 +188,7 @@ export function SparringRound({ disabled={judging} />
-

{isOnline ? <>⌘ or Ctrl + Enter {t('sparring.round.toSubmit', 'to submit')} : t('sparring.round.offlineCannotSubmit', 'Reconnect to submit this speech.')}

+

{isOnline ? <>⌘ {t('common.or')} Ctrl + Enter {t('sparring.round.toSubmit', 'to submit')} : t('sparring.round.offlineCannotSubmit', 'Reconnect to submit this speech.')}

diff --git a/src/components/landing/Footer.tsx b/src/components/landing/Footer.tsx index e857a0830..e4897c4b2 100644 --- a/src/components/landing/Footer.tsx +++ b/src/components/landing/Footer.tsx @@ -11,7 +11,7 @@ const Footer = () => { DebateLab logo DebateLab diff --git a/src/components/landing/Navbar.tsx b/src/components/landing/Navbar.tsx index 7c5760e87..a64a6db3a 100644 --- a/src/components/landing/Navbar.tsx +++ b/src/components/landing/Navbar.tsx @@ -25,13 +25,14 @@ const Navbar = () => { animate={{ y: 0 }} transition={{ duration: 0.6, ease: "easeOut" }} className="fixed left-0 right-0 top-0 z-50 nav-blur" + aria-label={t('nav.mainNavigation')} >
DebateLab logo DebateLab diff --git a/src/components/landing/UseCases.tsx b/src/components/landing/UseCases.tsx index ba724af36..ed4712020 100644 --- a/src/components/landing/UseCases.tsx +++ b/src/components/landing/UseCases.tsx @@ -40,7 +40,7 @@ const UseCases = () => { {t('useCases.title')}

- Whether you are competing, coaching, or judging, DebateLab gives you the edge. + {t('useCases.description')}

diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 88e25b5cf..28567a438 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -194,7 +194,7 @@ export function AppLayout() {
setMobileMenuOpen(false)} className="flex items-center gap-3 shrink-0">
- DebateLab + {t('common.logoAlt')}

DebateLab

@@ -202,7 +202,7 @@ export function AppLayout() { {/* Desktop Nav */} -