Compare commits

..
4 Commits
Author SHA1 Message Date
florian.wilde 0d69d77d4e Merge feature/v1.10.0: Dynamisches Sprachpaket-System für die Bedienoberfläche 2026-07-20 10:36:05 +02:00
florian.wildeandClaude Sonnet 5 236f8c9490 feat: Dynamisches Sprachpaket-System für die Bedienoberfläche
Teil 1 des "mehrsprachig variabel"-Umbaus (Speiseplan-Inhalte folgen
in einer späteren Version). Sprachen sind jetzt zur Laufzeit über eine
neue Admin-Seite "Sprachen" verwaltbar statt fest im Code zu stehen:

- Neue Tabellen languages/ui_translations (Migration 0004), bestehende
  Englisch-/Polnisch-Texte per generiertem Seed automatisch übernommen
  (Migration 0005) - keine manuelle Nacharbeit, nichts geht verloren.
- lib/i18n/flatten.ts: verschachtelte Dict-Objekte (inkl. Arrays wie
  weekdays.long) <-> flache Schlüssel-Wert-Paare, Basis für Vorlage-
  Export/-Import.
- lib/i18n/template.ts: Vollständigkeits-/Veraltet-Berechnung (Hash
  über die DE-Schlüsselliste) und Import-Validierung (Platzhalter wie
  {message} müssen erhalten bleiben, unbekannte Schlüssel werden
  ignoriert statt Fehler zu werfen).
- getDictionary()/getLocale() (lib/i18n/index.ts) jetzt asynchron und
  DB-gestützt für Nicht-Basis-Sprachen, mit Fallback auf Deutsch für
  fehlende Schlüssel. Locale-Typ bewusst von festem TypeScript-Tupel
  auf string generalisiert (Kompromiss: Compiler prüft Vollständigkeit
  nicht mehr automatisch, das übernimmt jetzt die Veraltet-Erkennung
  zur Laufzeit).
- LanguageSwitcher/LocaleProvider lesen aktive Sprachen jetzt aus dem
  Context statt aus einer festen Konstante; blendet sich bei nur einer
  aktiven Sprache automatisch aus.
- Neue Admin-Routen app/api/admin/languages/* (Liste inkl.
  Vollständigkeit, Anlegen, Aktivieren/Deaktivieren mit
  "mindestens eine muss aktiv bleiben"-Schutz, Löschen außer
  Basis-Sprache, Vorlage-Download, Datei-Import) + LanguageManager.tsx.

Vollständig gegen echten DB-Zustand getestet: Vorlage-Export/-Import
inkl. Platzhalter-Validierung, neue Sprache anlegen, auf eine Sprache
reduzieren (Umschalter verschwindet, "letzte Sprache"-Schutz greift),
Veraltet-Erkennung nach simuliertem neuen Schlüssel, Cascade-Delete.

Version 1.10.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 10:34:51 +02:00
florian.wilde 660490f1ac Merge feature/v1.9.5: interner Bereich für Mobilgeräte nutzbar 2026-07-20 00:55:13 +02:00
florian.wildeandClaude Sonnet 5 0c69a21a85 feat: interner Bereich für Mobilgeräte nutzbar
AppHeader.tsx: Hamburger-Menü unterhalb "sm" mit denselben Links
gestapelt (Vorschläge-Badge-Logik in gemeinsame renderLinks()-Funktion
ausgelagert), Panel schließt automatisch bei Navigation. Bisherige
einzeilige Navigation bleibt ab "sm" unverändert.

WeekEditor.tsx: Mobile Tag-für-Tag-Ansicht mit Tab-Leiste (Mo-Fr)
statt der 6-spaltigen Tabelle, die bei 375px unbenutzbar gequetscht
war. Zellinhalt (Eintragsliste + "hinzufügen"-Flow) in renderCell()
extrahiert und von Tabelle und Karten-Ansicht gemeinsam genutzt, damit
der Bearbeitungs-Zustand nicht dupliziert werden muss. Desktop-Tabelle
unverändert ab "sm".

DishManager/CodeListManager/UserManager/SectionManager/PasskeyManager:
Kopf- und Listenzeilen brechen jetzt bei Bedarf um (flex-wrap) statt
Buttons abzuschneiden, gleiches Muster wie bereits in
SuggestionReview.tsx vorhanden.

Version 1.9.5.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 00:54:05 +02:00
30 changed files with 1876 additions and 798 deletions
+44
View File
@@ -4,6 +4,50 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert.
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
Versionierung nach [Semantic Versioning](https://semver.org/lang/de/).
## [1.10.0] – 2026-07-20
### Hinzugefügt
- Dynamisches Sprachpaket-System für die Bedienoberfläche (Teil 1 des
"mehrsprachig variabel"-Umbaus, Speiseplan-Inhalte folgen in einer
späteren Version). Sprachen sind jetzt zur Laufzeit über eine neue
Admin-Seite „Sprachen" verwaltbar statt fest im Code zu stehen:
- Vorlage der aktuellen deutschen Texte herunterladen, übersetzt
wieder hochladen – neue Übersetzungen werden sofort aktiv, ohne
Deployment.
- Fehlende Begriffe fallen automatisch auf Deutsch zurück statt leer
zu bleiben; die Admin-Seite zeigt pro Sprache Vollständigkeit in %
sowie „N neue Begriffe seit letztem Import", falls eine neue
App-Version zusätzliche Texte eingeführt hat.
- Sprachen lassen sich einzeln deaktivieren, bis auf eine einzige
reduzierbar (der Sprachumschalter blendet sich dann automatisch
aus) – mindestens eine muss aktiv bleiben.
- Neue Sprachen lassen sich direkt anlegen (Code + Anzeigename),
Deutsch bleibt als einzige garantiert vollständige Basis-Sprache
unlöschbar.
- Import validiert hochgeladene Übersetzungen gegen die aktuelle
Vorlage: Platzhalter wie `{message}` müssen erhalten bleiben,
unbekannte Schlüssel werden ignoriert statt Fehler zu werfen.
- Neue Tabellen `languages`/`ui_translations` (Migration `0004`),
bestehende Englisch-/Polnisch-Texte automatisch übernommen
(Migration `0005`) – keine manuelle Nacharbeit nötig.
## [1.9.5] – 2026-07-20
### Hinzugefügt
- Interner Bereich (Wochenplan, Essenszeiten, Gerichte, Allergene &
Zusatzstoffe, Vorschläge, Benutzer, Design, Mein Konto) für
Mobilgeräte nutzbar gemacht. `AppHeader.tsx` bekommt unterhalb der
`sm`-Breakpoint-Grenze ein Hamburger-Menü mit denselben Links
gestapelt statt der bisherigen, bei schmalen Bildschirmen unübersichtlich
umbrechenden einzeiligen Navigation. Der Wochenplan (`WeekEditor.tsx`)
zeigt auf Mobilgeräten eine Tag-für-Tag-Ansicht mit Tab-Leiste
(Mo–Fr) statt der für 375px viel zu schmalen 6-spaltigen Tabelle –
Gericht hinzufügen/löschen funktioniert unverändert, da beide
Ansichten denselben Bearbeitungs-Zustand teilen. In DishManager,
CodeListManager, UserManager, SectionManager und PasskeyManager
brechen Kopf- und Listenzeilen jetzt bei Bedarf um, statt Buttons
abzuschneiden.
## [1.9.0] – 2026-07-20
### Hinzugefügt
+5
View File
@@ -0,0 +1,5 @@
import LanguageManager from "@/components/LanguageManager";
export default function LanguagesPage() {
return <LanguageManager />;
}
@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { getLanguage, importTranslations } from "@/lib/db/languages";
import { currentTemplateHash, validateImport } from "@/lib/i18n/template";
import { ForbiddenError, requireRole, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function POST(
request: Request,
{ params }: { params: Promise<{ code: string }> }
) {
try {
if (!isSameOrigin(request)) throw new ForbiddenError();
await requireRole(["admin"]);
} catch (err) {
if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
}
throw err;
}
const { code } = await params;
const language = await getLanguage(code);
if (!language) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
if (language.is_base) {
return NextResponse.json({ error: "cannot_import_base" }, { status: 400 });
}
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
let parsed: unknown;
try {
parsed = JSON.parse(await file.text());
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}
const { entries, skipped } = validateImport(parsed as Record<string, unknown>);
if (entries.length > 0) {
await importTranslations(code, entries, currentTemplateHash());
}
return NextResponse.json({ imported: entries.length, skipped });
}
+75
View File
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import {
deleteLanguage,
setLanguageActive,
updateLanguageLabel,
} from "@/lib/db/languages";
import { ForbiddenError, requireRole, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
async function requireAdmin(request: Request) {
if (!isSameOrigin(request)) throw new ForbiddenError();
return requireRole(["admin"]);
}
function errorResponse(err: unknown) {
if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
}
return null;
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ code: string }> }
) {
try {
await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const { code } = await params;
const body = await request.json().catch(() => null);
try {
if (typeof body?.active === "boolean") {
await setLanguageActive(code, body.active);
}
if (typeof body?.label === "string" && body.label.trim()) {
await updateLanguageLabel(code, body.label.trim());
}
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof Error && err.message === "cannot_deactivate_last") {
return NextResponse.json({ error: "cannot_deactivate_last" }, { status: 400 });
}
throw err;
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ code: string }> }
) {
try {
await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const { code } = await params;
try {
await deleteLanguage(code);
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof Error && err.message === "cannot_delete_base") {
return NextResponse.json({ error: "cannot_delete_base" }, { status: 400 });
}
throw err;
}
}
+96
View File
@@ -0,0 +1,96 @@
import { NextResponse } from "next/server";
import {
createLanguage,
listLanguages,
listTranslations,
} from "@/lib/db/languages";
import { computeCompleteness, currentTemplateHash } from "@/lib/i18n/template";
import { ForbiddenError, requireRole, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
// Sprachverwaltung (nur Admin) – gleiches Auth-/CSRF-Muster wie
// app/api/admin/users/route.ts.
async function requireAdmin(request: Request) {
if (!isSameOrigin(request)) throw new ForbiddenError();
return requireRole(["admin"]);
}
function errorResponse(err: unknown) {
if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
}
return null;
}
const CODE_PATTERN = /^[a-z]{2,5}$/;
export async function GET(request: Request) {
try {
await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const languages = await listLanguages();
const hash = currentTemplateHash();
const items = await Promise.all(
languages.map(async (lang) => {
if (lang.is_base) {
return {
code: lang.code,
label: lang.label,
is_base: true,
active: lang.active,
percent: 100,
outdated: false,
missingCount: 0,
};
}
const rows = await listTranslations(lang.code);
const translated = Object.fromEntries(rows.map((r) => [r.key, r.value]));
const completeness = computeCompleteness(translated);
return {
code: lang.code,
label: lang.label,
is_base: false,
active: lang.active,
percent: completeness.percent,
outdated: lang.template_version !== hash,
missingCount: completeness.missingKeys.length,
};
})
);
return NextResponse.json(items);
}
export async function POST(request: Request) {
try {
await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const body = await request.json().catch(() => null);
const code = String(body?.code ?? "").trim().toLowerCase();
const label = String(body?.label ?? "").trim();
if (!CODE_PATTERN.test(code) || !label) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
try {
const language = await createLanguage(code, label);
return NextResponse.json(language, { status: 201 });
} catch (err) {
if (err instanceof Error && /duplicate key/i.test(err.message)) {
return NextResponse.json({ error: "duplicate_code" }, { status: 409 });
}
throw err;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { currentTemplate } from "@/lib/i18n/template";
import { ForbiddenError, requireRole, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
// Deutsche Vorlage (alle aktuellen Übersetzungsschlüssel + Text) zum
// Download – wird von einem Übersetzer ausgefüllt und anschließend
// über POST /api/admin/languages/[code]/import wieder hochgeladen.
export async function GET(request: Request) {
try {
if (!isSameOrigin(request)) throw new ForbiddenError();
await requireRole(["admin"]);
} catch (err) {
if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
}
throw err;
}
const template = currentTemplate();
return new NextResponse(JSON.stringify(template, null, 2), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": 'attachment; filename="sprachvorlage.json"',
},
});
}
+3 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { isLocale, LOCALE_COOKIE } from "@/lib/i18n";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { isSameOrigin } from "@/lib/origin";
// Setzt das Sprach-Cookie. Kein Login nötig (auch anonyme Besucher
@@ -10,7 +11,8 @@ export async function POST(request: Request) {
}
const body = await request.json().catch(() => null);
const locale = body?.locale;
if (!isLocale(locale)) {
const active = await listActiveLanguageCodes();
if (!isLocale(locale, active)) {
return NextResponse.json({ error: "Ungültige Sprache." }, { status: 400 });
}
const response = NextResponse.json({ ok: true });
+11 -3
View File
@@ -4,6 +4,7 @@ import "./globals.css";
import { getSettings } from "@/lib/db/settings";
import { COLOR_VARS, DEFAULT_COLORS, faviconUrl, SITE_TITLE } from "@/lib/types";
import { getDictionary, getLocale } from "@/lib/i18n";
import { listLanguages } from "@/lib/db/languages";
import { LocaleProvider } from "@/components/i18n/LocaleProvider";
import packageJson from "@/package.json";
@@ -41,8 +42,15 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const [settings, locale] = await Promise.all([getSettings(), getLocale()]);
const dict = getDictionary(locale);
const [settings, locale, allLanguages] = await Promise.all([
getSettings(),
getLocale(),
listLanguages(),
]);
const dict = await getDictionary(locale);
const languages = allLanguages
.filter((l) => l.active)
.map((l) => ({ code: l.code, label: l.label }));
const colors: Record<string, string> = {
...DEFAULT_COLORS,
@@ -55,7 +63,7 @@ export default async function RootLayout({
return (
<html lang={locale} className={`${geistSans.variable} h-full antialiased`}>
<body style={style} className="min-h-full flex flex-col">
<LocaleProvider locale={locale} dict={dict}>
<LocaleProvider locale={locale} dict={dict} languages={languages}>
{children}
</LocaleProvider>
<footer className="mt-auto px-4 py-2 text-right text-xs text-gray-400 select-none">
+1 -1
View File
@@ -27,7 +27,7 @@ export default async function Home({
const friday = addDays(monday, 4);
const currentWeek = mondayOf(todayISO());
const locale = await getLocale();
const dict = getDictionary(locale);
const dict = await getDictionary(locale);
const days = dict.weekdays.long.map((weekday, i) => ({
weekday,
iso: addDays(monday, i),
+115 -24
View File
@@ -3,7 +3,8 @@
// Gemeinsamer Kopfbereich für alle internen Seiten. Statt zweier
// getrennter Bereiche (Kantine/Verwaltung) gibt es eine Navigation:
// Admins sehen zusätzlich die Verwaltungs-Links, der aktive Punkt
// wird hervorgehoben.
// wird hervorgehoben. Ab "sm" die klassische einzeilige Navigation,
// darunter ein Hamburger-Menü mit denselben Links gestapelt.
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -13,6 +14,11 @@ import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
interface NavLink {
href: string;
label: string;
}
export default function AppHeader({
role,
displayName,
@@ -23,6 +29,7 @@ export default function AppHeader({
const pathname = usePathname();
const dict = useDict();
const [suggestionCount, setSuggestionCount] = useState(0);
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
fetch("/api/dish-suggestions")
@@ -31,16 +38,22 @@ export default function AppHeader({
.catch(() => {});
}, [pathname]);
const menuLinks = [
// Hamburger-Panel bei jeder Navigation automatisch schließen.
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
const menuLinks: NavLink[] = [
{ href: "/intern", label: dict.header.weekPlan },
{ href: "/intern/essenszeiten", label: dict.header.sections },
{ href: "/intern/gerichte", label: dict.header.dishes },
{ href: "/intern/kennzeichnung", label: dict.header.labels },
{ href: "/intern/vorschlaege", label: dict.header.suggestions },
];
const adminLinks = [
const adminLinks: NavLink[] = [
{ href: "/admin", label: dict.header.users },
{ href: "/admin/design", label: dict.header.design },
{ href: "/admin/sprachen", label: dict.header.languages },
];
const linkClass = (href: string) =>
@@ -48,9 +61,105 @@ export default function AppHeader({
pathname === href ? "bg-white/20 font-semibold" : "text-white/90"
}`;
// Von Desktop- und Mobile-Navigation gemeinsam genutzt, damit die
// Vorschläge-Badge-Logik nicht doppelt gepflegt werden muss.
function renderLinks(links: NavLink[]) {
return links.map((l) => (
<Link key={l.href} href={l.href} className={`relative ${linkClass(l.href)}`}>
{l.label}
{l.href === "/intern/vorschlaege" && suggestionCount > 0 && (
<span
className="absolute -top-1.5 -right-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--c-del)] px-1 text-[10px] font-bold leading-none text-white"
aria-label={formatMessage(dict.header.openSuggestions, {
count: suggestionCount,
})}
>
{suggestionCount}
</span>
)}
</Link>
));
}
return (
<header className="bg-[var(--c-primary-dark)] text-white">
<div className="max-w-[1600px] mx-auto w-full px-4 py-3 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
{/* Mobil (< "sm"): kompakte Kopfzeile mit Hamburger-Toggle */}
<div className="sm:hidden flex items-center justify-between px-4 py-3">
<span className="font-bold text-base">{dict.header.canteen}</span>
<button
type="button"
className="p-1 -m-1"
onClick={() => setMobileOpen((v) => !v)}
aria-label={dict.header.menuToggle}
aria-expanded={mobileOpen}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
{mobileOpen ? (
<path d="M6 6l12 12M18 6L6 18" />
) : (
<path d="M4 7h16M4 12h16M4 17h16" />
)}
</svg>
</button>
</div>
{mobileOpen && (
<div className="sm:hidden px-4 pb-4 flex flex-col gap-4 text-sm border-t border-white/10">
<nav className="flex flex-col gap-1" aria-label={dict.header.menuGroup}>
<span className="mt-3 text-xs uppercase tracking-wider text-white/50">
{dict.header.menuGroup}
</span>
{renderLinks(menuLinks)}
</nav>
{role === "admin" && (
<nav className="flex flex-col gap-1" aria-label={dict.header.adminGroup}>
<span className="text-xs uppercase tracking-wider text-white/50">
{dict.header.adminGroup}
</span>
{renderLinks(adminLinks)}
</nav>
)}
<Link
href="/"
className="w-fit rounded px-2 py-0.5 text-white/70 hover:bg-white/10"
>
{dict.header.viewMenu}
</Link>
<div className="flex items-center gap-3 border-t border-white/10 pt-3">
<LanguageSwitcher />
<Link
href="/konto"
className={`rounded px-2 py-0.5 hover:bg-white/10 ${
pathname === "/konto" ? "bg-white/20 font-semibold" : "text-white/80"
}`}
>
{displayName}
</Link>
</div>
<form action="/logout" method="post">
<button type="submit" className="btn btn-del w-full">
{dict.common.logout}
</button>
</form>
</div>
)}
{/* Ab "sm": bisherige einzeilige Navigation */}
<div className="hidden sm:flex max-w-[1600px] mx-auto w-full px-4 py-3 flex-wrap items-center gap-x-6 gap-y-2 text-sm">
<span className="font-bold text-base">{dict.header.canteen}</span>
<nav
@@ -60,21 +169,7 @@ export default function AppHeader({
<span className="mr-1 text-xs uppercase tracking-wider text-white/50">
{dict.header.menuGroup}
</span>
{menuLinks.map((l) => (
<Link key={l.href} href={l.href} className={`relative ${linkClass(l.href)}`}>
{l.label}
{l.href === "/intern/vorschlaege" && suggestionCount > 0 && (
<span
className="absolute -top-1.5 -right-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--c-del)] px-1 text-[10px] font-bold leading-none text-white"
aria-label={formatMessage(dict.header.openSuggestions, {
count: suggestionCount,
})}
>
{suggestionCount}
</span>
)}
</Link>
))}
{renderLinks(menuLinks)}
</nav>
{role === "admin" && (
@@ -85,11 +180,7 @@ export default function AppHeader({
<span className="mr-1 text-xs uppercase tracking-wider text-white/50">
{dict.header.adminGroup}
</span>
{adminLinks.map((l) => (
<Link key={l.href} href={l.href} className={linkClass(l.href)}>
{l.label}
</Link>
))}
{renderLinks(adminLinks)}
</nav>
)}
+2 -2
View File
@@ -181,7 +181,7 @@ export default function CodeListManager({
return (
<section className="card">
<div className="flex items-center gap-3 mb-3">
<div className="flex flex-wrap items-center gap-3 mb-3">
<h2 className="text-lg font-bold text-[var(--c-primary)] mr-auto">
{title}
</h2>
@@ -291,7 +291,7 @@ export default function CodeListManager({
<ul className="divide-y divide-gray-100">
{items.map((item) => (
<li key={item.code} className="flex items-center gap-2 py-1.5 text-sm">
<li key={item.code} className="flex flex-wrap items-center gap-2 py-1.5 text-sm">
<span className="mr-auto">
<span className="font-semibold">{item.code}</span> –{" "}
{localized(item, locale)}
+3 -3
View File
@@ -284,7 +284,7 @@ export default function DishManager({
return (
<div className="max-w-3xl">
<div className="flex items-center gap-3 mb-4">
<div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.dishManager.title}
</h1>
@@ -444,7 +444,7 @@ export default function DishManager({
/>
)}
{dishes.length > 0 && (
<div className="flex items-center gap-3 mb-2 text-sm">
<div className="flex flex-wrap items-center gap-3 mb-2 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
@@ -474,7 +474,7 @@ export default function DishManager({
{visible.map((dish) => {
const codes = dishCodes(dish);
return (
<li key={dish.id} className="flex items-center gap-3 px-4 py-2">
<li key={dish.id} className="flex flex-wrap items-center gap-3 px-4 py-2">
<input
type="checkbox"
checked={selected.has(dish.id)}
+293
View File
@@ -0,0 +1,293 @@
"use client";
// Verwaltung der Sprachpakete (nur Admin): Sprachen aktivieren/
// deaktivieren (bis auf eine einzige reduzierbar), Vorlage der
// aktuellen Begriffe herunterladen, Übersetzungsdatei hochladen, neue
// Sprache anlegen/löschen. Deutsch ist die feste Basis-Sprache und
// nicht löschbar.
import { useCallback, useEffect, useState } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
interface LanguageItem {
code: string;
label: string;
is_base: boolean;
active: boolean;
percent: number;
outdated: boolean;
missingCount: number;
}
export default function LanguageManager() {
const dict = useDict();
const [languages, setLanguages] = useState<LanguageItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [newCode, setNewCode] = useState("");
const [newLabel, setNewLabel] = useState("");
const load = useCallback(async () => {
const res = await fetch("/api/admin/languages");
if (!res.ok) {
setError(formatMessage(dict.languageManager.loadError, { message: String(res.status) }));
} else {
setLanguages(await res.json());
}
setLoading(false);
}, [dict]);
useEffect(() => {
load();
}, [load]);
async function toggleActive(item: LanguageItem) {
setError(null);
setNotice(null);
setBusy(true);
const res = await fetch(`/api/admin/languages/${item.code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active: !item.active }),
});
setBusy(false);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(
data.error === "cannot_deactivate_last"
? dict.languageManager.cannotDeactivateLast
: formatMessage(dict.languageManager.saveError, { message: data.error ?? String(res.status) })
);
return;
}
await load();
}
async function deleteLanguage(item: LanguageItem) {
if (!window.confirm(formatMessage(dict.languageManager.confirmDelete, { label: item.label })))
return;
setError(null);
setNotice(null);
setBusy(true);
const res = await fetch(`/api/admin/languages/${item.code}`, { method: "DELETE" });
setBusy(false);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(
data.error === "cannot_delete_base"
? dict.languageManager.cannotDeleteBase
: formatMessage(dict.languageManager.deleteError, { message: data.error ?? String(res.status) })
);
return;
}
await load();
}
async function downloadTemplate() {
setError(null);
const res = await fetch("/api/admin/languages/template");
if (!res.ok) {
setError(formatMessage(dict.languageManager.loadError, { message: String(res.status) }));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "sprachvorlage.json";
a.click();
URL.revokeObjectURL(url);
}
async function uploadImport(code: string, e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setError(null);
setNotice(null);
setBusy(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`/api/admin/languages/${code}/import`, {
method: "POST",
body: fd,
});
setBusy(false);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(formatMessage(dict.languageManager.importError, { message: data.error ?? String(res.status) }));
return;
}
const data = (await res.json()) as {
imported: number;
skipped: { key: string; reason: string }[];
};
let msg = formatMessage(dict.languageManager.importSuccess, { count: data.imported });
if (data.skipped.length > 0) {
const first = data.skipped[0];
const [reason, placeholder] = first.reason.split(":");
if (reason === "missing_placeholder") {
msg +=
" " +
formatMessage(dict.languageManager.importMissingPlaceholder, {
key: first.key,
placeholder: `{${placeholder}}`,
});
if (data.skipped.length > 1) msg += ` (+${data.skipped.length - 1})`;
}
}
setNotice(msg);
await load();
}
async function createLanguage(e: React.FormEvent) {
e.preventDefault();
setError(null);
setNotice(null);
const code = newCode.trim().toLowerCase();
const label = newLabel.trim();
if (!/^[a-z]{2,5}$/.test(code)) {
setError(dict.languageManager.invalidCode);
return;
}
if (!label) return;
setBusy(true);
const res = await fetch("/api/admin/languages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, label }),
});
setBusy(false);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(
data.error === "duplicate_code"
? formatMessage(dict.languageManager.duplicateCode, { code })
: formatMessage(dict.languageManager.saveError, { message: data.error ?? String(res.status) })
);
return;
}
setNewCode("");
setNewLabel("");
await load();
}
return (
<div className="max-w-3xl space-y-6">
<div>
<h1 className="text-xl font-bold text-[var(--c-primary)] mb-1">
{dict.languageManager.title}
</h1>
<p className="text-sm text-gray-600">{dict.languageManager.hint}</p>
</div>
{error && (
<p className="text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{notice && <p className="text-sm text-green-700">{notice}</p>}
<section className="card">
<div className="flex flex-wrap items-center gap-3 mb-3">
<h2 className="font-semibold mr-auto">{dict.languageManager.title}</h2>
<button type="button" className="btn btn-neutral" onClick={downloadTemplate}>
{dict.languageManager.downloadTemplate}
</button>
</div>
{loading ? (
<p className="text-gray-500">{dict.common.loading}</p>
) : (
<ul className="divide-y divide-gray-100">
{languages.map((item) => (
<li key={item.code} className="flex flex-wrap items-center gap-3 py-2 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={item.active}
onChange={() => toggleActive(item)}
disabled={busy}
/>
<span className="font-semibold uppercase">{item.code}</span>
</label>
<span className="mr-auto">
{item.label}
{item.is_base ? (
<span className="ml-2 text-xs text-gray-500">
({dict.languageManager.baseBadge})
</span>
) : (
<span className="ml-2 text-xs text-gray-500">
{item.outdated
? formatMessage(dict.languageManager.outdated, { count: item.missingCount })
: formatMessage(dict.languageManager.completeness, { percent: item.percent })}
</span>
)}
</span>
{!item.is_base && (
<>
<label className="btn btn-neutral cursor-pointer">
{dict.languageManager.uploadTranslation}
<input
type="file"
accept=".json,application/json"
className="hidden"
onChange={(e) => uploadImport(item.code, e)}
disabled={busy}
/>
</label>
<button
type="button"
className="btn btn-del"
onClick={() => deleteLanguage(item)}
disabled={busy}
>
{dict.languageManager.delete}
</button>
</>
)}
</li>
))}
</ul>
)}
</section>
<section className="card">
<h2 className="font-semibold mb-3">{dict.languageManager.newLanguageTitle}</h2>
<form onSubmit={createLanguage} className="flex flex-wrap items-end gap-3">
<div className="max-w-40">
<label className="label" htmlFor="new-lang-code">
{dict.languageManager.newLanguageCodeLabel}
</label>
<input
id="new-lang-code"
className="input"
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
required
/>
</div>
<div>
<label className="label" htmlFor="new-lang-label">
{dict.languageManager.newLanguageLabelLabel}
</label>
<input
id="new-lang-label"
className="input"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
required
/>
</div>
<button type="submit" className="btn btn-add" disabled={busy}>
{dict.languageManager.create}
</button>
</form>
</section>
</div>
);
}
+1 -1
View File
@@ -96,7 +96,7 @@ export default function PasskeyManager() {
<ul className="divide-y divide-gray-100">
{passkeys.map((pk) => (
<li key={pk.id} className="flex items-center gap-3 py-1.5 text-sm">
<li key={pk.id} className="flex flex-wrap items-center gap-3 py-1.5 text-sm">
<span className="mr-auto">
<span className="font-semibold">{pk.name || "Passkey"}</span>{" "}
<span className="text-gray-500">
+2 -2
View File
@@ -124,7 +124,7 @@ export default function SectionManager() {
return (
<div className="max-w-3xl">
<div className="flex items-center gap-3 mb-4">
<div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.sectionManager.title}
</h1>
@@ -232,7 +232,7 @@ export default function SectionManager() {
) : (
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{sections.map((section, idx) => (
<li key={section.key} className="flex items-center gap-3 px-4 py-2">
<li key={section.key} className="flex flex-wrap items-center gap-3 px-4 py-2">
<div className="flex flex-col">
<button
type="button"
+2 -2
View File
@@ -106,7 +106,7 @@ export default function UserManager({ meId }: { meId: string }) {
return (
<div className="max-w-3xl">
<div className="flex items-center gap-3 mb-4">
<div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.userManager.title}
</h1>
@@ -221,7 +221,7 @@ export default function UserManager({ meId }: { meId: string }) {
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{profiles.map((p) => (
<li key={p.id} className="flex items-center gap-3 px-4 py-2 text-sm">
<li key={p.id} className="flex flex-wrap items-center gap-3 px-4 py-2 text-sm">
<span className="mr-auto">
<span className="font-semibold">{p.full_name || p.email}</span>{" "}
<span className="text-gray-500">
+186 -133
View File
@@ -10,6 +10,7 @@ import {
formatPrice,
mondayOf,
todayISO,
weekdayIndex,
} from "@/lib/menu";
import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types";
@@ -22,8 +23,11 @@ export default function WeekEditor({ monday }: { monday: string }) {
const friday = addDays(monday, 4);
const days = dict.weekdays.long.map((weekday, i) => ({
weekday,
short: dict.weekdays.short[i],
iso: addDays(monday, i),
}));
const isCurrentWeek = monday === mondayOf(todayISO());
const todayIdx = isCurrentWeek ? weekdayIndex(todayISO()) : -1;
const [sections, setSections] = useState<Section[]>([]);
const [dishes, setDishes] = useState<Dish[]>([]);
@@ -36,6 +40,8 @@ export default function WeekEditor({ monday }: { monday: string }) {
);
const [selectedDish, setSelectedDish] = useState("");
const [dishSearch, setDishSearch] = useState("");
// Mobil: ein Tag auf einmal statt aller fünf gleichzeitig
const [selectedDayIdx, setSelectedDayIdx] = useState(todayIdx >= 0 ? todayIdx : 0);
function startAdding(iso: string, section: string) {
setAdding({ iso, section });
@@ -108,6 +114,117 @@ export default function WeekEditor({ monday }: { monday: string }) {
? dishes.filter((d) => localized(d, locale).toLowerCase().includes(query))
: dishes;
// Inhalt einer Tag/Essenszeit-Zelle: Eintragsliste + "hinzufügen"-Flow.
// Identisches Fragment für Desktop-Tabelle und Mobile-Karten, damit der
// Bearbeitungs-Zustand (adding/selectedDish/dishSearch) nicht dupliziert
// werden muss – nur eine Zelle kann gleichzeitig bearbeitet werden,
// unabhängig davon, welche Ansicht gerade rendert.
function renderCell(iso: string, sectionKey: string) {
const isAdding = adding?.iso === iso && adding?.section === sectionKey;
return (
<>
{entriesFor(entries, iso, sectionKey).map((e) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish">
<span className="font-bold">{localized(e.dish, locale)}</span>
{codes && <sup className="dish-codes">{codes}</sup>}
{" – "}
{formatPrice(e.dish.price)}
<div className="mt-1">
<button
type="button"
className="btn btn-del"
onClick={() => removeEntry(e.id)}
>
{dict.common.delete}
</button>
</div>
</div>
);
})}
{isAdding ? (
<div className="mt-2 space-y-1 text-left">
{selectedDishObj ? (
<div className="flex items-center gap-1 text-sm">
<span className="mr-auto">
{localized(selectedDishObj, locale)} (
{formatPrice(selectedDishObj.price)})
</span>
<button
type="button"
className="text-gray-400 hover:text-[var(--c-primary)]"
onClick={() => {
setSelectedDish("");
setDishSearch("");
}}
aria-label={dict.weekEditor.changeDish}
title={dict.weekEditor.changeDish}
>
×
</button>
</div>
) : (
<>
<input
type="search"
className="input"
autoFocus
placeholder={dict.dishManager.searchPlaceholder}
value={dishSearch}
onChange={(e) => setDishSearch(e.target.value)}
/>
<ul className="max-h-32 overflow-y-auto border border-gray-200 rounded-md bg-white divide-y divide-gray-100">
{filteredDishes.map((dish) => (
<li key={dish.id}>
<button
type="button"
className="w-full text-left px-2 py-1 text-sm hover:bg-[var(--c-row-bg-alt)]"
onClick={() => setSelectedDish(dish.id)}
>
{localized(dish, locale)} ({formatPrice(dish.price)})
</button>
</li>
))}
{filteredDishes.length === 0 && (
<li className="px-2 py-1 text-sm text-gray-500">
{formatMessage(dict.dishManager.noResults, {
query: dishSearch,
})}
</li>
)}
</ul>
</>
)}
<div className="flex gap-1 justify-center">
<button
type="button"
className="btn btn-add"
onClick={addEntry}
disabled={!selectedDish}
>
{dict.common.add}
</button>
<button type="button" className="btn btn-neutral" onClick={cancelAdding}>
{dict.common.cancel}
</button>
</div>
</div>
) : (
<button
type="button"
className="btn btn-add mt-1"
onClick={() => startAdding(iso, sectionKey)}
>
{dict.weekEditor.addMeal}
</button>
)}
</>
);
}
const selectedDay = days[selectedDayIdx];
return (
<div>
<div className="flex flex-wrap items-center gap-3 mb-4">
@@ -136,140 +253,76 @@ export default function WeekEditor({ monday }: { monday: string }) {
{loading ? (
<p className="text-gray-500">{dict.weekEditor.loading}</p>
) : (
<table className="menu-table">
<thead>
<tr>
<th className="section-head w-52" aria-hidden />
{days.map((d) => (
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso, dict.dateLocale)}
</th>
<>
{/* Mobil (< "sm"): ein Tag auf einmal, per Tabs gewählt */}
<div className="sm:hidden">
<div className="flex gap-1 mb-3">
{days.map((d, i) => (
<button
key={d.iso}
type="button"
onClick={() => setSelectedDayIdx(i)}
className={`flex-1 rounded-md py-1.5 text-sm font-semibold border transition-colors ${
i === selectedDayIdx
? "bg-[var(--c-primary)] text-white border-[var(--c-primary)]"
: "bg-white text-gray-600 border-gray-200"
} ${i === todayIdx ? "ring-2 ring-[var(--c-accent)] ring-offset-1" : ""}`}
>
{d.short}
</button>
))}
</tr>
</thead>
<tbody>
{sections.map((s) => (
<tr key={s.key}>
<th className="section-head">
<div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => {
const isAdding =
adding?.iso === d.iso && adding?.section === s.key;
return (
<td key={d.iso} className="cell">
{entriesFor(entries, d.iso, s.key).map((e) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish">
<span className="font-bold">
{localized(e.dish, locale)}
</span>
{codes && <sup className="dish-codes">{codes}</sup>}
{" – "}
{formatPrice(e.dish.price)}
<div className="mt-1">
<button
type="button"
className="btn btn-del"
onClick={() => removeEntry(e.id)}
>
{dict.common.delete}
</button>
</div>
</div>
);
})}
{isAdding ? (
<div className="mt-2 space-y-1 text-left">
{selectedDishObj ? (
<div className="flex items-center gap-1 text-sm">
<span className="mr-auto">
{localized(selectedDishObj, locale)} (
{formatPrice(selectedDishObj.price)})
</span>
<button
type="button"
className="text-gray-400 hover:text-[var(--c-primary)]"
onClick={() => {
setSelectedDish("");
setDishSearch("");
}}
aria-label={dict.weekEditor.changeDish}
title={dict.weekEditor.changeDish}
>
×
</button>
</div>
) : (
<>
<input
type="search"
className="input"
autoFocus
placeholder={dict.dishManager.searchPlaceholder}
value={dishSearch}
onChange={(e) => setDishSearch(e.target.value)}
/>
<ul className="max-h-32 overflow-y-auto border border-gray-200 rounded-md bg-white divide-y divide-gray-100">
{filteredDishes.map((dish) => (
<li key={dish.id}>
<button
type="button"
className="w-full text-left px-2 py-1 text-sm hover:bg-[var(--c-row-bg-alt)]"
onClick={() => setSelectedDish(dish.id)}
>
{localized(dish, locale)} ({formatPrice(dish.price)})
</button>
</li>
))}
{filteredDishes.length === 0 && (
<li className="px-2 py-1 text-sm text-gray-500">
{formatMessage(dict.dishManager.noResults, {
query: dishSearch,
})}
</li>
)}
</ul>
</>
)}
<div className="flex gap-1 justify-center">
<button
type="button"
className="btn btn-add"
onClick={addEntry}
disabled={!selectedDish}
>
{dict.common.add}
</button>
<button
type="button"
className="btn btn-neutral"
onClick={cancelAdding}
>
{dict.common.cancel}
</button>
</div>
</div>
) : (
<button
type="button"
className="btn btn-add mt-1"
onClick={() => startAdding(d.iso, s.key)}
>
{dict.weekEditor.addMeal}
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<h2 className="font-bold text-[var(--c-primary)] mb-2">
{selectedDay.weekday} · {formatDateLong(selectedDay.iso, dict.dateLocale)}
</h2>
<div className="space-y-3">
{sections.map((s) => (
<div key={s.key} className="rounded-md border border-gray-200 overflow-hidden">
<div className="bg-[var(--c-primary)] text-[var(--c-header-text)] px-3 py-1.5 text-sm">
<span className="font-semibold">{localized(s, locale)}</span>{" "}
<span className="opacity-80">· {s.time_range}</span>
</div>
<div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm text-center">
{renderCell(selectedDay.iso, s.key)}
</div>
</div>
))}
</div>
</div>
{/* Ab "sm": klassische Wochentabelle */}
<div className="hidden sm:block">
<table className="menu-table">
<thead>
<tr>
<th className="section-head w-52" aria-hidden />
{days.map((d) => (
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso, dict.dateLocale)}
</th>
))}
</tr>
</thead>
<tbody>
{sections.map((s) => (
<tr key={s.key}>
<th className="section-head">
<div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => (
<td key={d.iso} className="cell">
{renderCell(d.iso, s.key)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</>
)}
<p className="mt-4 text-sm text-gray-500">
{hintBefore}
+13 -11
View File
@@ -2,9 +2,7 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { LOCALES, LOCALE_LABELS } from "@/lib/i18n/shared";
import type { Locale } from "@/lib/i18n/shared";
import { useLocale } from "@/components/i18n/LocaleProvider";
import { useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
export default function LanguageSwitcher({
className = "",
@@ -14,10 +12,11 @@ export default function LanguageSwitcher({
variant?: "dark" | "light";
}) {
const locale = useLocale();
const languages = useLanguages();
const router = useRouter();
const [busy, setBusy] = useState(false);
async function change(next: Locale) {
async function change(next: string) {
if (next === locale || busy) return;
setBusy(true);
await fetch("/api/locale", {
@@ -29,6 +28,9 @@ export default function LanguageSwitcher({
setBusy(false);
}
// Bei nur einer aktiven Sprache gibt es nichts umzuschalten.
if (languages.length <= 1) return null;
const activeCls =
variant === "dark" ? "bg-white/25 text-white" : "bg-gray-200 text-gray-900";
const inactiveCls =
@@ -38,19 +40,19 @@ export default function LanguageSwitcher({
return (
<div className={`flex gap-1 text-xs ${className}`} aria-label="Sprache">
{LOCALES.map((l) => (
{languages.map((l) => (
<button
key={l}
key={l.code}
type="button"
onClick={() => change(l)}
onClick={() => change(l.code)}
disabled={busy}
aria-current={l === locale}
title={LOCALE_LABELS[l]}
aria-current={l.code === locale}
title={l.label}
className={`rounded px-1.5 py-0.5 uppercase font-semibold transition-colors ${
l === locale ? activeCls : inactiveCls
l.code === locale ? activeCls : inactiveCls
}`}
>
{l}
{l.code}
</button>
))}
</div>
+20 -4
View File
@@ -1,16 +1,23 @@
"use client";
// Stellt Sprache + Wörterbuch für Client-Komponenten bereit. Wird einmal
// im Root-Layout mit dem serverseitig aus dem Cookie gelesenen Locale
// instanziiert; Client-Komponenten lesen darüber via useDict()/useLocale().
// Stellt Sprache + Wörterbuch + Liste der aktiven Sprachen für Client-
// Komponenten bereit. Wird einmal im Root-Layout mit den serverseitig
// ermittelten Werten instanziiert; Client-Komponenten lesen darüber
// via useDict()/useLocale()/useLanguages().
import { createContext, useContext, useMemo } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import type { Dict, Locale } from "@/lib/i18n/shared";
export interface LanguageInfo {
code: string;
label: string;
}
interface LocaleContextValue {
locale: Locale;
dict: Dict;
languages: LanguageInfo[];
}
const LocaleContext = createContext<LocaleContextValue | null>(null);
@@ -18,9 +25,13 @@ const LocaleContext = createContext<LocaleContextValue | null>(null);
export function LocaleProvider({
locale,
dict,
languages,
children,
}: LocaleContextValue & { children: React.ReactNode }) {
const value = useMemo(() => ({ locale, dict }), [locale, dict]);
const value = useMemo(
() => ({ locale, dict, languages }),
[locale, dict, languages]
);
return (
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
);
@@ -40,6 +51,11 @@ export function useLocale(): Locale {
return useLocaleContext().locale;
}
/** Alle aktiven Sprachen (Code + Anzeigename), z. B. für den Sprachumschalter. */
export function useLanguages(): LanguageInfo[] {
return useLocaleContext().languages;
}
/** t("Text mit {var}", { var: "Wert" }) für Platzhalter in Übersetzungen. */
export function useFormatMessage() {
return formatMessage;
+34
View File
@@ -0,0 +1,34 @@
-- Dynamisches Sprachpaket-System für die Bedienoberfläche: Sprachen
-- sind ab jetzt zur Laufzeit im Admin-Bereich verwaltbar (aktivieren/
-- deaktivieren, neue Sprache per Datei-Import hinzufügen) statt fest
-- im Code zu stehen. Deutsch bleibt als "de"-Objekt in
-- lib/i18n/dictionaries.ts die immer vollständige Basis-Sprache – sie
-- braucht keine Zeilen in ui_translations, nur einen Eintrag in
-- languages als Referenz für den Sprachumschalter.
create table languages (
code text primary key,
label text not null,
is_base boolean not null default false,
active boolean not null default true,
-- Hash der DE-Vorlage (siehe lib/i18n/flatten.ts + lib/db/languages.ts)
-- zum Zeitpunkt des letzten Imports – weicht der aktuelle Hash davon
-- ab, sind seit dem Import neue Begriffe hinzugekommen ("veraltet").
-- Bei der Basis-Sprache immer null (nie veraltet, da Code = Vorlage).
template_version text,
updated_at timestamptz not null default now()
);
-- Eine Zeile je Sprache + Übersetzungsschlüssel (z. B.
-- "login.errorGeneric"), fehlende Schlüssel fallen zur Laufzeit
-- automatisch auf die deutsche Vorlage zurück statt leer zu bleiben.
create table ui_translations (
language_code text not null references languages(code) on delete cascade,
key text not null,
value text not null,
primary key (language_code, key)
);
insert into languages (code, label, is_base, active, template_version) values
('de', 'Deutsch', true, true, null),
('en', 'English', false, true, '2da2662b794f019f6f7dba99ef93e2035eb38d1a78bd73bdccbb6b3cab447d5d'),
('pl', 'Polski', false, true, '2da2662b794f019f6f7dba99ef93e2035eb38d1a78bd73bdccbb6b3cab447d5d');
+574
View File
@@ -0,0 +1,574 @@
-- Einmaliger Seed: bisherige fest im Code hinterlegte Englisch-/Polnisch-
-- Übersetzungen (lib/i18n/dictionaries.ts) als Sprachpakete in
-- ui_translations überführen, damit nach dem Umbau auf das dynamische
-- Sprachpaket-System (siehe 0004_languages.sql) nichts verloren geht.
insert into ui_translations (language_code, key, value) values
('en', 'common.save', 'Save'),
('en', 'common.cancel', 'Cancel'),
('en', 'common.delete', 'Delete'),
('en', 'common.edit', 'Edit'),
('en', 'common.add', 'Add'),
('en', 'common.back', 'Back'),
('en', 'common.login', 'Log in'),
('en', 'common.logout', 'Log out'),
('en', 'common.loading', 'Loading…'),
('en', 'common.you', '(you)'),
('en', 'common.name', 'Name'),
('en', 'common.email', 'Email address'),
('en', 'common.password', 'Password'),
('en', 'common.role', 'Role'),
('en', 'common.roleAdmin', 'Administrator'),
('en', 'common.roleStaff', 'Canteen staff'),
('en', 'weekdays.long.0', 'Monday'),
('en', 'weekdays.long.1', 'Tuesday'),
('en', 'weekdays.long.2', 'Wednesday'),
('en', 'weekdays.long.3', 'Thursday'),
('en', 'weekdays.long.4', 'Friday'),
('en', 'weekdays.short.0', 'Mon'),
('en', 'weekdays.short.1', 'Tue'),
('en', 'weekdays.short.2', 'Wed'),
('en', 'weekdays.short.3', 'Thu'),
('en', 'weekdays.short.4', 'Fri'),
('en', 'dateLocale', 'en-GB'),
('en', 'header.canteen', 'Canteen'),
('en', 'header.menuGroup', 'Menu'),
('en', 'header.weekPlan', 'Weekly plan'),
('en', 'header.sections', 'Meal times'),
('en', 'header.dishes', 'Dishes'),
('en', 'header.labels', 'Allergens & Additives'),
('en', 'header.adminGroup', 'Administration'),
('en', 'header.users', 'Users'),
('en', 'header.design', 'Design'),
('en', 'header.languages', 'Languages'),
('en', 'header.suggestions', 'Suggestions'),
('en', 'header.openSuggestions', '{count} open dish suggestions'),
('en', 'header.viewMenu', 'View menu'),
('en', 'header.myAccount', 'My account'),
('en', 'header.menuToggle', 'Open/close menu'),
('en', 'login.title', 'Sign in'),
('en', 'login.submit', 'Log in'),
('en', 'login.or', 'or'),
('en', 'login.passkeyButton', 'Sign in with passkey'),
('en', 'login.ssoButton', 'Sign in with Single Sign-On'),
('en', 'login.backToMenu', '← Back to menu'),
('en', 'login.errorInvalidCredentials', 'Email address or password is incorrect.'),
('en', 'login.errorPasskeyCancelled', 'Passkey sign-in was cancelled.'),
('en', 'login.errorGeneric', 'Sign-in failed: {message}'),
('en', 'login.errorPasswordDisabled', 'Password sign-in is disabled for this account. Please sign in with a passkey.'),
('en', 'login.errorOidcInvalidState', 'The SSO sign-in expired or was invalid. Please try again.'),
('en', 'login.errorOidcUnknownUser', 'No account exists for this email address. Please contact your administrator.'),
('en', 'login.errorOidcFailed', 'SSO sign-in failed. Please try again.'),
('en', 'publicMenu.prevWeek', '← Previous week'),
('en', 'publicMenu.nextWeek', 'Next week →'),
('en', 'publicMenu.prevWeekShort', '← Week'),
('en', 'publicMenu.nextWeekShort', 'Week →'),
('en', 'publicMenu.today', 'Today'),
('en', 'publicMenu.dayTab', 'Day'),
('en', 'publicMenu.weekTab', 'Week'),
('en', 'publicMenu.allergens', 'Allergens'),
('en', 'publicMenu.additives', 'Additives'),
('en', 'publicMenu.labelingNoticeTitle', 'Allergens & additives'),
('en', 'publicMenu.suggestDish', 'Suggest a dish'),
('en', 'publicMenu.noEntry', '–'),
('en', 'publicMenu.rssFeed', 'Subscribe to RSS feed'),
('en', 'weekEditor.title', 'Weekly plan'),
('en', 'weekEditor.loading', 'Loading weekly plan…'),
('en', 'weekEditor.changeDish', 'Choose a different dish'),
('en', 'weekEditor.addMeal', 'Add meal'),
('en', 'weekEditor.newDishesHint', 'Add new dishes under {link}.'),
('en', 'weekEditor.newDishesLinkLabel', 'Dishes'),
('en', 'weekEditor.loadError', 'Loading failed: {message}'),
('en', 'weekEditor.duplicateEntry', 'This dish is already assigned on this day.'),
('en', 'weekEditor.saveError', 'Saving failed: {message}'),
('en', 'weekEditor.deleteError', 'Deleting failed: {message}'),
('en', 'dishManager.title', 'Dishes'),
('en', 'dishManager.newDish', 'New dish'),
('en', 'dishManager.editDish', 'Edit dish'),
('en', 'dishManager.nameLabel', 'Name'),
('en', 'dishManager.nameEnLabel', 'Name (English)'),
('en', 'dishManager.namePlLabel', 'Name (Polish)'),
('en', 'dishManager.autoTranslate', 'Auto-translate'),
('en', 'dishManager.translating', 'Translating…'),
('en', 'dishManager.translated', 'Translated.'),
('en', 'dishManager.priceLabel', 'Price in €'),
('en', 'dishManager.pricePlaceholder', '3.50'),
('en', 'dishManager.searchPlaceholder', 'Search dishes…'),
('en', 'dishManager.selectAll', 'Select all'),
('en', 'dishManager.selectedCount', '{count} selected'),
('en', 'dishManager.deleteSelected', 'Delete selected'),
('en', 'dishManager.deleteSelectedCount', 'Delete selected ({count})'),
('en', 'dishManager.empty', 'No dishes yet.'),
('en', 'dishManager.noResults', 'No results for “{query}”.'),
('en', 'dishManager.confirmDelete', 'Really delete “{name}”? The dish will also be removed from all weekly plans.'),
('en', 'dishManager.confirmDeleteSelected', 'Really delete {count} dish(es)? They will also be removed from all weekly plans.'),
('en', 'dishManager.invalidPrice', 'Please enter a valid price, e.g. 3.50'),
('en', 'dishManager.loadError', 'Loading failed: {message}'),
('en', 'dishManager.saveError', 'Saving failed: {message}'),
('en', 'dishManager.deleteError', 'Deleting failed: {message}'),
('en', 'dishManager.exportButton', 'Export'),
('en', 'dishManager.importButton', 'Import'),
('en', 'dishManager.exportError', 'Export failed: {message}'),
('en', 'dishManager.importError', 'Import failed: {message}'),
('en', 'dishManager.importSuccess', '{created} created, {updated} updated.'),
('en', 'dishManager.importSkipped', '{count} row(s) skipped: {reasons}'),
('en', 'dishManager.importSkippedRow', 'Row {row}: {reason}'),
('en', 'dishManager.labelingDisabledHint', 'Allergens/additives are currently disabled (notice mode active, see "Allergens & additives").'),
('en', 'codeList.allergensTitle', 'Allergens'),
('en', 'codeList.additivesTitle', 'Additives'),
('en', 'codeList.allergenCodeLabel', 'Code (e.g. A)'),
('en', 'codeList.additiveCodeLabel', 'Code (e.g. 1)'),
('en', 'codeList.nameLabel', 'Name'),
('en', 'codeList.empty', 'No entries.'),
('en', 'codeList.confirmDelete', 'Really delete “{code} – {name}”?'),
('en', 'codeList.duplicateCode', 'The code “{code}” already exists.'),
('en', 'codeList.loadError', 'Loading failed: {message}'),
('en', 'codeList.saveError', 'Saving failed: {message}'),
('en', 'codeList.deleteError', 'Deleting failed: {message}'),
('en', 'codeList.exportButton', 'Export'),
('en', 'codeList.importButton', 'Import'),
('en', 'codeList.exportError', 'Export failed: {message}'),
('en', 'codeList.importError', 'Import failed: {message}'),
('en', 'codeList.importSuccess', '{created} created, {updated} updated.'),
('en', 'codeList.importSkipped', '{count} row(s) skipped: {reasons}'),
('en', 'codeList.importSkippedRow', 'Row {row}: {reason}'),
('en', 'sectionManager.title', 'Meal times'),
('en', 'sectionManager.newSection', 'New meal time'),
('en', 'sectionManager.editSection', 'Edit meal time'),
('en', 'sectionManager.nameLabel', 'Name'),
('en', 'sectionManager.nameEnLabel', 'Name (English)'),
('en', 'sectionManager.namePlLabel', 'Name (Polish)'),
('en', 'sectionManager.timeFrom', 'From'),
('en', 'sectionManager.timeTo', 'To'),
('en', 'sectionManager.moveUp', 'Move up'),
('en', 'sectionManager.moveDown', 'Move down'),
('en', 'sectionManager.empty', 'No meal times yet.'),
('en', 'sectionManager.confirmDelete', 'Really delete “{name}”? Assigned dishes will also be removed from all weekly plans.'),
('en', 'sectionManager.loadError', 'Loading failed: {message}'),
('en', 'sectionManager.saveError', 'Saving failed: {message}'),
('en', 'sectionManager.deleteError', 'Deleting failed: {message}'),
('en', 'userManager.title', 'Users'),
('en', 'userManager.newUser', 'New user'),
('en', 'userManager.editUser', 'Edit user'),
('en', 'userManager.newPasswordLabel', 'New password (optional)'),
('en', 'userManager.repeatPasswordLabel', 'Repeat password'),
('en', 'userManager.passwordMismatch', 'The passwords do not match.'),
('en', 'userManager.confirmDelete', 'Really delete user “{email}”? Access will be permanently removed.'),
('en', 'userManager.loadError', 'Loading failed: {message}'),
('en', 'designEditor.title', 'Design'),
('en', 'designEditor.logoTitle', 'Logo'),
('en', 'designEditor.logoHint', 'The logo is displayed centered above the menu.'),
('en', 'designEditor.logoNone', 'No logo set.'),
('en', 'designEditor.logoUpload', 'Upload logo'),
('en', 'designEditor.logoRemove', 'Remove logo'),
('en', 'designEditor.logoUpdated', 'Logo updated.'),
('en', 'designEditor.logoRemoved', 'Logo removed.'),
('en', 'designEditor.faviconTitle', 'Favicon'),
('en', 'designEditor.faviconHint', 'Shown as the icon in the browser tab. Without a custom favicon, the application''s default icon is used.'),
('en', 'designEditor.faviconNone', 'No custom favicon set.'),
('en', 'designEditor.faviconUpload', 'Upload favicon'),
('en', 'designEditor.faviconRemove', 'Remove favicon'),
('en', 'designEditor.faviconUpdated', 'Favicon updated.'),
('en', 'designEditor.faviconRemoved', 'Favicon removed.'),
('en', 'designEditor.invalidImage', 'Please choose an image file (PNG, JPG, SVG, WebP, ICO).'),
('en', 'designEditor.colorsTitle', 'Colors'),
('en', 'designEditor.colorsSave', 'Save colors'),
('en', 'designEditor.colorsReset', 'Reset to default'),
('en', 'designEditor.colorsSaved', 'Colors saved.'),
('en', 'designEditor.colorPrimary', 'Primary color (table header)'),
('en', 'designEditor.colorPrimaryDark', 'Primary color, dark (internal top bar)'),
('en', 'designEditor.colorAccent', 'Accent color'),
('en', 'designEditor.colorHeaderText', 'Table header text color'),
('en', 'designEditor.colorRowBg', 'Cell background'),
('en', 'designEditor.colorRowBgAlt', 'Cell background (alternating)'),
('en', 'designEditor.colorAddBtn', '“Add” button'),
('en', 'designEditor.colorDeleteBtn', '“Delete” button'),
('en', 'designEditor.loadError', 'Loading failed: {message}'),
('en', 'designEditor.saveError', 'Saving failed: {message}'),
('en', 'designEditor.uploadError', 'Upload failed: {message}'),
('en', 'designEditor.confirmRemoveLogo', 'Really remove the logo?'),
('en', 'designEditor.confirmRemoveFavicon', 'Really remove the favicon?'),
('en', 'labelingNotice.title', 'Allergen & additive display'),
('en', 'labelingNotice.hint', 'By default every guest sees a detailed allergen/additive selection per dish. Alternatively, only a notice text can be shown instead – the per-dish selection is then also hidden in the dish manager.'),
('en', 'labelingNotice.modeDetailed', 'Show detailed selection per dish'),
('en', 'labelingNotice.modeNotice', 'Show only notice text'),
('en', 'labelingNotice.noticeDeLabel', 'Notice text (German)'),
('en', 'labelingNotice.noticeEnLabel', 'Notice text (English)'),
('en', 'labelingNotice.noticePlLabel', 'Notice text (Polish)'),
('en', 'labelingNotice.save', 'Save'),
('en', 'labelingNotice.saved', 'Saved.'),
('en', 'labelingNotice.loadError', 'Loading failed: {message}'),
('en', 'labelingNotice.saveError', 'Saving failed: {message}'),
('en', 'passkeys.title', 'Passkeys'),
('en', 'passkeys.hint', 'With a passkey (fingerprint, face recognition, or a security key) you can sign in without a password in the future. Passkeys are bound to a domain – after a domain change they need to be registered again.'),
('en', 'passkeys.register', 'Register new passkey'),
('en', 'passkeys.empty', 'No passkeys registered yet.'),
('en', 'passkeys.createdOn', '– created on {date}'),
('en', 'passkeys.registered', 'Passkey registered successfully.'),
('en', 'passkeys.registerError', 'Registration failed: {message}'),
('en', 'passkeys.registerCancelled', 'Passkey registration was cancelled.'),
('en', 'passkeys.loadError', 'Could not load passkeys: {message}'),
('en', 'passkeys.deleteError', 'Deleting failed: {message}'),
('en', 'passkeys.confirmDelete', 'Really delete this passkey?'),
('en', 'account.title', 'My account'),
('en', 'account.personalTitle', 'Personal details'),
('en', 'account.saveName', 'Save name'),
('en', 'account.nameSaved', 'Name saved.'),
('en', 'account.nameRequired', 'Please enter a name.'),
('en', 'account.emailTitle', 'Email address'),
('en', 'account.changeEmail', 'Change email address'),
('en', 'account.emailChanged', 'Email address changed.'),
('en', 'account.passwordTitle', 'Change password'),
('en', 'account.currentPassword', 'Current password (for verification)'),
('en', 'account.newPassword', 'New password'),
('en', 'account.newPasswordRepeat', 'Repeat new password'),
('en', 'account.changePassword', 'Change password'),
('en', 'account.passwordChanged', 'Password changed.'),
('en', 'account.passwordMismatch', 'The passwords do not match.'),
('en', 'account.passwordTooShort', 'The new password must be at least 8 characters long.'),
('en', 'account.passwordAuthTitle', 'Password sign-in'),
('en', 'account.passwordAuthHint', 'You can disable email and password sign-in for your account and sign in exclusively with a passkey in the future. At least one passkey must be registered for this.'),
('en', 'account.passwordAuthCheckbox', 'Email and password sign-in allowed'),
('en', 'account.passwordAuthEnabled', 'Password sign-in enabled.'),
('en', 'account.passwordAuthDisabled', 'Password sign-in disabled.'),
('en', 'account.passwordAuthNeedsPasskey', 'You can''t disable password sign-in without a registered passkey – you would lock yourself out. Register a passkey below first.'),
('en', 'account.confirmDisablePasswordAuth', 'Really disable email and password sign-in? Afterwards you can only sign in with a passkey.'),
('en', 'account.wrongCurrentPassword', 'The current password is incorrect.'),
('en', 'account.emailAlreadyUsed', 'This email address is already in use.'),
('en', 'account.saveError', 'Saving failed: {message}'),
('en', 'account.changeError', 'Change failed: {message}'),
('en', 'suggestions.formTitle', 'Suggest a dish'),
('en', 'suggestions.formHint', 'Missing a dish on the menu? Suggest it here – the canteen team will review your suggestion.'),
('en', 'suggestions.nameLabel', 'Dish name'),
('en', 'suggestions.descriptionLabel', 'Description / ingredients (optional)'),
('en', 'suggestions.submit', 'Submit suggestion'),
('en', 'suggestions.submitting', 'Submitting…'),
('en', 'suggestions.success', 'Thanks! Your suggestion has been submitted.'),
('en', 'suggestions.duplicate', 'This dish already exists on the menu.'),
('en', 'suggestions.genericError', 'Submission failed: {message}'),
('en', 'suggestions.nameRequired', 'Please enter a name.'),
('en', 'suggestions.backToMenu', '← Back to menu'),
('en', 'suggestions.reviewTitle', 'Dish suggestions'),
('en', 'suggestions.reviewEmpty', 'No suggestions yet.'),
('en', 'suggestions.accept', 'Add as dish'),
('en', 'suggestions.reject', 'Reject'),
('en', 'suggestions.confirmReject', 'Really reject and delete the suggestion “{name}”?'),
('en', 'suggestions.accepted', 'Added as dish.'),
('en', 'suggestions.rejected', 'Suggestion rejected.'),
('en', 'suggestions.loadError', 'Loading failed: {message}'),
('en', 'suggestions.actionError', 'Action failed: {message}'),
('en', 'suggestions.submittedOn', 'submitted on {date}'),
('en', 'languageManager.title', 'Languages'),
('en', 'languageManager.hint', 'German is the fixed base language and always complete. Additional languages are imported as a translation file and automatically filled in with German wherever a term is missing.'),
('en', 'languageManager.codeColumn', 'Code'),
('en', 'languageManager.labelColumn', 'Display name'),
('en', 'languageManager.activeColumn', 'Active'),
('en', 'languageManager.completenessColumn', 'Completeness'),
('en', 'languageManager.baseBadge', 'Base language'),
('en', 'languageManager.complete', 'Complete'),
('en', 'languageManager.completeness', '{percent}% translated'),
('en', 'languageManager.outdated', '{count} new term(s) since last import'),
('en', 'languageManager.downloadTemplate', 'Download template'),
('en', 'languageManager.uploadTranslation', 'Upload translation'),
('en', 'languageManager.newLanguageTitle', 'Add new language'),
('en', 'languageManager.newLanguageCodeLabel', 'Language code (e.g. fr)'),
('en', 'languageManager.newLanguageLabelLabel', 'Display name (e.g. Français)'),
('en', 'languageManager.create', 'Create'),
('en', 'languageManager.delete', 'Delete'),
('en', 'languageManager.confirmDelete', 'Really delete “{label}”? All translations for this language will be lost.'),
('en', 'languageManager.cannotDeleteBase', 'The base language cannot be deleted.'),
('en', 'languageManager.cannotDeactivateLast', 'At least one language must stay active.'),
('en', 'languageManager.loadError', 'Loading failed: {message}'),
('en', 'languageManager.saveError', 'Saving failed: {message}'),
('en', 'languageManager.deleteError', 'Deleting failed: {message}'),
('en', 'languageManager.importSuccess', '{count} terms imported.'),
('en', 'languageManager.importError', 'Import failed: {message}'),
('en', 'languageManager.importInvalidFile', 'The file could not be read (invalid JSON).'),
('en', 'languageManager.importMissingPlaceholder', '“{key}”: placeholder {placeholder} missing in the translation – row skipped.'),
('en', 'languageManager.duplicateCode', 'The language code “{code}” already exists.'),
('en', 'languageManager.invalidCode', 'Please enter a language code of 2-5 lowercase letters.');
insert into ui_translations (language_code, key, value) values
('pl', 'common.save', 'Zapisz'),
('pl', 'common.cancel', 'Anuluj'),
('pl', 'common.delete', 'Usuń'),
('pl', 'common.edit', 'Edytuj'),
('pl', 'common.add', 'Dodaj'),
('pl', 'common.back', 'Wstecz'),
('pl', 'common.login', 'Zaloguj się'),
('pl', 'common.logout', 'Wyloguj się'),
('pl', 'common.loading', 'Wczytywanie…'),
('pl', 'common.you', '(ty)'),
('pl', 'common.name', 'Nazwa'),
('pl', 'common.email', 'Adres e-mail'),
('pl', 'common.password', 'Hasło'),
('pl', 'common.role', 'Rola'),
('pl', 'common.roleAdmin', 'Administrator'),
('pl', 'common.roleStaff', 'Personel stołówki'),
('pl', 'weekdays.long.0', 'Poniedziałek'),
('pl', 'weekdays.long.1', 'Wtorek'),
('pl', 'weekdays.long.2', 'Środa'),
('pl', 'weekdays.long.3', 'Czwartek'),
('pl', 'weekdays.long.4', 'Piątek'),
('pl', 'weekdays.short.0', 'Pon'),
('pl', 'weekdays.short.1', 'Wto'),
('pl', 'weekdays.short.2', 'Śro'),
('pl', 'weekdays.short.3', 'Czw'),
('pl', 'weekdays.short.4', 'Pią'),
('pl', 'dateLocale', 'pl-PL'),
('pl', 'header.canteen', 'Stołówka'),
('pl', 'header.menuGroup', 'Jadłospis'),
('pl', 'header.weekPlan', 'Plan tygodnia'),
('pl', 'header.sections', 'Pory posiłków'),
('pl', 'header.dishes', 'Dania'),
('pl', 'header.labels', 'Alergeny i dodatki'),
('pl', 'header.adminGroup', 'Administracja'),
('pl', 'header.users', 'Użytkownicy'),
('pl', 'header.design', 'Wygląd'),
('pl', 'header.languages', 'Języki'),
('pl', 'header.suggestions', 'Propozycje'),
('pl', 'header.openSuggestions', '{count} otwartych propozycji dań'),
('pl', 'header.viewMenu', 'Zobacz jadłospis'),
('pl', 'header.myAccount', 'Moje konto'),
('pl', 'header.menuToggle', 'Otwórz/zamknij menu'),
('pl', 'login.title', 'Logowanie'),
('pl', 'login.submit', 'Zaloguj się'),
('pl', 'login.or', 'lub'),
('pl', 'login.passkeyButton', 'Zaloguj się kluczem dostępu'),
('pl', 'login.ssoButton', 'Zaloguj się przez Single Sign-On'),
('pl', 'login.backToMenu', '← Powrót do jadłospisu'),
('pl', 'login.errorInvalidCredentials', 'Nieprawidłowy adres e-mail lub hasło.'),
('pl', 'login.errorPasskeyCancelled', 'Logowanie kluczem dostępu zostało anulowane.'),
('pl', 'login.errorGeneric', 'Logowanie nie powiodło się: {message}'),
('pl', 'login.errorPasswordDisabled', 'Logowanie hasłem jest wyłączone dla tego konta. Zaloguj się kluczem dostępu.'),
('pl', 'login.errorOidcInvalidState', 'Logowanie SSO wygasło lub jest nieprawidłowe. Spróbuj ponownie.'),
('pl', 'login.errorOidcUnknownUser', 'Nie istnieje konto dla tego adresu e-mail. Skontaktuj się z administracją.'),
('pl', 'login.errorOidcFailed', 'Logowanie SSO nie powiodło się. Spróbuj ponownie.'),
('pl', 'publicMenu.prevWeek', '← Poprzedni tydzień'),
('pl', 'publicMenu.nextWeek', 'Następny tydzień →'),
('pl', 'publicMenu.prevWeekShort', '← Tydzień'),
('pl', 'publicMenu.nextWeekShort', 'Tydzień →'),
('pl', 'publicMenu.today', 'Dziś'),
('pl', 'publicMenu.dayTab', 'Dzień'),
('pl', 'publicMenu.weekTab', 'Tydzień'),
('pl', 'publicMenu.allergens', 'Alergeny'),
('pl', 'publicMenu.additives', 'Dodatki'),
('pl', 'publicMenu.labelingNoticeTitle', 'Alergeny i dodatki'),
('pl', 'publicMenu.suggestDish', 'Zaproponuj danie'),
('pl', 'publicMenu.noEntry', '–'),
('pl', 'publicMenu.rssFeed', 'Subskrybuj kanał RSS'),
('pl', 'weekEditor.title', 'Plan tygodnia'),
('pl', 'weekEditor.loading', 'Wczytywanie planu tygodnia…'),
('pl', 'weekEditor.changeDish', 'Wybierz inne danie'),
('pl', 'weekEditor.addMeal', 'Dodaj posiłek'),
('pl', 'weekEditor.newDishesHint', 'Nowe dania możesz dodać w zakładce {link}.'),
('pl', 'weekEditor.newDishesLinkLabel', 'Dania'),
('pl', 'weekEditor.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'weekEditor.duplicateEntry', 'To danie jest już przypisane na ten dzień.'),
('pl', 'weekEditor.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'weekEditor.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'dishManager.title', 'Dania'),
('pl', 'dishManager.newDish', 'Nowe danie'),
('pl', 'dishManager.editDish', 'Edytuj danie'),
('pl', 'dishManager.nameLabel', 'Nazwa'),
('pl', 'dishManager.nameEnLabel', 'Nazwa (angielski)'),
('pl', 'dishManager.namePlLabel', 'Nazwa (polski)'),
('pl', 'dishManager.autoTranslate', 'Przetłumacz automatycznie'),
('pl', 'dishManager.translating', 'Tłumaczenie…'),
('pl', 'dishManager.translated', 'Przetłumaczono.'),
('pl', 'dishManager.priceLabel', 'Cena w €'),
('pl', 'dishManager.pricePlaceholder', '3,50'),
('pl', 'dishManager.searchPlaceholder', 'Szukaj dania…'),
('pl', 'dishManager.selectAll', 'Zaznacz wszystko'),
('pl', 'dishManager.selectedCount', 'Zaznaczono: {count}'),
('pl', 'dishManager.deleteSelected', 'Usuń zaznaczone'),
('pl', 'dishManager.deleteSelectedCount', 'Usuń zaznaczone ({count})'),
('pl', 'dishManager.empty', 'Nie dodano jeszcze żadnych dań.'),
('pl', 'dishManager.noResults', 'Brak wyników dla „{query}”.'),
('pl', 'dishManager.confirmDelete', 'Czy na pewno usunąć „{name}”? Danie zostanie również usunięte ze wszystkich planów tygodniowych.'),
('pl', 'dishManager.confirmDeleteSelected', 'Czy na pewno usunąć {count} danie/dania? Zostaną również usunięte ze wszystkich planów tygodniowych.'),
('pl', 'dishManager.invalidPrice', 'Podaj prawidłową cenę, np. 3,50'),
('pl', 'dishManager.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'dishManager.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'dishManager.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'dishManager.exportButton', 'Eksportuj'),
('pl', 'dishManager.importButton', 'Importuj'),
('pl', 'dishManager.exportError', 'Eksport nie powiódł się: {message}'),
('pl', 'dishManager.importError', 'Import nie powiódł się: {message}'),
('pl', 'dishManager.importSuccess', 'Utworzono: {created}, zaktualizowano: {updated}.'),
('pl', 'dishManager.importSkipped', 'Pominięto wierszy: {count}: {reasons}'),
('pl', 'dishManager.importSkippedRow', 'Wiersz {row}: {reason}'),
('pl', 'dishManager.labelingDisabledHint', 'Alergeny/dodatki są obecnie wyłączone (aktywny tryb komunikatu, patrz „Alergeny i dodatki”).'),
('pl', 'codeList.allergensTitle', 'Alergeny'),
('pl', 'codeList.additivesTitle', 'Dodatki'),
('pl', 'codeList.allergenCodeLabel', 'Kod (np. A)'),
('pl', 'codeList.additiveCodeLabel', 'Kod (np. 1)'),
('pl', 'codeList.nameLabel', 'Nazwa'),
('pl', 'codeList.empty', 'Brak wpisów.'),
('pl', 'codeList.confirmDelete', 'Czy na pewno usunąć „{code} – {name}”?'),
('pl', 'codeList.duplicateCode', 'Kod „{code}” już istnieje.'),
('pl', 'codeList.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'codeList.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'codeList.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'codeList.exportButton', 'Eksportuj'),
('pl', 'codeList.importButton', 'Importuj'),
('pl', 'codeList.exportError', 'Eksport nie powiódł się: {message}'),
('pl', 'codeList.importError', 'Import nie powiódł się: {message}'),
('pl', 'codeList.importSuccess', 'Utworzono: {created}, zaktualizowano: {updated}.'),
('pl', 'codeList.importSkipped', 'Pominięto wierszy: {count}: {reasons}'),
('pl', 'codeList.importSkippedRow', 'Wiersz {row}: {reason}'),
('pl', 'sectionManager.title', 'Pory posiłków'),
('pl', 'sectionManager.newSection', 'Nowa pora posiłku'),
('pl', 'sectionManager.editSection', 'Edytuj porę posiłku'),
('pl', 'sectionManager.nameLabel', 'Nazwa'),
('pl', 'sectionManager.nameEnLabel', 'Nazwa (angielski)'),
('pl', 'sectionManager.namePlLabel', 'Nazwa (polski)'),
('pl', 'sectionManager.timeFrom', 'Od'),
('pl', 'sectionManager.timeTo', 'Do'),
('pl', 'sectionManager.moveUp', 'Przesuń w górę'),
('pl', 'sectionManager.moveDown', 'Przesuń w dół'),
('pl', 'sectionManager.empty', 'Nie dodano jeszcze żadnych pór posiłków.'),
('pl', 'sectionManager.confirmDelete', 'Czy na pewno usunąć „{name}”? Przypisane dania zostaną również usunięte ze wszystkich planów tygodniowych.'),
('pl', 'sectionManager.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'sectionManager.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'sectionManager.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'userManager.title', 'Użytkownicy'),
('pl', 'userManager.newUser', 'Nowy użytkownik'),
('pl', 'userManager.editUser', 'Edytuj użytkownika'),
('pl', 'userManager.newPasswordLabel', 'Nowe hasło (opcjonalnie)'),
('pl', 'userManager.repeatPasswordLabel', 'Powtórz hasło'),
('pl', 'userManager.passwordMismatch', 'Hasła nie są zgodne.'),
('pl', 'userManager.confirmDelete', 'Czy na pewno usunąć użytkownika „{email}”? Dostęp zostanie trwale usunięty.'),
('pl', 'userManager.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'designEditor.title', 'Wygląd'),
('pl', 'designEditor.logoTitle', 'Logo'),
('pl', 'designEditor.logoHint', 'Logo jest wyświetlane wyśrodkowane nad jadłospisem.'),
('pl', 'designEditor.logoNone', 'Nie ustawiono logo.'),
('pl', 'designEditor.logoUpload', 'Prześlij logo'),
('pl', 'designEditor.logoRemove', 'Usuń logo'),
('pl', 'designEditor.logoUpdated', 'Logo zaktualizowane.'),
('pl', 'designEditor.logoRemoved', 'Logo usunięte.'),
('pl', 'designEditor.faviconTitle', 'Favicon'),
('pl', 'designEditor.faviconHint', 'Wyświetlane jako ikona na karcie przeglądarki. Bez własnego favicona używana jest domyślna ikona aplikacji.'),
('pl', 'designEditor.faviconNone', 'Nie ustawiono własnego favicona.'),
('pl', 'designEditor.faviconUpload', 'Prześlij favicon'),
('pl', 'designEditor.faviconRemove', 'Usuń favicon'),
('pl', 'designEditor.faviconUpdated', 'Favicon zaktualizowany.'),
('pl', 'designEditor.faviconRemoved', 'Favicon usunięty.'),
('pl', 'designEditor.invalidImage', 'Wybierz plik graficzny (PNG, JPG, SVG, WebP, ICO).'),
('pl', 'designEditor.colorsTitle', 'Kolorystyka'),
('pl', 'designEditor.colorsSave', 'Zapisz kolory'),
('pl', 'designEditor.colorsReset', 'Przywróć domyślne'),
('pl', 'designEditor.colorsSaved', 'Kolory zapisane.'),
('pl', 'designEditor.colorPrimary', 'Kolor główny (nagłówek tabeli)'),
('pl', 'designEditor.colorPrimaryDark', 'Kolor główny, ciemny (górny pasek wewnętrzny)'),
('pl', 'designEditor.colorAccent', 'Kolor akcentu'),
('pl', 'designEditor.colorHeaderText', 'Kolor tekstu w nagłówku tabeli'),
('pl', 'designEditor.colorRowBg', 'Tło komórek'),
('pl', 'designEditor.colorRowBgAlt', 'Tło komórek (naprzemienne)'),
('pl', 'designEditor.colorAddBtn', 'Przycisk „Dodaj”'),
('pl', 'designEditor.colorDeleteBtn', 'Przycisk „Usuń”'),
('pl', 'designEditor.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'designEditor.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'designEditor.uploadError', 'Przesyłanie nie powiodło się: {message}'),
('pl', 'designEditor.confirmRemoveLogo', 'Czy na pewno usunąć logo?'),
('pl', 'designEditor.confirmRemoveFavicon', 'Czy na pewno usunąć favicon?'),
('pl', 'labelingNotice.title', 'Wyświetlanie alergenów i dodatków'),
('pl', 'labelingNotice.hint', 'Domyślnie każdy gość widzi szczegółowy wybór alergenów/dodatków dla każdego dania. Alternatywnie można zamiast tego wyświetlić tylko komunikat – wybór dla poszczególnych dań zostanie wtedy również ukryty w zarządzaniu daniami.'),
('pl', 'labelingNotice.modeDetailed', 'Pokaż szczegółowy wybór dla każdego dania'),
('pl', 'labelingNotice.modeNotice', 'Pokaż tylko komunikat'),
('pl', 'labelingNotice.noticeDeLabel', 'Tekst komunikatu (niemiecki)'),
('pl', 'labelingNotice.noticeEnLabel', 'Tekst komunikatu (angielski)'),
('pl', 'labelingNotice.noticePlLabel', 'Tekst komunikatu (polski)'),
('pl', 'labelingNotice.save', 'Zapisz'),
('pl', 'labelingNotice.saved', 'Zapisano.'),
('pl', 'labelingNotice.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'labelingNotice.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'passkeys.title', 'Klucze dostępu'),
('pl', 'passkeys.hint', 'Dzięki kluczowi dostępu (odcisk palca, rozpoznawanie twarzy lub klucz bezpieczeństwa) możesz w przyszłości logować się bez hasła. Klucze dostępu są przypisane do domeny – po zmianie domeny trzeba je zarejestrować ponownie.'),
('pl', 'passkeys.register', 'Zarejestruj nowy klucz dostępu'),
('pl', 'passkeys.empty', 'Nie zarejestrowano jeszcze żadnych kluczy dostępu.'),
('pl', 'passkeys.createdOn', '– utworzono {date}'),
('pl', 'passkeys.registered', 'Klucz dostępu został pomyślnie zarejestrowany.'),
('pl', 'passkeys.registerError', 'Rejestracja nie powiodła się: {message}'),
('pl', 'passkeys.registerCancelled', 'Rejestracja klucza dostępu została anulowana.'),
('pl', 'passkeys.loadError', 'Nie udało się wczytać kluczy dostępu: {message}'),
('pl', 'passkeys.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'passkeys.confirmDelete', 'Czy na pewno usunąć ten klucz dostępu?'),
('pl', 'account.title', 'Moje konto'),
('pl', 'account.personalTitle', 'Dane osobowe'),
('pl', 'account.saveName', 'Zapisz nazwę'),
('pl', 'account.nameSaved', 'Nazwa zapisana.'),
('pl', 'account.nameRequired', 'Podaj nazwę.'),
('pl', 'account.emailTitle', 'Adres e-mail'),
('pl', 'account.changeEmail', 'Zmień adres e-mail'),
('pl', 'account.emailChanged', 'Adres e-mail zmieniony.'),
('pl', 'account.passwordTitle', 'Zmień hasło'),
('pl', 'account.currentPassword', 'Obecne hasło (do weryfikacji)'),
('pl', 'account.newPassword', 'Nowe hasło'),
('pl', 'account.newPasswordRepeat', 'Powtórz nowe hasło'),
('pl', 'account.changePassword', 'Zmień hasło'),
('pl', 'account.passwordChanged', 'Hasło zmienione.'),
('pl', 'account.passwordMismatch', 'Hasła nie są zgodne.'),
('pl', 'account.passwordTooShort', 'Nowe hasło musi mieć co najmniej 8 znaków.'),
('pl', 'account.passwordAuthTitle', 'Logowanie hasłem'),
('pl', 'account.passwordAuthHint', 'Możesz wyłączyć logowanie e-mailem i hasłem dla swojego konta i logować się w przyszłości wyłącznie kluczem dostępu. Wymaga to zarejestrowania co najmniej jednego klucza dostępu.'),
('pl', 'account.passwordAuthCheckbox', 'Logowanie e-mailem i hasłem dozwolone'),
('pl', 'account.passwordAuthEnabled', 'Logowanie hasłem włączone.'),
('pl', 'account.passwordAuthDisabled', 'Logowanie hasłem wyłączone.'),
('pl', 'account.passwordAuthNeedsPasskey', 'Bez zarejestrowanego klucza dostępu nie możesz wyłączyć logowania hasłem – stracisz dostęp do konta. Najpierw zarejestruj klucz dostępu poniżej.'),
('pl', 'account.confirmDisablePasswordAuth', 'Czy na pewno wyłączyć logowanie e-mailem i hasłem? Potem będziesz mógł/mogła zalogować się tylko kluczem dostępu.'),
('pl', 'account.wrongCurrentPassword', 'Obecne hasło jest nieprawidłowe.'),
('pl', 'account.emailAlreadyUsed', 'Ten adres e-mail jest już używany.'),
('pl', 'account.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'account.changeError', 'Zmiana nie powiodła się: {message}'),
('pl', 'suggestions.formTitle', 'Zaproponuj danie'),
('pl', 'suggestions.formHint', 'Brakuje dania w jadłospisie? Zaproponuj je tutaj – zespół stołówki sprawdzi Twoją propozycję.'),
('pl', 'suggestions.nameLabel', 'Nazwa dania'),
('pl', 'suggestions.descriptionLabel', 'Opis / składniki (opcjonalnie)'),
('pl', 'suggestions.submit', 'Wyślij propozycję'),
('pl', 'suggestions.submitting', 'Wysyłanie…'),
('pl', 'suggestions.success', 'Dziękujemy! Twoja propozycja została przesłana.'),
('pl', 'suggestions.duplicate', 'To danie już istnieje w jadłospisie.'),
('pl', 'suggestions.genericError', 'Wysyłanie nie powiodło się: {message}'),
('pl', 'suggestions.nameRequired', 'Podaj nazwę.'),
('pl', 'suggestions.backToMenu', '← Powrót do jadłospisu'),
('pl', 'suggestions.reviewTitle', 'Propozycje dań'),
('pl', 'suggestions.reviewEmpty', 'Brak propozycji.'),
('pl', 'suggestions.accept', 'Dodaj jako danie'),
('pl', 'suggestions.reject', 'Odrzuć'),
('pl', 'suggestions.confirmReject', 'Czy na pewno odrzucić i usunąć propozycję „{name}”?'),
('pl', 'suggestions.accepted', 'Dodano jako danie.'),
('pl', 'suggestions.rejected', 'Propozycja odrzucona.'),
('pl', 'suggestions.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'suggestions.actionError', 'Czynność nie powiodła się: {message}'),
('pl', 'suggestions.submittedOn', 'przesłano {date}'),
('pl', 'languageManager.title', 'Języki'),
('pl', 'languageManager.hint', 'Niemiecki jest stałym językiem bazowym i zawsze kompletny. Kolejne języki są importowane jako plik tłumaczenia i automatycznie uzupełniane niemieckim tekstem tam, gdzie brakuje tłumaczenia.'),
('pl', 'languageManager.codeColumn', 'Kod'),
('pl', 'languageManager.labelColumn', 'Nazwa wyświetlana'),
('pl', 'languageManager.activeColumn', 'Aktywny'),
('pl', 'languageManager.completenessColumn', 'Kompletność'),
('pl', 'languageManager.baseBadge', 'Język bazowy'),
('pl', 'languageManager.complete', 'Kompletny'),
('pl', 'languageManager.completeness', 'Przetłumaczono {percent}%'),
('pl', 'languageManager.outdated', 'Nowe terminy od ostatniego importu: {count}'),
('pl', 'languageManager.downloadTemplate', 'Pobierz szablon'),
('pl', 'languageManager.uploadTranslation', 'Prześlij tłumaczenie'),
('pl', 'languageManager.newLanguageTitle', 'Dodaj nowy język'),
('pl', 'languageManager.newLanguageCodeLabel', 'Kod języka (np. fr)'),
('pl', 'languageManager.newLanguageLabelLabel', 'Nazwa wyświetlana (np. Français)'),
('pl', 'languageManager.create', 'Utwórz'),
('pl', 'languageManager.delete', 'Usuń'),
('pl', 'languageManager.confirmDelete', 'Czy na pewno usunąć „{label}”? Wszystkie tłumaczenia tego języka zostaną utracone.'),
('pl', 'languageManager.cannotDeleteBase', 'Nie można usunąć języka bazowego.'),
('pl', 'languageManager.cannotDeactivateLast', 'Co najmniej jeden język musi pozostać aktywny.'),
('pl', 'languageManager.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'languageManager.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'languageManager.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'languageManager.importSuccess', 'Zaimportowano terminów: {count}.'),
('pl', 'languageManager.importError', 'Import nie powiódł się: {message}'),
('pl', 'languageManager.importInvalidFile', 'Nie można odczytać pliku (nieprawidłowy JSON).'),
('pl', 'languageManager.importMissingPlaceholder', '„{key}”: brak symbolu zastępczego {placeholder} w tłumaczeniu – wiersz pominięto.'),
('pl', 'languageManager.duplicateCode', 'Kod języka „{code}” już istnieje.'),
('pl', 'languageManager.invalidCode', 'Podaj kod języka złożony z 2–5 małych liter.');
+106
View File
@@ -0,0 +1,106 @@
import "server-only";
import { query, queryOne, withTransaction } from "@/lib/db";
export interface LanguageRow {
code: string;
label: string;
is_base: boolean;
active: boolean;
template_version: string | null;
updated_at: string;
}
export async function listLanguages(): Promise<LanguageRow[]> {
return query<LanguageRow>(
"select * from languages order by is_base desc, code"
);
}
export async function listActiveLanguageCodes(): Promise<string[]> {
const rows = await query<{ code: string }>(
"select code from languages where active = true order by is_base desc, code"
);
return rows.map((r) => r.code);
}
export async function getLanguage(code: string): Promise<LanguageRow | null> {
return queryOne<LanguageRow>("select * from languages where code = $1", [code]);
}
export async function createLanguage(code: string, label: string): Promise<LanguageRow> {
return (await queryOne<LanguageRow>(
`insert into languages (code, label, is_base, active, template_version)
values ($1, $2, false, true, null) returning *`,
[code, label]
))!;
}
export async function updateLanguageLabel(code: string, label: string): Promise<LanguageRow | null> {
return queryOne<LanguageRow>(
"update languages set label = $1, updated_at = now() where code = $2 returning *",
[label, code]
);
}
/** Wirft, wenn dadurch keine aktive Sprache mehr übrig bliebe. */
export async function setLanguageActive(code: string, active: boolean): Promise<LanguageRow> {
return withTransaction(async (client) => {
if (!active) {
const { rows } = await client.query<{ count: string }>(
"select count(*)::int as count from languages where active = true and code != $1",
[code]
);
if (Number(rows[0].count) === 0) {
throw new Error("cannot_deactivate_last");
}
}
const { rows } = await client.query<LanguageRow>(
"update languages set active = $1, updated_at = now() where code = $2 returning *",
[active, code]
);
return rows[0];
});
}
export async function deleteLanguage(code: string): Promise<void> {
const lang = await getLanguage(code);
if (lang?.is_base) throw new Error("cannot_delete_base");
await query("delete from languages where code = $1", [code]);
}
export interface TranslationEntry {
key: string;
value: string;
}
export async function listTranslations(code: string): Promise<TranslationEntry[]> {
return query<TranslationEntry>(
"select key, value from ui_translations where language_code = $1",
[code]
);
}
/** Übernimmt neu importierte Übersetzungen (Insert/Update je Schlüssel)
* und aktualisiert den gespeicherten Vorlagen-Stand. Bewusst kein
* vollständiges Ersetzen: ein erneuter Import mit nur den zuvor
* fehlenden Schlüsseln darf bereits vorhandene Übersetzungen nicht
* löschen. */
export async function importTranslations(
code: string,
entries: TranslationEntry[],
templateVersion: string
): Promise<void> {
await withTransaction(async (client) => {
for (const entry of entries) {
await client.query(
`insert into ui_translations (language_code, key, value) values ($1, $2, $3)
on conflict (language_code, key) do update set value = excluded.value`,
[code, entry.key, entry.value]
);
}
await client.query(
"update languages set template_version = $1, updated_at = now() where code = $2",
[templateVersion, code]
);
});
}
+37 -582
View File
@@ -36,10 +36,12 @@ const de = {
adminGroup: "Verwaltung",
users: "Benutzer",
design: "Design",
languages: "Sprachen",
suggestions: "Vorschläge",
openSuggestions: "{count} offene Rezeptvorschläge",
viewMenu: "Speisekarte ansehen",
myAccount: "Mein Konto",
menuToggle: "Menü öffnen/schließen",
},
login: {
title: "Anmeldung",
@@ -291,590 +293,43 @@ const de = {
actionError: "Aktion fehlgeschlagen: {message}",
submittedOn: "eingereicht am {date}",
},
languageManager: {
title: "Sprachen",
hint:
"Deutsch ist die feste Basis-Sprache und immer vollständig. Weitere Sprachen werden als Übersetzungsdatei importiert und automatisch mit Deutsch aufgefüllt, wenn einzelne Begriffe fehlen.",
codeColumn: "Code",
labelColumn: "Anzeigename",
activeColumn: "Aktiv",
completenessColumn: "Vollständigkeit",
baseBadge: "Basis-Sprache",
complete: "Vollständig",
completeness: "{percent} % übersetzt",
outdated: "{count} neue(r) Begriff(e) seit letztem Import",
downloadTemplate: "Vorlage herunterladen",
uploadTranslation: "Übersetzung hochladen",
newLanguageTitle: "Neue Sprache anlegen",
newLanguageCodeLabel: "Sprachcode (z. B. fr)",
newLanguageLabelLabel: "Anzeigename (z. B. Français)",
create: "Anlegen",
delete: "Löschen",
confirmDelete:
"„{label}“ wirklich löschen? Alle Übersetzungen dieser Sprache gehen verloren.",
cannotDeleteBase: "Die Basis-Sprache kann nicht gelöscht werden.",
cannotDeactivateLast: "Mindestens eine Sprache muss aktiv bleiben.",
loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
importSuccess: "{count} Begriffe importiert.",
importError: "Import fehlgeschlagen: {message}",
importInvalidFile: "Die Datei konnte nicht gelesen werden (ungültiges JSON).",
importMissingPlaceholder:
"„{key}“: Platzhalter {placeholder} fehlt in der Übersetzung – Zeile übersprungen.",
duplicateCode: "Der Sprachcode „{code}“ existiert bereits.",
invalidCode: "Bitte einen Sprachcode aus 2–5 Kleinbuchstaben angeben.",
},
};
type Dict = typeof de;
const en: Dict = {
common: {
save: "Save",
cancel: "Cancel",
delete: "Delete",
edit: "Edit",
add: "Add",
back: "Back",
login: "Log in",
logout: "Log out",
loading: "Loading…",
you: "(you)",
name: "Name",
email: "Email address",
password: "Password",
role: "Role",
roleAdmin: "Administrator",
roleStaff: "Canteen staff",
},
weekdays: {
long: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
short: ["Mon", "Tue", "Wed", "Thu", "Fri"],
},
dateLocale: "en-GB",
header: {
canteen: "Canteen",
menuGroup: "Menu",
weekPlan: "Weekly plan",
sections: "Meal times",
dishes: "Dishes",
labels: "Allergens & Additives",
adminGroup: "Administration",
users: "Users",
design: "Design",
suggestions: "Suggestions",
openSuggestions: "{count} open dish suggestions",
viewMenu: "View menu",
myAccount: "My account",
},
login: {
title: "Sign in",
submit: "Log in",
or: "or",
passkeyButton: "Sign in with passkey",
ssoButton: "Sign in with Single Sign-On",
backToMenu: "← Back to menu",
errorInvalidCredentials: "Email address or password is incorrect.",
errorPasskeyCancelled: "Passkey sign-in was cancelled.",
errorGeneric: "Sign-in failed: {message}",
errorPasswordDisabled:
"Password sign-in is disabled for this account. Please sign in with a passkey.",
errorOidcInvalidState: "The SSO sign-in expired or was invalid. Please try again.",
errorOidcUnknownUser:
"No account exists for this email address. Please contact your administrator.",
errorOidcFailed: "SSO sign-in failed. Please try again.",
},
publicMenu: {
prevWeek: "← Previous week",
nextWeek: "Next week →",
prevWeekShort: "← Week",
nextWeekShort: "Week →",
today: "Today",
dayTab: "Day",
weekTab: "Week",
allergens: "Allergens",
additives: "Additives",
labelingNoticeTitle: "Allergens & additives",
suggestDish: "Suggest a dish",
noEntry: "–",
rssFeed: "Subscribe to RSS feed",
},
weekEditor: {
title: "Weekly plan",
loading: "Loading weekly plan…",
changeDish: "Choose a different dish",
addMeal: "Add meal",
newDishesHint: "Add new dishes under {link}.",
newDishesLinkLabel: "Dishes",
loadError: "Loading failed: {message}",
duplicateEntry: "This dish is already assigned on this day.",
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
},
dishManager: {
title: "Dishes",
newDish: "New dish",
editDish: "Edit dish",
nameLabel: "Name",
nameEnLabel: "Name (English)",
namePlLabel: "Name (Polish)",
autoTranslate: "Auto-translate",
translating: "Translating…",
translated: "Translated.",
priceLabel: "Price in €",
pricePlaceholder: "3.50",
searchPlaceholder: "Search dishes…",
selectAll: "Select all",
selectedCount: "{count} selected",
deleteSelected: "Delete selected",
deleteSelectedCount: "Delete selected ({count})",
empty: "No dishes yet.",
noResults: "No results for “{query}”.",
confirmDelete:
"Really delete “{name}”? The dish will also be removed from all weekly plans.",
confirmDeleteSelected:
"Really delete {count} dish(es)? They will also be removed from all weekly plans.",
invalidPrice: "Please enter a valid price, e.g. 3.50",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
exportButton: "Export",
importButton: "Import",
exportError: "Export failed: {message}",
importError: "Import failed: {message}",
importSuccess: "{created} created, {updated} updated.",
importSkipped: "{count} row(s) skipped: {reasons}",
importSkippedRow: "Row {row}: {reason}",
labelingDisabledHint:
"Allergens/additives are currently disabled (notice mode active, see \"Allergens & additives\").",
},
codeList: {
allergensTitle: "Allergens",
additivesTitle: "Additives",
allergenCodeLabel: "Code (e.g. A)",
additiveCodeLabel: "Code (e.g. 1)",
nameLabel: "Name",
empty: "No entries.",
confirmDelete: "Really delete “{code} – {name}”?",
duplicateCode: "The code “{code}” already exists.",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
exportButton: "Export",
importButton: "Import",
exportError: "Export failed: {message}",
importError: "Import failed: {message}",
importSuccess: "{created} created, {updated} updated.",
importSkipped: "{count} row(s) skipped: {reasons}",
importSkippedRow: "Row {row}: {reason}",
},
sectionManager: {
title: "Meal times",
newSection: "New meal time",
editSection: "Edit meal time",
nameLabel: "Name",
nameEnLabel: "Name (English)",
namePlLabel: "Name (Polish)",
timeFrom: "From",
timeTo: "To",
moveUp: "Move up",
moveDown: "Move down",
empty: "No meal times yet.",
confirmDelete:
"Really delete “{name}”? Assigned dishes will also be removed from all weekly plans.",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
},
userManager: {
title: "Users",
newUser: "New user",
editUser: "Edit user",
newPasswordLabel: "New password (optional)",
repeatPasswordLabel: "Repeat password",
passwordMismatch: "The passwords do not match.",
confirmDelete:
"Really delete user “{email}”? Access will be permanently removed.",
loadError: "Loading failed: {message}",
},
designEditor: {
title: "Design",
logoTitle: "Logo",
logoHint: "The logo is displayed centered above the menu.",
logoNone: "No logo set.",
logoUpload: "Upload logo",
logoRemove: "Remove logo",
logoUpdated: "Logo updated.",
logoRemoved: "Logo removed.",
faviconTitle: "Favicon",
faviconHint:
"Shown as the icon in the browser tab. Without a custom favicon, the application's default icon is used.",
faviconNone: "No custom favicon set.",
faviconUpload: "Upload favicon",
faviconRemove: "Remove favicon",
faviconUpdated: "Favicon updated.",
faviconRemoved: "Favicon removed.",
invalidImage: "Please choose an image file (PNG, JPG, SVG, WebP, ICO).",
colorsTitle: "Colors",
colorsSave: "Save colors",
colorsReset: "Reset to default",
colorsSaved: "Colors saved.",
colorPrimary: "Primary color (table header)",
colorPrimaryDark: "Primary color, dark (internal top bar)",
colorAccent: "Accent color",
colorHeaderText: "Table header text color",
colorRowBg: "Cell background",
colorRowBgAlt: "Cell background (alternating)",
colorAddBtn: "“Add” button",
colorDeleteBtn: "“Delete” button",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
uploadError: "Upload failed: {message}",
confirmRemoveLogo: "Really remove the logo?",
confirmRemoveFavicon: "Really remove the favicon?",
},
labelingNotice: {
title: "Allergen & additive display",
hint:
"By default every guest sees a detailed allergen/additive selection per dish. Alternatively, only a notice text can be shown instead – the per-dish selection is then also hidden in the dish manager.",
modeDetailed: "Show detailed selection per dish",
modeNotice: "Show only notice text",
noticeDeLabel: "Notice text (German)",
noticeEnLabel: "Notice text (English)",
noticePlLabel: "Notice text (Polish)",
save: "Save",
saved: "Saved.",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
},
passkeys: {
title: "Passkeys",
hint: "With a passkey (fingerprint, face recognition, or a security key) you can sign in without a password in the future. Passkeys are bound to a domain – after a domain change they need to be registered again.",
register: "Register new passkey",
empty: "No passkeys registered yet.",
createdOn: "– created on {date}",
registered: "Passkey registered successfully.",
registerError: "Registration failed: {message}",
registerCancelled: "Passkey registration was cancelled.",
loadError: "Could not load passkeys: {message}",
deleteError: "Deleting failed: {message}",
confirmDelete: "Really delete this passkey?",
},
account: {
title: "My account",
personalTitle: "Personal details",
saveName: "Save name",
nameSaved: "Name saved.",
nameRequired: "Please enter a name.",
emailTitle: "Email address",
changeEmail: "Change email address",
emailChanged: "Email address changed.",
passwordTitle: "Change password",
currentPassword: "Current password (for verification)",
newPassword: "New password",
newPasswordRepeat: "Repeat new password",
changePassword: "Change password",
passwordChanged: "Password changed.",
passwordMismatch: "The passwords do not match.",
passwordTooShort: "The new password must be at least 8 characters long.",
passwordAuthTitle: "Password sign-in",
passwordAuthHint:
"You can disable email and password sign-in for your account and sign in exclusively with a passkey in the future. At least one passkey must be registered for this.",
passwordAuthCheckbox: "Email and password sign-in allowed",
passwordAuthEnabled: "Password sign-in enabled.",
passwordAuthDisabled: "Password sign-in disabled.",
passwordAuthNeedsPasskey:
"You can't disable password sign-in without a registered passkey – you would lock yourself out. Register a passkey below first.",
confirmDisablePasswordAuth:
"Really disable email and password sign-in? Afterwards you can only sign in with a passkey.",
wrongCurrentPassword: "The current password is incorrect.",
emailAlreadyUsed: "This email address is already in use.",
saveError: "Saving failed: {message}",
changeError: "Change failed: {message}",
},
suggestions: {
formTitle: "Suggest a dish",
formHint:
"Missing a dish on the menu? Suggest it here – the canteen team will review your suggestion.",
nameLabel: "Dish name",
descriptionLabel: "Description / ingredients (optional)",
submit: "Submit suggestion",
submitting: "Submitting…",
success: "Thanks! Your suggestion has been submitted.",
duplicate: "This dish already exists on the menu.",
genericError: "Submission failed: {message}",
nameRequired: "Please enter a name.",
backToMenu: "← Back to menu",
reviewTitle: "Dish suggestions",
reviewEmpty: "No suggestions yet.",
accept: "Add as dish",
reject: "Reject",
confirmReject: "Really reject and delete the suggestion “{name}”?",
accepted: "Added as dish.",
rejected: "Suggestion rejected.",
loadError: "Loading failed: {message}",
actionError: "Action failed: {message}",
submittedOn: "submitted on {date}",
},
};
const pl: Dict = {
common: {
save: "Zapisz",
cancel: "Anuluj",
delete: "Usuń",
edit: "Edytuj",
add: "Dodaj",
back: "Wstecz",
login: "Zaloguj się",
logout: "Wyloguj się",
loading: "Wczytywanie…",
you: "(ty)",
name: "Nazwa",
email: "Adres e-mail",
password: "Hasło",
role: "Rola",
roleAdmin: "Administrator",
roleStaff: "Personel stołówki",
},
weekdays: {
long: ["Poniedziałek", "Wtorek", "Środa", "Czwartek", "Piątek"],
short: ["Pon", "Wto", "Śro", "Czw", "Pią"],
},
dateLocale: "pl-PL",
header: {
canteen: "Stołówka",
menuGroup: "Jadłospis",
weekPlan: "Plan tygodnia",
sections: "Pory posiłków",
dishes: "Dania",
labels: "Alergeny i dodatki",
adminGroup: "Administracja",
users: "Użytkownicy",
design: "Wygląd",
suggestions: "Propozycje",
openSuggestions: "{count} otwartych propozycji dań",
viewMenu: "Zobacz jadłospis",
myAccount: "Moje konto",
},
login: {
title: "Logowanie",
submit: "Zaloguj się",
or: "lub",
passkeyButton: "Zaloguj się kluczem dostępu",
ssoButton: "Zaloguj się przez Single Sign-On",
backToMenu: "← Powrót do jadłospisu",
errorInvalidCredentials: "Nieprawidłowy adres e-mail lub hasło.",
errorPasskeyCancelled: "Logowanie kluczem dostępu zostało anulowane.",
errorGeneric: "Logowanie nie powiodło się: {message}",
errorPasswordDisabled:
"Logowanie hasłem jest wyłączone dla tego konta. Zaloguj się kluczem dostępu.",
errorOidcInvalidState:
"Logowanie SSO wygasło lub jest nieprawidłowe. Spróbuj ponownie.",
errorOidcUnknownUser:
"Nie istnieje konto dla tego adresu e-mail. Skontaktuj się z administracją.",
errorOidcFailed: "Logowanie SSO nie powiodło się. Spróbuj ponownie.",
},
publicMenu: {
prevWeek: "← Poprzedni tydzień",
nextWeek: "Następny tydzień →",
prevWeekShort: "← Tydzień",
nextWeekShort: "Tydzień →",
today: "Dziś",
dayTab: "Dzień",
weekTab: "Tydzień",
allergens: "Alergeny",
additives: "Dodatki",
labelingNoticeTitle: "Alergeny i dodatki",
suggestDish: "Zaproponuj danie",
noEntry: "–",
rssFeed: "Subskrybuj kanał RSS",
},
weekEditor: {
title: "Plan tygodnia",
loading: "Wczytywanie planu tygodnia…",
changeDish: "Wybierz inne danie",
addMeal: "Dodaj posiłek",
newDishesHint: "Nowe dania możesz dodać w zakładce {link}.",
newDishesLinkLabel: "Dania",
loadError: "Wczytywanie nie powiodło się: {message}",
duplicateEntry: "To danie jest już przypisane na ten dzień.",
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
},
dishManager: {
title: "Dania",
newDish: "Nowe danie",
editDish: "Edytuj danie",
nameLabel: "Nazwa",
nameEnLabel: "Nazwa (angielski)",
namePlLabel: "Nazwa (polski)",
autoTranslate: "Przetłumacz automatycznie",
translating: "Tłumaczenie…",
translated: "Przetłumaczono.",
priceLabel: "Cena w €",
pricePlaceholder: "3,50",
searchPlaceholder: "Szukaj dania…",
selectAll: "Zaznacz wszystko",
selectedCount: "Zaznaczono: {count}",
deleteSelected: "Usuń zaznaczone",
deleteSelectedCount: "Usuń zaznaczone ({count})",
empty: "Nie dodano jeszcze żadnych dań.",
noResults: "Brak wyników dla „{query}”.",
confirmDelete:
"Czy na pewno usunąć „{name}”? Danie zostanie również usunięte ze wszystkich planów tygodniowych.",
confirmDeleteSelected:
"Czy na pewno usunąć {count} danie/dania? Zostaną również usunięte ze wszystkich planów tygodniowych.",
invalidPrice: "Podaj prawidłową cenę, np. 3,50",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
exportButton: "Eksportuj",
importButton: "Importuj",
exportError: "Eksport nie powiódł się: {message}",
importError: "Import nie powiódł się: {message}",
importSuccess: "Utworzono: {created}, zaktualizowano: {updated}.",
importSkipped: "Pominięto wierszy: {count}: {reasons}",
importSkippedRow: "Wiersz {row}: {reason}",
labelingDisabledHint:
"Alergeny/dodatki są obecnie wyłączone (aktywny tryb komunikatu, patrz „Alergeny i dodatki”).",
},
codeList: {
allergensTitle: "Alergeny",
additivesTitle: "Dodatki",
allergenCodeLabel: "Kod (np. A)",
additiveCodeLabel: "Kod (np. 1)",
nameLabel: "Nazwa",
empty: "Brak wpisów.",
confirmDelete: "Czy na pewno usunąć „{code} – {name}”?",
duplicateCode: "Kod „{code}” już istnieje.",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
exportButton: "Eksportuj",
importButton: "Importuj",
exportError: "Eksport nie powiódł się: {message}",
importError: "Import nie powiódł się: {message}",
importSuccess: "Utworzono: {created}, zaktualizowano: {updated}.",
importSkipped: "Pominięto wierszy: {count}: {reasons}",
importSkippedRow: "Wiersz {row}: {reason}",
},
sectionManager: {
title: "Pory posiłków",
newSection: "Nowa pora posiłku",
editSection: "Edytuj porę posiłku",
nameLabel: "Nazwa",
nameEnLabel: "Nazwa (angielski)",
namePlLabel: "Nazwa (polski)",
timeFrom: "Od",
timeTo: "Do",
moveUp: "Przesuń w górę",
moveDown: "Przesuń w dół",
empty: "Nie dodano jeszcze żadnych pór posiłków.",
confirmDelete:
"Czy na pewno usunąć „{name}”? Przypisane dania zostaną również usunięte ze wszystkich planów tygodniowych.",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
},
userManager: {
title: "Użytkownicy",
newUser: "Nowy użytkownik",
editUser: "Edytuj użytkownika",
newPasswordLabel: "Nowe hasło (opcjonalnie)",
repeatPasswordLabel: "Powtórz hasło",
passwordMismatch: "Hasła nie są zgodne.",
confirmDelete:
"Czy na pewno usunąć użytkownika „{email}”? Dostęp zostanie trwale usunięty.",
loadError: "Wczytywanie nie powiodło się: {message}",
},
designEditor: {
title: "Wygląd",
logoTitle: "Logo",
logoHint: "Logo jest wyświetlane wyśrodkowane nad jadłospisem.",
logoNone: "Nie ustawiono logo.",
logoUpload: "Prześlij logo",
logoRemove: "Usuń logo",
logoUpdated: "Logo zaktualizowane.",
logoRemoved: "Logo usunięte.",
faviconTitle: "Favicon",
faviconHint:
"Wyświetlane jako ikona na karcie przeglądarki. Bez własnego favicona używana jest domyślna ikona aplikacji.",
faviconNone: "Nie ustawiono własnego favicona.",
faviconUpload: "Prześlij favicon",
faviconRemove: "Usuń favicon",
faviconUpdated: "Favicon zaktualizowany.",
faviconRemoved: "Favicon usunięty.",
invalidImage: "Wybierz plik graficzny (PNG, JPG, SVG, WebP, ICO).",
colorsTitle: "Kolorystyka",
colorsSave: "Zapisz kolory",
colorsReset: "Przywróć domyślne",
colorsSaved: "Kolory zapisane.",
colorPrimary: "Kolor główny (nagłówek tabeli)",
colorPrimaryDark: "Kolor główny, ciemny (górny pasek wewnętrzny)",
colorAccent: "Kolor akcentu",
colorHeaderText: "Kolor tekstu w nagłówku tabeli",
colorRowBg: "Tło komórek",
colorRowBgAlt: "Tło komórek (naprzemienne)",
colorAddBtn: "Przycisk „Dodaj”",
colorDeleteBtn: "Przycisk „Usuń”",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
uploadError: "Przesyłanie nie powiodło się: {message}",
confirmRemoveLogo: "Czy na pewno usunąć logo?",
confirmRemoveFavicon: "Czy na pewno usunąć favicon?",
},
labelingNotice: {
title: "Wyświetlanie alergenów i dodatków",
hint:
"Domyślnie każdy gość widzi szczegółowy wybór alergenów/dodatków dla każdego dania. Alternatywnie można zamiast tego wyświetlić tylko komunikat – wybór dla poszczególnych dań zostanie wtedy również ukryty w zarządzaniu daniami.",
modeDetailed: "Pokaż szczegółowy wybór dla każdego dania",
modeNotice: "Pokaż tylko komunikat",
noticeDeLabel: "Tekst komunikatu (niemiecki)",
noticeEnLabel: "Tekst komunikatu (angielski)",
noticePlLabel: "Tekst komunikatu (polski)",
save: "Zapisz",
saved: "Zapisano.",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
},
passkeys: {
title: "Klucze dostępu",
hint: "Dzięki kluczowi dostępu (odcisk palca, rozpoznawanie twarzy lub klucz bezpieczeństwa) możesz w przyszłości logować się bez hasła. Klucze dostępu są przypisane do domeny – po zmianie domeny trzeba je zarejestrować ponownie.",
register: "Zarejestruj nowy klucz dostępu",
empty: "Nie zarejestrowano jeszcze żadnych kluczy dostępu.",
createdOn: "– utworzono {date}",
registered: "Klucz dostępu został pomyślnie zarejestrowany.",
registerError: "Rejestracja nie powiodła się: {message}",
registerCancelled: "Rejestracja klucza dostępu została anulowana.",
loadError: "Nie udało się wczytać kluczy dostępu: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
confirmDelete: "Czy na pewno usunąć ten klucz dostępu?",
},
account: {
title: "Moje konto",
personalTitle: "Dane osobowe",
saveName: "Zapisz nazwę",
nameSaved: "Nazwa zapisana.",
nameRequired: "Podaj nazwę.",
emailTitle: "Adres e-mail",
changeEmail: "Zmień adres e-mail",
emailChanged: "Adres e-mail zmieniony.",
passwordTitle: "Zmień hasło",
currentPassword: "Obecne hasło (do weryfikacji)",
newPassword: "Nowe hasło",
newPasswordRepeat: "Powtórz nowe hasło",
changePassword: "Zmień hasło",
passwordChanged: "Hasło zmienione.",
passwordMismatch: "Hasła nie są zgodne.",
passwordTooShort: "Nowe hasło musi mieć co najmniej 8 znaków.",
passwordAuthTitle: "Logowanie hasłem",
passwordAuthHint:
"Możesz wyłączyć logowanie e-mailem i hasłem dla swojego konta i logować się w przyszłości wyłącznie kluczem dostępu. Wymaga to zarejestrowania co najmniej jednego klucza dostępu.",
passwordAuthCheckbox: "Logowanie e-mailem i hasłem dozwolone",
passwordAuthEnabled: "Logowanie hasłem włączone.",
passwordAuthDisabled: "Logowanie hasłem wyłączone.",
passwordAuthNeedsPasskey:
"Bez zarejestrowanego klucza dostępu nie możesz wyłączyć logowania hasłem – stracisz dostęp do konta. Najpierw zarejestruj klucz dostępu poniżej.",
confirmDisablePasswordAuth:
"Czy na pewno wyłączyć logowanie e-mailem i hasłem? Potem będziesz mógł/mogła zalogować się tylko kluczem dostępu.",
wrongCurrentPassword: "Obecne hasło jest nieprawidłowe.",
emailAlreadyUsed: "Ten adres e-mail jest już używany.",
saveError: "Zapisywanie nie powiodło się: {message}",
changeError: "Zmiana nie powiodła się: {message}",
},
suggestions: {
formTitle: "Zaproponuj danie",
formHint:
"Brakuje dania w jadłospisie? Zaproponuj je tutaj – zespół stołówki sprawdzi Twoją propozycję.",
nameLabel: "Nazwa dania",
descriptionLabel: "Opis / składniki (opcjonalnie)",
submit: "Wyślij propozycję",
submitting: "Wysyłanie…",
success: "Dziękujemy! Twoja propozycja została przesłana.",
duplicate: "To danie już istnieje w jadłospisie.",
genericError: "Wysyłanie nie powiodło się: {message}",
nameRequired: "Podaj nazwę.",
backToMenu: "← Powrót do jadłospisu",
reviewTitle: "Propozycje dań",
reviewEmpty: "Brak propozycji.",
accept: "Dodaj jako danie",
reject: "Odrzuć",
confirmReject: "Czy na pewno odrzucić i usunąć propozycję „{name}”?",
accepted: "Dodano jako danie.",
rejected: "Propozycja odrzucona.",
loadError: "Wczytywanie nie powiodło się: {message}",
actionError: "Czynność nie powiodła się: {message}",
submittedOn: "przesłano {date}",
},
};
export type { Dict };
export const dictionaries = { de, en, pl };
export const dictionaries = { de };
+46
View File
@@ -0,0 +1,46 @@
// Reine, abhängigkeitsfreie Umwandlung zwischen dem verschachtelten
// Dict-Objekt (lib/i18n/dictionaries.ts) und flachen Schlüssel-Wert-
// Paaren ("login.errorGeneric") – Grundlage für Vorlage-Export/-Import
// von Sprachpaketen. Arrays (z. B. weekdays.long) werden über den
// numerischen Index als eigene Schlüssel geflattet ("weekdays.long.0")
// und beim Zurückbauen anhand rein numerischer Kind-Schlüssel wieder
// als Array erkannt.
export function flattenDict(value: unknown, prefix = ""): Record<string, string> {
const out: Record<string, string> = {};
function walk(node: unknown, path: string) {
if (Array.isArray(node)) {
node.forEach((item, i) => walk(item, `${path}.${i}`));
} else if (node !== null && typeof node === "object") {
for (const [key, child] of Object.entries(node)) {
walk(child, path ? `${path}.${key}` : key);
}
} else {
out[path] = String(node);
}
}
walk(value, prefix);
return out;
}
export function unflattenDict(flat: Record<string, string>): unknown {
const root: Record<string, unknown> = {};
for (const [path, value] of Object.entries(flat)) {
const parts = path.split(".");
let node: Record<string, unknown> = root;
for (let i = 0; i < parts.length - 1; i++) {
const key = parts[i];
if (node[key] === undefined) {
const nextIsIndex = /^\d+$/.test(parts[i + 1]);
node[key] = nextIsIndex ? [] : {};
}
node = node[key] as Record<string, unknown>;
}
node[parts[parts.length - 1]] = value;
}
return root;
}
+26 -5
View File
@@ -1,16 +1,37 @@
// Nur für Server-Komponenten/Routen importieren (next/headers)!
// Nur für Server-Komponenten/Routen importieren (next/headers, DB)!
// Client-Komponenten müssen stattdessen "@/lib/i18n/shared" verwenden,
// sonst landet next/headers im Client-Bundle.
// sonst landet next/headers/die DB-Anbindung im Client-Bundle.
import "server-only";
import { cookies } from "next/headers";
import { listActiveLanguageCodes, listTranslations } from "@/lib/db/languages";
import { dictionaries, type Dict } from "./dictionaries";
import { flattenDict, unflattenDict } from "./flatten";
import { DEFAULT_LOCALE, isLocale, type Locale } from "./locales";
import { LOCALE_COOKIE } from "./shared";
export * from "./shared";
/** Aktuelle Sprache aus dem Cookie lesen (Server-Komponenten). */
/** Aktuelle Sprache aus dem Cookie lesen, validiert gegen die aktiven
* Sprachen (Server-Komponenten). Ungültig oder nicht mehr aktiv →
* Basis-Sprache. */
export async function getLocale(): Promise<Locale> {
const store = await cookies();
const value = store.get("locale")?.value;
return isLocale(value) ? value : DEFAULT_LOCALE;
const value = store.get(LOCALE_COOKIE)?.value;
const active = await listActiveLanguageCodes();
return isLocale(value, active) ? value : DEFAULT_LOCALE;
}
/** Wörterbuch für eine Sprache. Nicht-deutsche Sprachen werden aus der
* Datenbank geladen und mit der deutschen Vorlage aufgefüllt – fehlt
* eine Übersetzung (z. B. nach einem Update mit neuen Texten), zeigt
* die App automatisch den deutschen Text statt leer zu bleiben. */
export async function getDictionary(locale: Locale): Promise<Dict> {
if (locale === DEFAULT_LOCALE) return dictionaries.de;
const rows = await listTranslations(locale);
const flat = flattenDict(dictionaries.de);
for (const row of rows) {
if (row.key in flat) flat[row.key] = row.value;
}
return unflattenDict(flat) as Dict;
}
+11 -11
View File
@@ -1,13 +1,13 @@
export const LOCALES = ["de", "en", "pl"] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "de";
// Sprachen sind seit v1.10.0 zur Laufzeit verwaltbar (Tabelle
// "languages", siehe lib/db/languages.ts) statt fest im Code zu
// stehen. "de" bleibt als einzige garantiert vollständige Basis-
// Sprache ein Sonderfall (siehe lib/i18n/dictionaries.ts).
export const DEFAULT_LOCALE = "de";
export type Locale = string;
export const LOCALE_LABELS: Record<Locale, string> = {
de: "Deutsch",
en: "English",
pl: "Polski",
};
export function isLocale(value: string | undefined | null): value is Locale {
return !!value && (LOCALES as readonly string[]).includes(value);
export function isLocale(
value: string | undefined | null,
activeCodes: readonly string[]
): value is Locale {
return !!value && activeCodes.includes(value);
}
+5 -10
View File
@@ -1,19 +1,14 @@
// Client-sicherer Teil von lib/i18n: keine Abhängigkeit auf next/headers,
// darf daher auch von "use client"-Komponenten importiert werden.
// Client-sicherer Teil von lib/i18n: keine Abhängigkeit auf next/headers
// oder die Datenbank, darf daher auch von "use client"-Komponenten
// importiert werden. getDictionary()/getLocale() (DB-gestützt) leben
// in lib/i18n/index.ts und dürfen nur serverseitig importiert werden.
import { dictionaries, type Dict } from "./dictionaries";
import type { Locale } from "./locales";
export { LOCALES, LOCALE_LABELS, DEFAULT_LOCALE, isLocale } from "./locales";
export { DEFAULT_LOCALE, isLocale } from "./locales";
export type { Locale } from "./locales";
export type { Dict } from "./dictionaries";
export const LOCALE_COOKIE = "locale";
export function getDictionary(locale: Locale): Dict {
return dictionaries[locale];
}
/** Platzhalter der Form "{name}" durch vars.name ersetzen. */
export function formatMessage(
template: string,
+83
View File
@@ -0,0 +1,83 @@
import "server-only";
import { createHash } from "node:crypto";
import { dictionaries } from "./dictionaries";
import { flattenDict } from "./flatten";
/** Aktuelle deutsche Vorlage als flache Schlüssel-Wert-Paare – Basis
* für Export (Vorlage für Übersetzer), Vollständigkeits- und
* Veraltet-Prüfung. */
export function currentTemplate(): Record<string, string> {
return flattenDict(dictionaries.de);
}
/** Hash über die (sortierte) Schlüsselliste der aktuellen Vorlage –
* ändert sich nur, wenn Schlüssel hinzukommen/wegfallen, nicht bei
* reinen Text-/Tippfehler-Korrekturen am deutschen Text. */
export function currentTemplateHash(): string {
const keys = Object.keys(currentTemplate()).sort();
return createHash("sha256").update(keys.join("\n")).digest("hex");
}
export interface LanguageCompleteness {
total: number;
translated: number;
percent: number;
missingKeys: string[];
}
export function computeCompleteness(
translated: Record<string, string>
): LanguageCompleteness {
const templateKeys = Object.keys(currentTemplate());
const missingKeys = templateKeys.filter((k) => !(k in translated));
const translatedCount = templateKeys.length - missingKeys.length;
return {
total: templateKeys.length,
translated: translatedCount,
percent:
templateKeys.length === 0
? 100
: Math.round((translatedCount / templateKeys.length) * 100),
missingKeys,
};
}
function extractPlaceholders(text: string): Set<string> {
return new Set([...text.matchAll(/\{(\w+)\}/g)].map((m) => m[1]));
}
export interface ImportValidationResult {
entries: { key: string; value: string }[];
skipped: { key: string; reason: string }[];
}
/** Prüft eine hochgeladene Übersetzung gegen die aktuelle Vorlage:
* unbekannte Schlüssel werden stillschweigend ignoriert (z. B. von
* einer älteren Vorlagen-Version), Zeilen mit fehlenden Platzhaltern
* wie {message} übersprungen statt kaputte Texte zu übernehmen. */
export function validateImport(
uploaded: Record<string, unknown>
): ImportValidationResult {
const template = currentTemplate();
const entries: { key: string; value: string }[] = [];
const skipped: { key: string; reason: string }[] = [];
for (const [key, rawValue] of Object.entries(uploaded)) {
const templateValue = template[key];
if (templateValue === undefined) continue;
if (typeof rawValue !== "string" || !rawValue.trim()) {
skipped.push({ key, reason: "empty" });
continue;
}
const expected = extractPlaceholders(templateValue);
const actual = extractPlaceholders(rawValue);
const missing = [...expected].filter((p) => !actual.has(p));
if (missing.length > 0) {
skipped.push({ key, reason: `missing_placeholder:${missing[0]}` });
continue;
}
entries.push({ key, value: rawValue });
}
return { entries, skipped };
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "canteen-menu",
"version": "1.8.1",
"version": "1.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "canteen-menu",
"version": "1.8.1",
"version": "1.10.0",
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canteen-menu",
"version": "1.9.0",
"version": "1.10.0",
"private": true,
"scripts": {
"predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs",