Repository Public
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