From 7aa068061d0168f5e97f125060145d114e790233 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 00:30:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20foundation=20=E2=80=94=20react-i1?= =?UTF-8?q?8next,=20language=20switcher,=20server-persisted=20choice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/auth.py | 7 ++ frontend/package-lock.json | 119 ++++++++++++------- frontend/package.json | 4 +- frontend/src/App.tsx | 19 ++- frontend/src/components/LanguageSwitcher.tsx | 62 ++++++++++ frontend/src/i18n/index.ts | 58 +++++++++ frontend/src/lib/version.ts | 8 +- frontend/src/main.tsx | 1 + frontend/src/vite-env.d.ts | 7 ++ 9 files changed, 233 insertions(+), 52 deletions(-) create mode 100644 frontend/src/components/LanguageSwitcher.tsx create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/vite-env.d.ts diff --git a/backend/app/auth.py b/backend/app/auth.py index 787f2b7..53fa444 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3f93ad2..00661c5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 0f78c58..8567f4f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d517734..6d49a0c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(() => loadLocalTheme()); const [filters, setFiltersState] = useState(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
Loading…
; + return ( +
{t("common.loading")}
+ ); if (meQuery.error instanceof HttpError && meQuery.error.status === 401) return ; if (meQuery.error) return (
- Something went wrong. + {t("common.somethingWrong")}
); @@ -171,6 +181,7 @@ export default function App() { setPage={setPage} onOpenSettings={() => setSettingsOpen(true)} onOpenAbout={() => setAboutOpen(true)} + onChangeLanguage={changeLanguage} onGoToFullHistory={() => { setChannelFilter("needs_full"); setPage("channels"); diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..f62a888 --- /dev/null +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -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 | 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 ( +
+ + + {open && ( +
+ {LANGUAGES.map((l) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..50397b6 --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -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//.json) and merge each 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 } +>; + +const resources: Record }> = {}; +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; diff --git a/frontend/src/lib/version.ts b/frontend/src/lib/version.ts index 61231fb..caa07a4 100644 --- a/frontend/src/lib/version.ts +++ b/frontend/src/lib/version.ts @@ -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 }).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"; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9f6e91b..44320e3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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({ diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..a1a5588 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +interface ImportMetaEnv { + readonly VITE_APP_VERSION?: string; + readonly VITE_GIT_SHA?: string; + readonly VITE_BUILD_DATE?: string; +}