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

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