i18n-Fundament: Cookie-basierte Sprachumschaltung (DE/EN/PL)

Wörterbücher unter lib/i18n statt next-intl (Next.js 16 ist neu genug,
dass eine externe Routing-Middleware zusätzliches Risiko bedeutet
hätte); Sprache liegt in einem Cookie, kein URL-Präfix nötig, dadurch
bleibt proxy.ts unverändert. LocaleProvider stellt Wörterbuch + Sprache
per Context für Client-Komponenten bereit (lib/i18n/shared.ts ist
bewusst getrennt von lib/i18n/index.ts, damit next/headers nicht in
den Client-Bundle gelangt).
This commit is contained in:
2026-07-18 02:08:01 +02:00
parent d96faad4d5
commit 33909c660b
8 changed files with 912 additions and 7 deletions
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { isLocale, LOCALE_COOKIE } from "@/lib/i18n";
import { isSameOrigin } from "@/lib/origin";
// Setzt das Sprach-Cookie. Kein Login nötig (auch anonyme Besucher
// der öffentlichen Speisekarte sollen die Sprache wechseln können).
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
const body = await request.json().catch(() => null);
const locale = body?.locale;
if (!isLocale(locale)) {
return NextResponse.json({ error: "Ungültige Sprache." }, { status: 400 });
}
const response = NextResponse.json({ ok: true });
response.cookies.set(LOCALE_COOKIE, locale, {
path: "/",
maxAge: 60 * 60 * 24 * 365,
sameSite: "lax",
});
return response;
}
+10 -6
View File
@@ -3,6 +3,8 @@ import { Geist } from "next/font/google";
import "./globals.css";
import { createClient } from "@/lib/supabase/server";
import { COLOR_VARS, DEFAULT_COLORS, faviconUrl, SITE_TITLE } from "@/lib/types";
import { getDictionary, getLocale } from "@/lib/i18n";
import { LocaleProvider } from "@/components/i18n/LocaleProvider";
import packageJson from "@/package.json";
// Farben/Logo/Favicon sollen Änderungen sofort widerspiegeln.
@@ -37,11 +39,11 @@ export default async function RootLayout({
children: React.ReactNode;
}>) {
const supabase = await createClient();
const { data: settings } = await supabase
.from("settings")
.select("colors")
.eq("id", 1)
.maybeSingle();
const [{ data: settings }, locale] = await Promise.all([
supabase.from("settings").select("colors").eq("id", 1).maybeSingle(),
getLocale(),
]);
const dict = getDictionary(locale);
const colors: Record<string, string> = {
...DEFAULT_COLORS,
@@ -52,9 +54,11 @@ export default async function RootLayout({
) as React.CSSProperties;
return (
<html lang="de" className={`${geistSans.variable} h-full antialiased`}>
<html lang={locale} className={`${geistSans.variable} h-full antialiased`}>
<body style={style} className="min-h-full flex flex-col">
<LocaleProvider locale={locale} dict={dict}>
{children}
</LocaleProvider>
<footer className="mt-auto px-4 py-2 text-right text-xs text-gray-400 select-none">
Version {packageJson.version}
</footer>
+58
View File
@@ -0,0 +1,58 @@
"use client";
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";
export default function LanguageSwitcher({
className = "",
variant = "dark",
}: {
className?: string;
variant?: "dark" | "light";
}) {
const locale = useLocale();
const router = useRouter();
const [busy, setBusy] = useState(false);
async function change(next: Locale) {
if (next === locale || busy) return;
setBusy(true);
await fetch("/api/locale", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locale: next }),
});
router.refresh();
setBusy(false);
}
const activeCls =
variant === "dark" ? "bg-white/25 text-white" : "bg-gray-200 text-gray-900";
const inactiveCls =
variant === "dark"
? "text-white/60 hover:text-white hover:bg-white/10"
: "text-gray-400 hover:text-gray-700 hover:bg-gray-100";
return (
<div className={`flex gap-1 text-xs ${className}`} aria-label="Sprache">
{LOCALES.map((l) => (
<button
key={l}
type="button"
onClick={() => change(l)}
disabled={busy}
aria-current={l === locale}
title={LOCALE_LABELS[l]}
className={`rounded px-1.5 py-0.5 uppercase font-semibold transition-colors ${
l === locale ? activeCls : inactiveCls
}`}
>
{l}
</button>
))}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
"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().
import { createContext, useContext, useMemo } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import type { Dict, Locale } from "@/lib/i18n/shared";
interface LocaleContextValue {
locale: Locale;
dict: Dict;
}
const LocaleContext = createContext<LocaleContextValue | null>(null);
export function LocaleProvider({
locale,
dict,
children,
}: LocaleContextValue & { children: React.ReactNode }) {
const value = useMemo(() => ({ locale, dict }), [locale, dict]);
return (
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
);
}
function useLocaleContext(): LocaleContextValue {
const ctx = useContext(LocaleContext);
if (!ctx) throw new Error("useDict/useLocale muss innerhalb von LocaleProvider verwendet werden.");
return ctx;
}
export function useDict(): Dict {
return useLocaleContext().dict;
}
export function useLocale(): Locale {
return useLocaleContext().locale;
}
/** t("Text mit {var}", { var: "Wert" }) für Platzhalter in Übersetzungen. */
export function useFormatMessage() {
return formatMessage;
}
+719
View File
@@ -0,0 +1,719 @@
// Übersetzungswörterbücher für die komplette Oberfläche.
// "de" ist die Referenzstruktur; en/pl werden dagegen typgeprüft,
// damit kein Schlüssel in einer Sprache fehlt.
const de = {
common: {
save: "Speichern",
cancel: "Abbrechen",
delete: "Löschen",
edit: "Bearbeiten",
add: "Hinzufügen",
back: "Zurück",
login: "Anmelden",
logout: "Abmelden",
loading: "Lade…",
you: "(du)",
name: "Name",
email: "E-Mail-Adresse",
password: "Passwort",
role: "Rolle",
roleAdmin: "Administrator",
roleStaff: "Kantinenmitarbeiter",
},
weekdays: {
long: ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"],
short: ["Mo", "Di", "Mi", "Do", "Fr"],
},
dateLocale: "de-DE",
header: {
canteen: "Kantine",
menuGroup: "Speiseplan",
weekPlan: "Wochenplan",
dishes: "Gerichte",
labels: "Allergene & Zusatzstoffe",
adminGroup: "Verwaltung",
users: "Benutzer",
design: "Design",
suggestions: "Vorschläge",
viewMenu: "Speisekarte ansehen",
myAccount: "Mein Konto",
},
login: {
title: "Anmeldung",
submit: "Anmelden",
or: "oder",
passkeyButton: "Mit Passkey anmelden",
backToMenu: "← Zur Speisekarte",
errorInvalidCredentials: "E-Mail-Adresse oder Passwort ist falsch.",
errorEmailNotConfirmed: "Die E-Mail-Adresse wurde noch nicht bestätigt.",
errorPasskeyCancelled: "Passkey-Anmeldung wurde abgebrochen.",
errorGeneric: "Anmeldung fehlgeschlagen: {message}",
errorPasswordDisabled:
"Die Anmeldung mit Passwort ist für dieses Konto deaktiviert. Bitte mit Passkey anmelden.",
},
publicMenu: {
prevWeek: "← Vorherige Woche",
nextWeek: "Nächste Woche →",
prevWeekShort: "← Woche",
nextWeekShort: "Woche →",
today: "Heute",
dayTab: "Tag",
weekTab: "Woche",
allergens: "Allergene",
additives: "Zusatzstoffe",
suggestDish: "Rezept vorschlagen",
noEntry: "–",
},
weekEditor: {
title: "Wochenplan",
loading: "Lade Wochenplan…",
selectDish: "– Gericht wählen –",
addMeal: "Mahlzeit hinzufügen",
newDishesHint: "Neue Gerichte legst du unter {link} an.",
newDishesLinkLabel: "Gerichte",
loadError: "Laden fehlgeschlagen: {message}",
duplicateEntry: "Dieses Gericht ist an diesem Tag bereits zugewiesen.",
saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
},
dishManager: {
title: "Gerichte",
newDish: "Neues Gericht",
editDish: "Gericht bearbeiten",
nameLabel: "Bezeichnung",
nameEnLabel: "Name (Englisch)",
namePlLabel: "Name (Polnisch)",
autoTranslate: "Automatisch übersetzen",
translating: "Übersetze…",
translated: "Übersetzt.",
translateUnavailable:
"Automatische Übersetzung ist nicht eingerichtet. Bitte Englisch/Polnisch manuell ausfüllen.",
priceLabel: "Preis in €",
pricePlaceholder: "3,50",
searchPlaceholder: "Gericht suchen…",
selectAll: "Alle auswählen",
selectedCount: "{count} ausgewählt",
deleteSelected: "Ausgewählte löschen",
deleteSelectedCount: "Ausgewählte löschen ({count})",
empty: "Noch keine Gerichte angelegt.",
noResults: "Keine Treffer für „{query}“.",
confirmDelete:
"„{name}“ wirklich löschen? Das Gericht wird auch aus allen Wochenplänen entfernt.",
confirmDeleteSelected:
"{count} Gericht(e) wirklich löschen? Sie werden auch aus allen Wochenplänen entfernt.",
invalidPrice: "Bitte einen gültigen Preis angeben, z. B. 3,50",
loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
},
codeList: {
allergensTitle: "Allergene",
additivesTitle: "Zusatzstoffe",
allergenCodeLabel: "Kürzel (z. B. A)",
additiveCodeLabel: "Kürzel (z. B. 1)",
nameLabel: "Bezeichnung",
empty: "Keine Einträge.",
confirmDelete: "„{code} – {name}“ wirklich löschen?",
duplicateCode: "Das Kürzel „{code}“ existiert bereits.",
loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
},
userManager: {
title: "Benutzer",
newUser: "Neuer Benutzer",
editUser: "Benutzer bearbeiten",
newPasswordLabel: "Neues Passwort (optional)",
repeatPasswordLabel: "Passwort wiederholen",
passwordMismatch: "Die Passwörter stimmen nicht überein.",
confirmDelete:
"Benutzer „{email}“ wirklich löschen? Der Zugang wird dauerhaft entfernt.",
loadError: "Laden fehlgeschlagen: {message}",
},
designEditor: {
title: "Design",
logoTitle: "Logo",
logoHint: "Das Logo wird zentriert über der Speisekarte angezeigt.",
logoNone: "Kein Logo hinterlegt.",
logoUpload: "Logo hochladen",
logoRemove: "Logo entfernen",
logoUpdated: "Logo aktualisiert.",
logoRemoved: "Logo entfernt.",
faviconTitle: "Favicon",
faviconHint:
"Wird als Icon im Browser-Tab angezeigt. Ohne eigenes Favicon gilt das Standard-Icon der Anwendung.",
faviconNone: "Kein eigenes Favicon hinterlegt.",
faviconUpload: "Favicon hochladen",
faviconRemove: "Favicon entfernen",
faviconUpdated: "Favicon aktualisiert.",
faviconRemoved: "Favicon entfernt.",
invalidImage: "Bitte eine Bilddatei auswählen (PNG, JPG, SVG, WebP, ICO).",
colorsTitle: "Farbgebung",
colorsSave: "Farben speichern",
colorsReset: "Auf Standard zurücksetzen",
colorsSaved: "Farben gespeichert.",
colorPrimary: "Primärfarbe (Tabellenkopf)",
colorPrimaryDark: "Primärfarbe dunkel (Kopfleiste intern)",
colorAccent: "Akzentfarbe",
colorHeaderText: "Schriftfarbe im Tabellenkopf",
colorRowBg: "Zellen-Hintergrund",
colorRowBgAlt: "Zellen-Hintergrund (alternierend)",
colorAddBtn: "Button „Hinzufügen“",
colorDeleteBtn: "Button „Löschen“",
loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}",
uploadError: "Upload fehlgeschlagen: {message}",
confirmRemoveLogo: "Logo wirklich entfernen?",
confirmRemoveFavicon: "Favicon wirklich entfernen?",
},
passkeys: {
title: "Passkeys",
hint: "Mit einem Passkey (Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel) kannst du dich künftig ohne Passwort anmelden. Passkeys gelten pro Domain – für den Live-Betrieb muss die Domain in der Supabase-Konfiguration hinterlegt sein.",
register: "Neuen Passkey registrieren",
empty: "Noch keine Passkeys registriert.",
createdOn: "– angelegt am {date}",
registered: "Passkey wurde erfolgreich registriert.",
registerError: "Registrierung fehlgeschlagen: {message}",
registerCancelled: "Die Passkey-Registrierung wurde abgebrochen.",
loadError: "Passkeys konnten nicht geladen werden: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
confirmDelete: "Diesen Passkey wirklich löschen?",
},
account: {
title: "Mein Konto",
personalTitle: "Persönliche Daten",
saveName: "Name speichern",
nameSaved: "Name gespeichert.",
nameRequired: "Bitte einen Namen angeben.",
emailTitle: "E-Mail-Adresse",
changeEmail: "E-Mail-Adresse ändern",
emailSent:
"Bestätigungs-E-Mails wurden verschickt. Die neue Adresse wird erst nach Bestätigung des Links aktiv.",
passwordTitle: "Passwort ändern",
currentPassword: "Aktuelles Passwort (zur Verifizierung)",
newPassword: "Neues Passwort",
newPasswordRepeat: "Neues Passwort wiederholen",
changePassword: "Passwort ändern",
passwordChanged: "Passwort geändert.",
passwordMismatch: "Die Passwörter stimmen nicht überein.",
passwordTooShort: "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
passwordAuthTitle: "Anmeldung mit Passwort",
passwordAuthHint:
"Du kannst die Anmeldung mit E-Mail und Passwort für dein Konto deaktivieren und dich künftig ausschließlich per Passkey anmelden. Dafür muss mindestens ein Passkey registriert sein.",
passwordAuthCheckbox: "Anmeldung mit E-Mail und Passwort erlaubt",
passwordAuthEnabled: "Passwort-Anmeldung aktiviert.",
passwordAuthDisabled: "Passwort-Anmeldung deaktiviert.",
passwordAuthNeedsPasskey:
"Ohne registrierten Passkey kannst du die Passwort-Anmeldung nicht deaktivieren – sonst wärst du ausgesperrt. Registriere zuerst einen Passkey weiter unten.",
confirmDisablePasswordAuth:
"Anmeldung mit E-Mail und Passwort wirklich deaktivieren? Du kannst dich danach nur noch per Passkey anmelden.",
wrongCurrentPassword: "Das aktuelle Passwort ist falsch.",
emailAlreadyUsed: "Diese E-Mail-Adresse wird bereits verwendet.",
saveError: "Speichern fehlgeschlagen: {message}",
changeError: "Ändern fehlgeschlagen: {message}",
},
suggestions: {
formTitle: "Rezept vorschlagen",
formHint:
"Fehlt dir ein Gericht auf der Speisekarte? Schlage es hier vor – das Kantinen-Team sichtet deinen Vorschlag.",
nameLabel: "Gerichtname",
descriptionLabel: "Beschreibung / Zutaten (optional)",
submit: "Vorschlag senden",
submitting: "Wird gesendet…",
success: "Danke! Dein Vorschlag wurde übermittelt.",
duplicate: "Dieses Gericht gibt es bereits auf der Speisekarte.",
genericError: "Senden fehlgeschlagen: {message}",
nameRequired: "Bitte einen Namen angeben.",
backToMenu: "← Zur Speisekarte",
reviewTitle: "Rezeptvorschläge",
reviewEmpty: "Noch keine Vorschläge.",
accept: "Als Gericht übernehmen",
reject: "Ablehnen",
confirmReject: "Vorschlag „{name}“ wirklich ablehnen und löschen?",
accepted: "Als Gericht übernommen.",
rejected: "Vorschlag abgelehnt.",
loadError: "Laden fehlgeschlagen: {message}",
actionError: "Aktion fehlgeschlagen: {message}",
submittedOn: "eingereicht am {date}",
},
};
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",
dishes: "Dishes",
labels: "Allergens & Additives",
adminGroup: "Administration",
users: "Users",
design: "Design",
suggestions: "Suggestions",
viewMenu: "View menu",
myAccount: "My account",
},
login: {
title: "Sign in",
submit: "Log in",
or: "or",
passkeyButton: "Sign in with passkey",
backToMenu: "← Back to menu",
errorInvalidCredentials: "Email address or password is incorrect.",
errorEmailNotConfirmed: "This email address has not been confirmed yet.",
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.",
},
publicMenu: {
prevWeek: "← Previous week",
nextWeek: "Next week →",
prevWeekShort: "← Week",
nextWeekShort: "Week →",
today: "Today",
dayTab: "Day",
weekTab: "Week",
allergens: "Allergens",
additives: "Additives",
suggestDish: "Suggest a dish",
noEntry: "–",
},
weekEditor: {
title: "Weekly plan",
loading: "Loading weekly plan…",
selectDish: "– Select 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.",
translateUnavailable:
"Automatic translation is not set up. Please fill in English/Polish manually.",
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}",
},
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}",
},
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?",
},
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 – for production use, the domain must be configured in Supabase.",
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",
emailSent:
"Confirmation emails have been sent. The new address becomes active only after confirming the link.",
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",
dishes: "Dania",
labels: "Alergeny i dodatki",
adminGroup: "Administracja",
users: "Użytkownicy",
design: "Wygląd",
suggestions: "Propozycje",
viewMenu: "Zobacz jadłospis",
myAccount: "Moje konto",
},
login: {
title: "Logowanie",
submit: "Zaloguj się",
or: "lub",
passkeyButton: "Zaloguj się kluczem dostępu",
backToMenu: "← Powrót do jadłospisu",
errorInvalidCredentials: "Nieprawidłowy adres e-mail lub hasło.",
errorEmailNotConfirmed: "Adres e-mail nie został jeszcze potwierdzony.",
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.",
},
publicMenu: {
prevWeek: "← Poprzedni tydzień",
nextWeek: "Następny tydzień →",
prevWeekShort: "← Tydzień",
nextWeekShort: "Tydzień →",
today: "Dziś",
dayTab: "Dzień",
weekTab: "Tydzień",
allergens: "Alergeny",
additives: "Dodatki",
suggestDish: "Zaproponuj danie",
noEntry: "–",
},
weekEditor: {
title: "Plan tygodnia",
loading: "Wczytywanie planu tygodnia…",
selectDish: "– Wybierz 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.",
translateUnavailable:
"Automatyczne tłumaczenie nie jest skonfigurowane. Wypełnij angielski/polski ręcznie.",
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}",
},
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}",
},
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?",
},
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 – w środowisku produkcyjnym domena musi być skonfigurowana w Supabase.",
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",
emailSent:
"Wysłano e-maile potwierdzające. Nowy adres zacznie obowiązywać dopiero po potwierdzeniu linku.",
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 };
+16
View File
@@ -0,0 +1,16 @@
// Nur für Server-Komponenten/Routen importieren (next/headers)!
// Client-Komponenten müssen stattdessen "@/lib/i18n/shared" verwenden,
// sonst landet next/headers im Client-Bundle.
import "server-only";
import { cookies } from "next/headers";
import { DEFAULT_LOCALE, isLocale, type Locale } from "./locales";
export * from "./shared";
/** Aktuelle Sprache aus dem Cookie lesen (Server-Komponenten). */
export async function getLocale(): Promise<Locale> {
const store = await cookies();
const value = store.get("locale")?.value;
return isLocale(value) ? value : DEFAULT_LOCALE;
}
+13
View File
@@ -0,0 +1,13 @@
export const LOCALES = ["de", "en", "pl"] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "de";
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);
}
+26
View File
@@ -0,0 +1,26 @@
// Client-sicherer Teil von lib/i18n: keine Abhängigkeit auf next/headers,
// darf daher auch von "use client"-Komponenten importiert werden.
import { dictionaries, type Dict } from "./dictionaries";
import type { Locale } from "./locales";
export { LOCALES, LOCALE_LABELS, 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,
vars?: Record<string, string | number>
): string {
if (!vars) return template;
return template.replace(/\{(\w+)\}/g, (match, key) =>
key in vars ? String(vars[key]) : match
);
}