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:
parent
844fed7d2f
commit
7aa068061d
9 changed files with 233 additions and 52 deletions
119
frontend/package-lock.json
generated
119
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
62
frontend/src/components/LanguageSwitcher.tsx
Normal file
62
frontend/src/components/LanguageSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
frontend/src/i18n/index.ts
Normal file
58
frontend/src/i18n/index.ts
Normal 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;
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
7
frontend/src/vite-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue