SPB Git

Repository Public

i18n.ts 3.1 KB 56 lines typescript

1// French by default, English available. Each area of the app owns one dictionary file in src/i18n/; keys are2// namespaced by area ("common.cancel", "explorer.newFolder"). A missing key shows the key itself, so it is noticed.3import { useSyncExternalStore } from 'react';4import { account } from './i18n/account.ts';5import { common } from './i18n/common.ts';6import { explorer } from './i18n/explorer.ts';7import { preview } from './i18n/preview.ts';8import { publicPages } from './i18n/public.ts';9import { sharing } from './i18n/sharing.ts';1011export type Locale = 'fr' | 'en';12export type Dict = { fr: Record<string, string>; en: Record<string, string> };13const AREAS: Record<string, Dict> = { common, account, explorer, preview, public: publicPages, sharing };1415const KEY = 'spd.locale';16let locale: Locale = ((): Locale => {17  const saved = localStorage.getItem(KEY);18  if (saved === 'fr' || saved === 'en') return saved;19  return navigator.language.toLowerCase().startsWith('en') ? 'en' : 'fr';20})();21const listeners = new Set<() => void>();22export const getLocale = (): Locale => locale;23export function setLocale(l: Locale): void { locale = l; localStorage.setItem(KEY, l); document.documentElement.lang = l; listeners.forEach((f) => f()); }24document.documentElement.lang = locale;2526/** t('explorer.items', { n: 3 }) — `{n}` placeholders; "one | other" plural forms chosen by `n`. */27export function t(key: string, vars: Record<string, string | number> = {}): string {28  const [area, ...rest] = key.split('.');29  const d = AREAS[area ?? ''];30  let s = d?.[locale][rest.join('.')] ?? d?.fr[rest.join('.')] ?? key;31  if (s.includes(' | ') && typeof vars.n === 'number') { const [one, other] = s.split(' | '); s = (vars.n === 1 || (locale === 'fr' && vars.n === 0) ? one : other) ?? s; }32  return s.replace(/\{(\w+)\}/g, (_, k: string) => String(vars[k] ?? `{${k}}`));33}34/** Re-renders the component when the language changes. */35export function useT(): typeof t {36  useSyncExternalStore((cb) => { listeners.add(cb); return () => listeners.delete(cb); }, () => locale);37  return t;38}39export const fmtDate = (iso: string | Date | null | undefined, o: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' }) =>40  iso ? new Intl.DateTimeFormat(locale === 'fr' ? 'fr-CA' : 'en-CA', o).format(new Date(iso)) : '';41export function fmtRelative(iso: string | Date): string {42  const s = (new Date(iso).getTime() - Date.now()) / 1000, a = Math.abs(s);43  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });44  if (a < 60) return rtf.format(Math.round(s), 'second');45  if (a < 3600) return rtf.format(Math.round(s / 60), 'minute');46  if (a < 86400) return rtf.format(Math.round(s / 3600), 'hour');47  if (a < 30 * 86400) return rtf.format(Math.round(s / 86400), 'day');48  return fmtDate(iso, { dateStyle: 'medium' });49}50/** Message for an API error: a translated text for known codes, the server's message otherwise. */51export function errorText(e: unknown): string {52  const code = (e as { code?: string })?.code;53  const known = code ? common[locale][`error.${code}`] ?? common.fr[`error.${code}`] : undefined;54  return known ?? (e as Error)?.message ?? t('common.error.unknown');55}56
Files main
Menu

Theme