feat(i18n): foundation — react-i18next, language switcher, server-persisted choice

Set up react-i18next with locale files auto-loaded per area (Vite glob), a compact
LanguageSwitcher, and language as a server-persisted preference (preferences.language)
mirrored to localStorage. On first login the default UI language is guessed from the
Google-reported locale (hu/en/de, else English). vite-env.d.ts types the build-time env.
This commit is contained in:
npeter83 2026-06-15 00:30:34 +02:00
parent ffc8a87fe4
commit c165c3f274
9 changed files with 233 additions and 52 deletions

View file

@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { api, HttpError, type FeedFilters } from "./lib/api";
import { setLanguage, isSupported, type LangCode } from "./i18n";
import {
applyTheme,
DEFAULT_THEME,
@ -61,6 +63,7 @@ function loadInitialFilters(): FeedFilters {
}
export default function App() {
const { t } = useTranslation();
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
const [view, setView] = useState<"grid" | "list">("grid");
@ -124,6 +127,7 @@ export default function App() {
setSidebarLayoutState(l);
saveLayoutLocal(l);
}
if (isSupported(prefs.language)) setLanguage(prefs.language);
if (prefs.notifications) configureNotifications(prefs.notifications);
if (typeof prefs.hints === "boolean") setHintsEnabled(prefs.hints);
if (typeof prefs.performanceMode === "boolean")
@ -133,8 +137,8 @@ export default function App() {
if (meQuery.data?.role === "admin" && pending > 0) {
notify({
level: "warning",
title: "Access requests",
message: `${pending} pending — review in Settings → Account.`,
title: t("common.accessRequestsTitle"),
message: t("common.accessRequestsMessage", { count: pending }),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -149,15 +153,21 @@ export default function App() {
setView(v);
api.savePrefs({ view: v }).catch(() => {});
}
function changeLanguage(code: LangCode) {
setLanguage(code);
api.savePrefs({ language: code }).catch(() => {});
}
if (meQuery.isLoading)
return <div className="min-h-screen grid place-items-center text-muted">Loading</div>;
return (
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
);
if (meQuery.error instanceof HttpError && meQuery.error.status === 401)
return <Login />;
if (meQuery.error)
return (
<div className="min-h-screen grid place-items-center text-muted">
Something went wrong.
{t("common.somethingWrong")}
</div>
);
@ -171,6 +181,7 @@ export default function App() {
setPage={setPage}
onOpenSettings={() => setSettingsOpen(true)}
onOpenAbout={() => setAboutOpen(true)}
onChangeLanguage={changeLanguage}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setPage("channels");

View file

@ -0,0 +1,62 @@
import { useRef, useState } from "react";
import { Check, Globe } from "lucide-react";
import { LANGUAGES, type LangCode } from "../i18n";
// Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in).
export default function LanguageSwitcher({
value,
onChange,
align = "right",
}: {
value: LangCode;
onChange: (code: LangCode) => void;
align?: "left" | "right";
}) {
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0];
function openNow() {
if (closeTimer.current) clearTimeout(closeTimer.current);
setOpen(true);
}
function closeSoon() {
closeTimer.current = setTimeout(() => setOpen(false), 200);
}
return (
<div className="relative" onMouseEnter={openNow} onMouseLeave={closeSoon}>
<button
onClick={() => setOpen((o) => !o)}
title={current.label}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-card transition"
>
<Globe className="w-4 h-4" />
<span className="text-xs font-semibold uppercase">{current.code}</span>
</button>
{open && (
<div
className={`glass absolute ${
align === "right" ? "right-0" : "left-0"
} mt-1 w-40 rounded-xl p-1.5 z-40 animate-[popIn_0.16s_ease]`}
>
{LANGUAGES.map((l) => (
<button
key={l.code}
onClick={() => {
onChange(l.code);
setOpen(false);
}}
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<span>{l.label}</span>
{l.code === value && <Check className="w-4 h-4 text-accent" />}
</button>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,58 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
// Endonyms (shown as-is regardless of the active language — the convention for language pickers).
export const LANGUAGES = [
{ code: "en", label: "English" },
{ code: "hu", label: "Magyar" },
{ code: "de", label: "Deutsch" },
] as const;
export type LangCode = (typeof LANGUAGES)[number]["code"];
const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[];
export const LANG_KEY = "siftlode.lang";
// Auto-load every locale file (locales/<lang>/<area>.json) and merge each <area> under one
// `translation` namespace, so components use t("area.key") and adding an area needs no wiring.
const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record<
string,
{ default: Record<string, unknown> }
>;
const resources: Record<string, { translation: Record<string, unknown> }> = {};
for (const [path, mod] of Object.entries(mods)) {
const m = path.match(/\/locales\/([a-z]{2})\/([a-zA-Z0-9_]+)\.json$/);
if (!m) continue;
const [, lang, area] = m;
(resources[lang] ??= { translation: {} }).translation[area] = mod.default;
}
export function isSupported(code: string | null | undefined): code is LangCode {
return !!code && SUPPORTED.includes(code);
}
// Initial language before the server preference is known: stored choice, else the browser
// language, else English. (After login, App adopts the server-persisted preference.)
export function detectInitialLang(): LangCode {
const stored = localStorage.getItem(LANG_KEY);
if (isSupported(stored)) return stored;
const nav = (navigator.language || "en").slice(0, 2).toLowerCase();
return isSupported(nav) ? nav : "en";
}
const initial = detectInitialLang();
void i18n.use(initReactI18next).init({
resources,
lng: initial,
fallbackLng: "en",
interpolation: { escapeValue: false },
returnNull: false,
});
document.documentElement.lang = initial;
export function setLanguage(code: LangCode): void {
void i18n.changeLanguage(code);
localStorage.setItem(LANG_KEY, code);
document.documentElement.lang = code;
}
export default i18n;

View file

@ -1,10 +1,8 @@
// Build info baked into the SPA at build time (Vite inlines VITE_*-prefixed env).
// Injected via Docker build-args (see Dockerfile); falls back to "dev" for un-stamped builds.
const env = (import.meta as unknown as { env: Record<string, string | undefined> }).env;
export const FRONTEND_VERSION = env.VITE_APP_VERSION || "dev";
export const FRONTEND_SHA = env.VITE_GIT_SHA || "unknown";
export const FRONTEND_BUILD_DATE = env.VITE_BUILD_DATE || "";
export const FRONTEND_VERSION = import.meta.env.VITE_APP_VERSION || "dev";
export const FRONTEND_SHA = import.meta.env.VITE_GIT_SHA || "unknown";
export const FRONTEND_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || "";
// Key for the "last version the user has seen release notes for" (drives the banner).
export const SEEN_VERSION_KEY = "siftlode.seenVersion";

View file

@ -5,6 +5,7 @@ import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary";
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
import Terms from "./components/legal/Terms";
import "./i18n";
import "./index.css";
const queryClient = new QueryClient({

7
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_VERSION?: string;
readonly VITE_GIT_SHA?: string;
readonly VITE_BUILD_DATE?: string;
}