Compare commits
4
Commits
3dd37d053b
...
b640ec486d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b640ec486d | ||
|
|
0bd8c6c16a | ||
|
|
6ac9187c52 | ||
|
|
dcd11a3b17 |
@@ -210,6 +210,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"hooks": "*",
|
||||
"i18n": "*",
|
||||
"input-otp": "^1.4.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.4",
|
||||
@@ -271,6 +272,7 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"hooks": "workspace:*",
|
||||
"i18n": "workspace:*",
|
||||
"state": "workspace:*",
|
||||
"widgets": "workspace:*",
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { X } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { SHIPPED_LOCALES } from '@/lib/locales';
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'en', label: 'English' },
|
||||
@@ -56,7 +57,8 @@ export const Languages = () => {
|
||||
const removeSpoken = (code: string) => {
|
||||
const next = spoken.filter((s) => s !== code);
|
||||
const updates = { ...settings.languages, spoken: next };
|
||||
if (defaultLang === code) updates.default = next[0] ?? 'en';
|
||||
// `default` (UI localization) draws from SHIPPED_LOCALES, not `spoken` — removing a spoken
|
||||
// language never leaves it without a valid value.
|
||||
if (translateTo === code) updates.translateTo = next[0] ?? 'en';
|
||||
save(updates);
|
||||
};
|
||||
@@ -104,7 +106,7 @@ export const Languages = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Default language */}
|
||||
{/* Default language — UI localization, so it only offers locales that actually ship (D2) */}
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">{t('settings.profile.languages.defaultLabel')}</span>
|
||||
<select
|
||||
@@ -112,9 +114,9 @@ export const Languages = () => {
|
||||
onChange={(ev) => save({ ...settings.languages, default: ev.target.value })}
|
||||
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
|
||||
>
|
||||
{spoken.map((code) => (
|
||||
{SHIPPED_LOCALES.map(({ code, label }) => (
|
||||
<option key={code} value={code}>
|
||||
{getLabel(code)}
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { TranslationMap } from 'i18n';
|
||||
import { I18nProvider } from 'i18n';
|
||||
import { I18nProvider, localeDirection } from 'i18n';
|
||||
import { panelCatalogs } from 'officerdev';
|
||||
import { uiCatalogs } from '@/components/locales';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import en from '../locales/en.json';
|
||||
import pt from '../locales/pt.json';
|
||||
import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales';
|
||||
import appEnGB from '../locales/en-GB.json';
|
||||
import appPtPT from '../locales/pt-PT.json';
|
||||
|
||||
const translations: TranslationMap = { en, pt };
|
||||
const appCatalogs: TranslationMap = { 'en-GB': appEnGB, 'pt-PT': appPtPT };
|
||||
|
||||
/**
|
||||
* Deep-merges the three per-workspace catalogs (D6) into one map per shipped locale — `app.*` from
|
||||
* officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components.
|
||||
*/
|
||||
const translations: TranslationMap = Object.fromEntries(
|
||||
SHIPPED_LOCALES.map(({ code }) => [
|
||||
code,
|
||||
{
|
||||
app: appCatalogs[code] ?? {},
|
||||
panel: panelCatalogs[code] ?? {},
|
||||
ui: uiCatalogs[code] ?? {},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
type I18nBridgeProps = {
|
||||
children: ReactNode;
|
||||
@@ -15,8 +34,14 @@ export const I18nBridge = ({ children }: I18nBridgeProps) => {
|
||||
const { settings } = useSettings();
|
||||
const locale = settings.languages.default;
|
||||
|
||||
// Prepared for RTL (D3) — every shipped locale is `ltr` today, but the wiring exists so a future
|
||||
// RTL catalog needs no second pass over the tree.
|
||||
useEffect(() => {
|
||||
document.documentElement.dir = localeDirection(locale);
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<I18nProvider translations={translations} locale={locale}>
|
||||
<I18nProvider translations={translations} locale={locale} fallbackLocale={FALLBACK_LOCALE}>
|
||||
{children}
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { TranslationKeys } from 'i18n';
|
||||
import type { InterpolationParams } from 'i18n';
|
||||
import { useTranslation as useTranslationBase } from 'i18n';
|
||||
import type en from '../locales/en.json';
|
||||
import type en from '../locales/en-GB.json';
|
||||
|
||||
type AppTranslationKey = TranslationKeys<typeof en>;
|
||||
|
||||
// The `app.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
|
||||
// pass the same bare key the JSON file uses.
|
||||
export const useTranslation = () => {
|
||||
const { t: baseT, locale } = useTranslationBase();
|
||||
const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(key, params);
|
||||
const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(`app.${key}`, params);
|
||||
return { t, locale };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* The UI locales that actually ship — regional BCP 47 tags, per docs/localization.md D2. This is the
|
||||
* one list `I18nBridge` merges catalogs for and `Languages.tsx` offers for UI localization; it is
|
||||
* deliberately separate from the broader "languages you speak" / "translate to" lists, which are a
|
||||
* different feature (see the doc's open question 4) and are not limited to locales with a catalog.
|
||||
*/
|
||||
export type ShippedLocale = { code: string; label: string };
|
||||
|
||||
export const SHIPPED_LOCALES: ShippedLocale[] = [
|
||||
{ code: 'en-GB', label: 'English (UK)' },
|
||||
{ code: 'pt-PT', label: 'Português (Portugal)' },
|
||||
];
|
||||
|
||||
export const FALLBACK_LOCALE = 'en-GB';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and pt-PT lacks is a red build,
|
||||
// not a warning — there is no regional fallback to paper over the gap.
|
||||
describe('officer-web (app.*) locale catalogs', () => {
|
||||
test('pt-PT has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { TranslationMap } from 'i18n';
|
||||
import enGB from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
|
||||
/** Shared components' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `ui.*`. */
|
||||
export const uiCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
|
||||
|
||||
export { useTranslation } from './useTranslation';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. Phase 0 scaffolds this catalog with no keys yet
|
||||
// (ui.* strings land in phase 1) — the test still runs so it is load-bearing from day one.
|
||||
describe('shared components (ui.*) locale catalogs', () => {
|
||||
test('pt-PT has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { InterpolationParams, TranslationKeys } from 'i18n';
|
||||
import { useTranslation as useTranslationBase } from 'i18n';
|
||||
import type en from './en-GB.json';
|
||||
|
||||
type UiTranslationKey = TranslationKeys<typeof en>;
|
||||
|
||||
// The `ui.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
|
||||
// pass the same bare key the JSON file uses. `ui.*` may not name a feature (see the naming convention).
|
||||
export const useTranslation = () => {
|
||||
const { t: baseT, locale } = useTranslationBase();
|
||||
const t = (key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params);
|
||||
return { t, locale };
|
||||
};
|
||||
@@ -45,6 +45,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"hooks": "*",
|
||||
"i18n": "*",
|
||||
"input-otp": "^1.4.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.4",
|
||||
|
||||
@@ -11,15 +11,19 @@ export const I18nContext = createContext<I18nContextValue>({
|
||||
type I18nProviderProps = {
|
||||
translations: TranslationMap;
|
||||
locale: string;
|
||||
/** The catalog `resolveKey` falls back to on a miss — a bug path, per D8, not a regional chain. */
|
||||
fallbackLocale?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const I18nProvider = ({ translations, locale, children }: I18nProviderProps) => {
|
||||
export const I18nProvider = ({ translations, locale, fallbackLocale = 'en', children }: I18nProviderProps) => {
|
||||
const value = useMemo<I18nContextValue>(() => {
|
||||
const t = (key: string, params?: InterpolationParams) =>
|
||||
interpolate(resolveKey(translations, locale, key), params);
|
||||
const t = (key: string, params?: InterpolationParams) => {
|
||||
const count = typeof params?.count === 'number' ? params.count : undefined;
|
||||
return interpolate(resolveKey(translations, locale, key, fallbackLocale, count), params);
|
||||
};
|
||||
return { t, locale };
|
||||
}, [translations, locale]);
|
||||
}, [translations, locale, fallbackLocale]);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export { I18nProvider, I18nContext } from './I18nProvider';
|
||||
export { useTranslation } from './useTranslation';
|
||||
export { resolveKey, interpolate } from './resolve';
|
||||
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys } from './types';
|
||||
export { useFormat } from './useFormat';
|
||||
export { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
||||
export { findMissingKeys } from './parity';
|
||||
export type { MissingKey } from './parity';
|
||||
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys, PluralForms } from './types';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { isPluralForms } from './resolve';
|
||||
|
||||
type Catalog = Record<string, unknown>;
|
||||
|
||||
const flattenKeys = (obj: Catalog, prefix = ''): string[] =>
|
||||
Object.entries(obj).flatMap(([k, v]) => {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
if (v !== null && typeof v === 'object' && !Array.isArray(v) && !isPluralForms(v)) {
|
||||
return flattenKeys(v as Catalog, path);
|
||||
}
|
||||
return [path];
|
||||
});
|
||||
|
||||
export type MissingKey = { locale: string; key: string };
|
||||
|
||||
/**
|
||||
* Per D2, every shipped locale is a COMPLETE catalog — no regional inheritance. So a key `en` has
|
||||
* and a locale's own file lacks is not a fallback case, it's a red build (docs/localization.md,
|
||||
* "Tooling and guardrails").
|
||||
*/
|
||||
export const findMissingKeys = (en: Catalog, locales: Record<string, Catalog>): MissingKey[] => {
|
||||
const enKeys = flattenKeys(en);
|
||||
return Object.entries(locales).flatMap(([locale, catalog]) => {
|
||||
const ownKeys = new Set(flattenKeys(catalog));
|
||||
return enKeys.filter((key) => !ownKeys.has(key)).map((key) => ({ locale, key }));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
||||
import { findMissingKeys } from './parity';
|
||||
import type { TranslationMap } from './types';
|
||||
|
||||
describe('resolveKey', () => {
|
||||
const translations: TranslationMap = {
|
||||
'pt-PT': { greeting: 'Olá {{name}}' },
|
||||
'en-GB': { greeting: 'Hello {{name}}', onlyInEnglish: 'English only' },
|
||||
};
|
||||
|
||||
test('resolves from the active locale', () => {
|
||||
expect(resolveKey(translations, 'pt-PT', 'greeting', 'en-GB')).toBe('Olá {{name}}');
|
||||
});
|
||||
|
||||
test('falls back to the fallback locale on a miss', () => {
|
||||
expect(resolveKey(translations, 'pt-PT', 'onlyInEnglish', 'en-GB')).toBe('English only');
|
||||
});
|
||||
|
||||
test('renders the raw key when even the fallback is missing', () => {
|
||||
expect(resolveKey(translations, 'pt-PT', 'nowhere', 'en-GB')).toBe('nowhere');
|
||||
});
|
||||
|
||||
test('never walks a chain between two non-fallback locales', () => {
|
||||
const withThird: TranslationMap = { ...translations, 'pt-BR': {} };
|
||||
// pt-BR has no key of its own and must NOT inherit pt-PT's — only the fallback locale counts.
|
||||
expect(resolveKey(withThird, 'pt-BR', 'greeting', 'en-GB')).toBe('Hello {{name}}');
|
||||
});
|
||||
|
||||
test('selects a plural branch via Intl.PluralRules', () => {
|
||||
const plurals: TranslationMap = {
|
||||
'en-GB': { files: { one: '{{count}} file', other: '{{count}} files' } },
|
||||
};
|
||||
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 1)).toBe('{{count}} file');
|
||||
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 2)).toBe('{{count}} files');
|
||||
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 0)).toBe('{{count}} files');
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolate', () => {
|
||||
test('substitutes params', () => {
|
||||
expect(interpolate('Hello {{name}}', { name: 'Ana' })).toBe('Hello Ana');
|
||||
});
|
||||
|
||||
test('leaves an unknown placeholder untouched', () => {
|
||||
expect(interpolate('Hello {{name}}', {})).toBe('Hello {{name}}');
|
||||
});
|
||||
|
||||
test('passes a plain string through with no params', () => {
|
||||
expect(interpolate('Hello')).toBe('Hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPluralForms', () => {
|
||||
test('accepts an open record of CLDR categories', () => {
|
||||
expect(isPluralForms({ one: 'a', other: 'b' })).toBe(true);
|
||||
expect(isPluralForms({ other: 'b' })).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects a plain nested catalog node', () => {
|
||||
expect(isPluralForms({ title: 'a', description: 'b' })).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects non-objects', () => {
|
||||
expect(isPluralForms('a string')).toBe(false);
|
||||
expect(isPluralForms(null)).toBe(false);
|
||||
expect(isPluralForms(['one', 'other'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('localeDirection', () => {
|
||||
test('ltr for English and Portuguese', () => {
|
||||
expect(localeDirection('en-GB')).toBe('ltr');
|
||||
expect(localeDirection('pt-PT')).toBe('ltr');
|
||||
});
|
||||
|
||||
test('rtl for Arabic and Hebrew, by language subtag', () => {
|
||||
expect(localeDirection('ar')).toBe('rtl');
|
||||
expect(localeDirection('ar-EG')).toBe('rtl');
|
||||
expect(localeDirection('he-IL')).toBe('rtl');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMissingKeys', () => {
|
||||
test('flags a key en has and a locale lacks', () => {
|
||||
const en = { common: { save: 'Save', cancel: 'Cancel' } };
|
||||
const ptPT = { common: { save: 'Guardar' } };
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([{ locale: 'pt-PT', key: 'common.cancel' }]);
|
||||
});
|
||||
|
||||
test('a complete catalog has no gaps', () => {
|
||||
const en = { common: { save: 'Save' } };
|
||||
const ptPT = { common: { save: 'Guardar' } };
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
});
|
||||
|
||||
test('a plural-forms leaf counts as one key, not one per category', () => {
|
||||
const en = { files: { one: '{{count}} file', other: '{{count}} files' } };
|
||||
const ptPT = { files: { one: '{{count}} ficheiro', other: '{{count}} ficheiros' } };
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -2,6 +2,12 @@ export type TranslationMap = Record<string, Record<string, unknown>>;
|
||||
|
||||
export type InterpolationParams = Record<string, string | number>;
|
||||
|
||||
/**
|
||||
* A pluralizable catalog entry — CLDR's category set (Intl.PluralRules), never a fixed
|
||||
* `{one, other}` shape, because some locales need up to all six (see docs/localization.md D7).
|
||||
*/
|
||||
export type PluralForms = Partial<Record<Intl.LDMLPluralRule, string>>;
|
||||
|
||||
export type I18nContextValue = {
|
||||
t: (key: string, params?: InterpolationParams) => string;
|
||||
locale: string;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { I18nContext } from './I18nProvider';
|
||||
|
||||
type DateInput = Date | number | string;
|
||||
|
||||
/**
|
||||
* Date, time and number formatting bound to the active locale, via `Intl` (D7) — the replacement for
|
||||
* the ~23 `toLocale*()` call sites that otherwise follow the browser's locale and quietly contradict
|
||||
* the rest of the UI. Moving those call sites is phase 1-3 work; this is just the hook they move to.
|
||||
*/
|
||||
export const useFormat = () => {
|
||||
const { locale } = useContext(I18nContext);
|
||||
|
||||
return useMemo(() => {
|
||||
const date = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
|
||||
new Intl.DateTimeFormat(locale, options).format(new Date(value));
|
||||
|
||||
const time = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
|
||||
new Intl.DateTimeFormat(locale, { timeStyle: 'short', ...options }).format(new Date(value));
|
||||
|
||||
const dateTime = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
|
||||
new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', ...options }).format(new Date(value));
|
||||
|
||||
const number = (value: number, options?: Intl.NumberFormatOptions) =>
|
||||
new Intl.NumberFormat(locale, options).format(value);
|
||||
|
||||
const relative = (value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions) =>
|
||||
new Intl.RelativeTimeFormat(locale, options).format(value, unit);
|
||||
|
||||
return { date, time, dateTime, number, relative };
|
||||
}, [locale]);
|
||||
};
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"hooks": "workspace:*",
|
||||
"i18n": "workspace:*",
|
||||
"state": "workspace:*",
|
||||
"widgets": "workspace:*"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from './hooks';
|
||||
export * from './channels';
|
||||
// The panel apps' own i18n catalog — I18nBridge merges it into the app-wide map under `panel.*`.
|
||||
export { panelCatalogs } from './locales';
|
||||
// For the shell: a screen naming itself better than its route can — see usePageTitle's precedence.
|
||||
export { usePageTitleOverride, usePublishPageTitle } from './page-title';
|
||||
export type { PageTitleOverride } from './page-title';
|
||||
@@ -49,13 +51,11 @@ export type {
|
||||
CommandHqCostSummary,
|
||||
} from './apps/CommandHQ';
|
||||
|
||||
|
||||
// /calendar and /contacts select with a query param rather than a path segment, so what the screens need
|
||||
// from the app is the param name and the link builder.
|
||||
export { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared';
|
||||
export type { DavCollection, CalendarEvent, DavContact } from './apps/Dav/shared';
|
||||
|
||||
|
||||
export {
|
||||
useFilesAPI,
|
||||
useTasks,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { TranslationMap } from 'i18n';
|
||||
import enGB from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
|
||||
/** Panel apps' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `panel.*`. */
|
||||
export const panelCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
|
||||
|
||||
export { useTranslation } from './useTranslation';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. Phase 0 scaffolds this catalog with no keys yet
|
||||
// (no panel is converted in this phase) — the test still runs so it is load-bearing from day one.
|
||||
describe('officerdev (panel.*) locale catalogs', () => {
|
||||
test('pt-PT has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { InterpolationParams, TranslationKeys } from 'i18n';
|
||||
import { useTranslation as useTranslationBase } from 'i18n';
|
||||
import type en from './en-GB.json';
|
||||
|
||||
type PanelTranslationKey = TranslationKeys<typeof en>;
|
||||
|
||||
// The `panel.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
|
||||
// pass the same bare key the JSON file uses.
|
||||
export const useTranslation = () => {
|
||||
const { t: baseT, locale } = useTranslationBase();
|
||||
const t = (key: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params);
|
||||
return { t, locale };
|
||||
};
|
||||
@@ -5,6 +5,13 @@ import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
const QUERY_KEY = ['USER_SETTINGS'];
|
||||
|
||||
/**
|
||||
* UI locales went from bare codes to regional BCP 47 tags (docs/localization.md D2). A stale bare
|
||||
* code must resolve rather than fail, and this is a one-time widening on read, not a DB migration.
|
||||
*/
|
||||
const WIDEN_BARE_LOCALE: Record<string, string> = { en: 'en-GB', pt: 'pt-PT' };
|
||||
const widenLocale = (code: string): string => WIDEN_BARE_LOCALE[code] ?? code;
|
||||
|
||||
const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
|
||||
chat: { ...DEFAULT_SETTINGS.chat, ...saved.chat },
|
||||
ai: {
|
||||
@@ -14,7 +21,11 @@ const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
|
||||
},
|
||||
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
|
||||
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
|
||||
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages },
|
||||
languages: {
|
||||
...DEFAULT_SETTINGS.languages,
|
||||
...saved.languages,
|
||||
default: widenLocale(saved.languages?.default ?? DEFAULT_SETTINGS.languages.default),
|
||||
},
|
||||
tts: { ...DEFAULT_SETTINGS.tts, ...saved.tts },
|
||||
});
|
||||
|
||||
@@ -121,7 +132,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
|
||||
},
|
||||
languages: {
|
||||
spoken: ['en'],
|
||||
default: 'en',
|
||||
default: 'en-GB',
|
||||
translateTo: 'en',
|
||||
},
|
||||
tts: {
|
||||
|
||||
Reference in New Issue
Block a user