mirror of
https://github.com/temetro/temetro.git
synced 2026-08-31 04:47:57 +00:00
frontend: add Somali, Arabic (RTL) & German languages
Add three UI locales (so/ar/de) with full ~1,660-key translations alongside en/fr, selectable in Settings → Profile. Arabic gets full right-to-left support: - config.ts registers the locales and exports a `dirFor` helper; an inline <head> script in layout.tsx sets <html dir/lang> before first paint (no RTL flash), and i18n-provider keeps them in sync on language change. - ~160 physical direction utilities converted to logical (ms/me/ps/pe/ start/end/text-start/text-end); directional chevrons/arrows get rtl:rotate-180; chat-bubble align variants fixed to logical. - IBM Plex Sans Arabic appended to the sans/heading font stacks for per-character Arabic fallback. - Language persists to the backend user_settings and re-applies on sign-in so it roams across devices (localStorage stays the offline source of truth). - New scripts/check-locales.mjs (npm run check-locales) enforces key/placeholder parity and Arabic CLDR plural completeness. Bump to 0.3.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,9 @@ import { initReactI18next } from "react-i18next";
|
||||
|
||||
import en from "./locales/en/translation.json";
|
||||
import fr from "./locales/fr/translation.json";
|
||||
import so from "./locales/so/translation.json";
|
||||
import ar from "./locales/ar/translation.json";
|
||||
import de from "./locales/de/translation.json";
|
||||
|
||||
export const defaultNS = "translation";
|
||||
|
||||
@@ -13,10 +16,21 @@ export const defaultNS = "translation";
|
||||
export const resources = {
|
||||
en: { translation: en },
|
||||
fr: { translation: fr },
|
||||
so: { translation: so },
|
||||
ar: { translation: ar },
|
||||
de: { translation: de },
|
||||
} as const;
|
||||
|
||||
// Languages offered in the Settings → Profile switcher (label rendered there).
|
||||
export const supportedLanguages = ["en", "fr"] as const;
|
||||
export const supportedLanguages = ["en", "fr", "so", "ar", "de"] as const;
|
||||
|
||||
// Right-to-left languages. Arabic is our only RTL locale today; keep this and the
|
||||
// inline <head> script in app/layout.tsx (which can't import this module) in sync.
|
||||
export const rtlLanguages = ["ar"] as const;
|
||||
|
||||
/** Writing direction for a BCP-47 language tag (e.g. "ar", "ar-SA"). */
|
||||
export const dirFor = (lng: string | undefined): "rtl" | "ltr" =>
|
||||
lng && rtlLanguages.some((r) => lng.startsWith(r)) ? "rtl" : "ltr";
|
||||
|
||||
if (!i18n.isInitialized) {
|
||||
i18n
|
||||
@@ -27,7 +41,7 @@ if (!i18n.isInitialized) {
|
||||
defaultNS,
|
||||
fallbackLng: "en",
|
||||
// Keep this in sync with `resources` as languages grow.
|
||||
supportedLngs: ["en", "fr"],
|
||||
supportedLngs: ["en", "fr", "so", "ar", "de"],
|
||||
interpolation: { escapeValue: false },
|
||||
detection: {
|
||||
order: ["localStorage", "navigator", "htmlTag"],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1690,6 +1690,9 @@
|
||||
"label": "Display language",
|
||||
"en": "English",
|
||||
"fr": "Français",
|
||||
"so": "Soomaali",
|
||||
"ar": "العربية",
|
||||
"de": "Deutsch",
|
||||
"confirmTitle": "Change display language?",
|
||||
"confirmBody": "Switch the interface to {{language}}? The app will reload its text in the new language.",
|
||||
"confirmCta": "Change language",
|
||||
|
||||
@@ -1690,6 +1690,9 @@
|
||||
"label": "Langue d'affichage",
|
||||
"en": "English",
|
||||
"fr": "Français",
|
||||
"so": "Soomaali",
|
||||
"ar": "العربية",
|
||||
"de": "Deutsch",
|
||||
"confirmTitle": "Changer la langue d'affichage ?",
|
||||
"confirmBody": "Basculer l'interface en {{language}} ? L'application rechargera ses textes dans la nouvelle langue.",
|
||||
"confirmCta": "Changer la langue",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
import i18n, { supportedLanguages } from "@/lib/i18n/config";
|
||||
import { getSettings, saveSettings } from "@/lib/settings";
|
||||
|
||||
// The user's chosen UI language roams across devices via the backend
|
||||
// `user_settings` preferences map (localStorage stays the offline source of
|
||||
// truth). We store it under this key alongside notification preferences.
|
||||
const LANG_KEY = "language";
|
||||
|
||||
const isSupported = (value: unknown): value is string =>
|
||||
typeof value === "string" &&
|
||||
(supportedLanguages as readonly string[]).includes(value);
|
||||
|
||||
/**
|
||||
* Persist the chosen language to the backend, merging into existing preferences
|
||||
* so notification toggles aren't clobbered (PUT replaces the whole map).
|
||||
* Best-effort: failures are swallowed since localStorage already holds the choice.
|
||||
*/
|
||||
export async function persistLanguage(lang: string): Promise<void> {
|
||||
try {
|
||||
const current = await getSettings();
|
||||
if (current[LANG_KEY] === lang) return;
|
||||
await saveSettings({ ...current, [LANG_KEY]: lang });
|
||||
} catch {
|
||||
// Ignore — the language is already applied and cached in localStorage.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On app load, adopt the language saved on the backend if it differs from the
|
||||
* locally detected one (roaming to a new device). No-op when offline or when the
|
||||
* stored value matches; localStorage remains authoritative otherwise.
|
||||
*/
|
||||
export async function applyStoredLanguage(): Promise<void> {
|
||||
try {
|
||||
const current = await getSettings();
|
||||
const lang = current[LANG_KEY];
|
||||
if (isSupported(lang) && lang !== (i18n.resolvedLanguage ?? i18n.language)) {
|
||||
await i18n.changeLanguage(lang);
|
||||
}
|
||||
} catch {
|
||||
// Ignore — unauthenticated or offline; keep the detected language.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user