Nothing in the project was translatable — every string sat inline in English. en.ts is the source dictionary and its type is derived from it, so a typo or a missing key fails typecheck instead of rendering the raw key at runtime. pt-BR.ts is deliberately Partial: translation proceeds one system per update and anything absent falls back to English, so a half-migrated interface is never broken, only partly English. Locale is persisted, guessed from the browser on first run, and kept in sync with <html lang> through a subscription — persisted state rehydrates after first paint, so a one-off assignment would miss it. Translates the voice input panel (including the mic test shipped earlier today) and the profile card as this round's system. Language options are labelled in the active language, so a wrong pick can always be undone.
79 lines
2.4 KiB
TypeScript
79 lines
2.4 KiB
TypeScript
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
import { en, type TranslationKey } from './locales/en';
|
|
import { ptBR } from './locales/pt-BR';
|
|
|
|
export const LOCALES = ['en', 'pt-BR'] as const;
|
|
export type Locale = (typeof LOCALES)[number];
|
|
|
|
const DICTIONARIES: Record<Locale, Partial<Record<TranslationKey, string>>> = {
|
|
en,
|
|
'pt-BR': ptBR,
|
|
};
|
|
|
|
/**
|
|
* First-run guess from the browser. Persisted afterwards, so an explicit
|
|
* choice always wins over the browser's setting on later visits.
|
|
*/
|
|
function detectLocale(): Locale {
|
|
if (typeof navigator === 'undefined') return 'en';
|
|
return navigator.language?.toLowerCase().startsWith('pt') ? 'pt-BR' : 'en';
|
|
}
|
|
|
|
interface LocaleState {
|
|
locale: Locale;
|
|
setLocale: (locale: Locale) => void;
|
|
}
|
|
|
|
export const useLocaleStore = create<LocaleState>()(
|
|
persist(
|
|
(set) => ({
|
|
locale: detectLocale(),
|
|
setLocale: (locale) => set({ locale }),
|
|
}),
|
|
{ name: 'backspace-locale' },
|
|
),
|
|
);
|
|
|
|
// Keep <html lang> in sync: screen readers, spellcheck and hyphenation all read
|
|
// it, and persisted state rehydrates after the first paint — hence the
|
|
// subscription rather than a one-off assignment.
|
|
if (typeof document !== 'undefined') {
|
|
document.documentElement.lang = useLocaleStore.getState().locale;
|
|
useLocaleStore.subscribe((state) => {
|
|
document.documentElement.lang = state.locale;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolves a key, substituting `{name}` placeholders.
|
|
*
|
|
* Falls back to English, then to the key itself. The key is a deliberate last
|
|
* resort: it is ugly on screen, which makes a missing entry obvious in review
|
|
* instead of silently rendering an empty string.
|
|
*/
|
|
export function translate(
|
|
locale: Locale,
|
|
key: TranslationKey,
|
|
params?: Record<string, string | number>,
|
|
): string {
|
|
const template = DICTIONARIES[locale]?.[key] ?? en[key] ?? key;
|
|
if (!params) return template;
|
|
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
|
name in params ? String(params[name]) : match,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Subscribes the calling component to the active locale, so switching language
|
|
* re-renders it. Components that only need the string once (outside React) can
|
|
* call `translate` with `useLocaleStore.getState().locale` instead.
|
|
*/
|
|
export function useT() {
|
|
const locale = useLocaleStore((s) => s.locale);
|
|
return (key: TranslationKey, params?: Record<string, string | number>) =>
|
|
translate(locale, key, params);
|
|
}
|
|
|
|
export type { TranslationKey };
|