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

@ -151,6 +151,13 @@ async def callback(
user.display_name = userinfo.get("name")
user.avatar_url = userinfo.get("picture")
user.role = "admin" if email in settings.admin_email_set else "user"
# Default UI language from the Google-reported locale on first login (if the user hasn't
# picked one yet); unsupported locales fall back to English.
prefs = dict(user.preferences or {})
if not prefs.get("language"):
loc = (userinfo.get("locale") or "").split("-")[0].lower()
prefs["language"] = loc if loc in {"en", "hu", "de"} else "en"
user.preferences = prefs
db.flush()
tok = user.token or OAuthToken(user=user)

View file

@ -1,19 +1,21 @@
{
"name": "subfeed-frontend",
"name": "siftlode-frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "subfeed-frontend",
"name": "siftlode-frontend",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.51.0",
"clsx": "^2.1.1",
"i18next": "^23.11.5",
"lucide-react": "^0.408.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-i18next": "^14.1.2"
},
"devDependencies": {
"@types/react": "^18.3.3",
@ -273,6 +275,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@ -952,9 +963,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -969,9 +977,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -986,9 +991,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1003,9 +1005,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1020,9 +1019,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1037,9 +1033,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1054,9 +1047,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1071,9 +1061,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1088,9 +1075,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1105,9 +1089,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1122,9 +1103,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1139,9 +1117,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1156,9 +1131,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1855,6 +1827,38 @@
"node": ">= 0.4"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "23.16.8",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz",
"integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -2368,6 +2372,28 @@
"react": "^18.3.1"
}
},
"node_modules/react-i18next": {
"version": "14.1.3",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.3.tgz",
"integrity": "sha512-wZnpfunU6UIAiJ+bxwOiTmBOAaB14ha97MjOEnLGac2RJ+h/maIYXZuTHlmyqQVX1UVHmU1YDTQ5vxLmwfXTjw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.9",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -2815,6 +2841,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

@ -13,9 +13,11 @@
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.51.0",
"clsx": "^2.1.1",
"i18next": "^23.11.5",
"lucide-react": "^0.408.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-i18next": "^14.1.2"
},
"devDependencies": {
"@types/react": "^18.3.3",

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;
}