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
ffc8a87fe4
commit
c165c3f274
9 changed files with 233 additions and 52 deletions
|
|
@ -151,6 +151,13 @@ async def callback(
|
||||||
user.display_name = userinfo.get("name")
|
user.display_name = userinfo.get("name")
|
||||||
user.avatar_url = userinfo.get("picture")
|
user.avatar_url = userinfo.get("picture")
|
||||||
user.role = "admin" if email in settings.admin_email_set else "user"
|
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()
|
db.flush()
|
||||||
|
|
||||||
tok = user.token or OAuthToken(user=user)
|
tok = user.token or OAuthToken(user=user)
|
||||||
|
|
|
||||||
119
frontend/package-lock.json
generated
119
frontend/package-lock.json
generated
|
|
@ -1,19 +1,21 @@
|
||||||
{
|
{
|
||||||
"name": "subfeed-frontend",
|
"name": "siftlode-frontend",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "subfeed-frontend",
|
"name": "siftlode-frontend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tanstack/react-query": "^5.51.0",
|
"@tanstack/react-query": "^5.51.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"i18next": "^23.11.5",
|
||||||
"lucide-react": "^0.408.0",
|
"lucide-react": "^0.408.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-i18next": "^14.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
|
|
@ -273,6 +275,15 @@
|
||||||
"@babel/core": "^7.0.0-0"
|
"@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": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
|
|
@ -952,9 +963,6 @@
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -969,9 +977,6 @@
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -986,9 +991,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1003,9 +1005,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1020,9 +1019,6 @@
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1037,9 +1033,6 @@
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1054,9 +1047,6 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1071,9 +1061,6 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1088,9 +1075,6 @@
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1105,9 +1089,6 @@
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1122,9 +1103,6 @@
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1139,9 +1117,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1156,9 +1131,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1855,6 +1827,38 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
|
|
@ -2368,6 +2372,28 @@
|
||||||
"react": "^18.3.1"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,11 @@
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tanstack/react-query": "^5.51.0",
|
"@tanstack/react-query": "^5.51.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"i18next": "^23.11.5",
|
||||||
"lucide-react": "^0.408.0",
|
"lucide-react": "^0.408.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-i18next": "^14.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api, HttpError, type FeedFilters } from "./lib/api";
|
import { api, HttpError, type FeedFilters } from "./lib/api";
|
||||||
|
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
||||||
import {
|
import {
|
||||||
applyTheme,
|
applyTheme,
|
||||||
DEFAULT_THEME,
|
DEFAULT_THEME,
|
||||||
|
|
@ -61,6 +63,7 @@ function loadInitialFilters(): FeedFilters {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
||||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||||
const [view, setView] = useState<"grid" | "list">("grid");
|
const [view, setView] = useState<"grid" | "list">("grid");
|
||||||
|
|
@ -124,6 +127,7 @@ export default function App() {
|
||||||
setSidebarLayoutState(l);
|
setSidebarLayoutState(l);
|
||||||
saveLayoutLocal(l);
|
saveLayoutLocal(l);
|
||||||
}
|
}
|
||||||
|
if (isSupported(prefs.language)) setLanguage(prefs.language);
|
||||||
if (prefs.notifications) configureNotifications(prefs.notifications);
|
if (prefs.notifications) configureNotifications(prefs.notifications);
|
||||||
if (typeof prefs.hints === "boolean") setHintsEnabled(prefs.hints);
|
if (typeof prefs.hints === "boolean") setHintsEnabled(prefs.hints);
|
||||||
if (typeof prefs.performanceMode === "boolean")
|
if (typeof prefs.performanceMode === "boolean")
|
||||||
|
|
@ -133,8 +137,8 @@ export default function App() {
|
||||||
if (meQuery.data?.role === "admin" && pending > 0) {
|
if (meQuery.data?.role === "admin" && pending > 0) {
|
||||||
notify({
|
notify({
|
||||||
level: "warning",
|
level: "warning",
|
||||||
title: "Access requests",
|
title: t("common.accessRequestsTitle"),
|
||||||
message: `${pending} pending — review in Settings → Account.`,
|
message: t("common.accessRequestsMessage", { count: pending }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|
@ -149,15 +153,21 @@ export default function App() {
|
||||||
setView(v);
|
setView(v);
|
||||||
api.savePrefs({ view: v }).catch(() => {});
|
api.savePrefs({ view: v }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
function changeLanguage(code: LangCode) {
|
||||||
|
setLanguage(code);
|
||||||
|
api.savePrefs({ language: code }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
if (meQuery.isLoading)
|
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)
|
if (meQuery.error instanceof HttpError && meQuery.error.status === 401)
|
||||||
return <Login />;
|
return <Login />;
|
||||||
if (meQuery.error)
|
if (meQuery.error)
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen grid place-items-center text-muted">
|
<div className="min-h-screen grid place-items-center text-muted">
|
||||||
Something went wrong.
|
{t("common.somethingWrong")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -171,6 +181,7 @@ export default function App() {
|
||||||
setPage={setPage}
|
setPage={setPage}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
onOpenAbout={() => setAboutOpen(true)}
|
onOpenAbout={() => setAboutOpen(true)}
|
||||||
|
onChangeLanguage={changeLanguage}
|
||||||
onGoToFullHistory={() => {
|
onGoToFullHistory={() => {
|
||||||
setChannelFilter("needs_full");
|
setChannelFilter("needs_full");
|
||||||
setPage("channels");
|
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).
|
// 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.
|
// 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 = import.meta.env.VITE_APP_VERSION || "dev";
|
||||||
|
export const FRONTEND_SHA = import.meta.env.VITE_GIT_SHA || "unknown";
|
||||||
export const FRONTEND_VERSION = env.VITE_APP_VERSION || "dev";
|
export const FRONTEND_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || "";
|
||||||
export const FRONTEND_SHA = env.VITE_GIT_SHA || "unknown";
|
|
||||||
export const FRONTEND_BUILD_DATE = env.VITE_BUILD_DATE || "";
|
|
||||||
|
|
||||||
// Key for the "last version the user has seen release notes for" (drives the banner).
|
// Key for the "last version the user has seen release notes for" (drives the banner).
|
||||||
export const SEEN_VERSION_KEY = "siftlode.seenVersion";
|
export const SEEN_VERSION_KEY = "siftlode.seenVersion";
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import App from "./App";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
|
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
|
||||||
import Terms from "./components/legal/Terms";
|
import Terms from "./components/legal/Terms";
|
||||||
|
import "./i18n";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
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