diff --git a/app/api/locale/route.ts b/app/api/locale/route.ts new file mode 100644 index 0000000..3d87df3 --- /dev/null +++ b/app/api/locale/route.ts @@ -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; +} diff --git a/app/layout.tsx b/app/layout.tsx index 968a65d..868f023 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 = { ...DEFAULT_COLORS, @@ -52,9 +54,11 @@ export default async function RootLayout({ ) as React.CSSProperties; return ( - + - {children} + + {children} + diff --git a/components/i18n/LanguageSwitcher.tsx b/components/i18n/LanguageSwitcher.tsx new file mode 100644 index 0000000..54037ff --- /dev/null +++ b/components/i18n/LanguageSwitcher.tsx @@ -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 ( +
+ {LOCALES.map((l) => ( + + ))} +
+ ); +} diff --git a/components/i18n/LocaleProvider.tsx b/components/i18n/LocaleProvider.tsx new file mode 100644 index 0000000..83633ec --- /dev/null +++ b/components/i18n/LocaleProvider.tsx @@ -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(null); + +export function LocaleProvider({ + locale, + dict, + children, +}: LocaleContextValue & { children: React.ReactNode }) { + const value = useMemo(() => ({ locale, dict }), [locale, dict]); + return ( + {children} + ); +} + +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; +} diff --git a/lib/i18n/dictionaries.ts b/lib/i18n/dictionaries.ts new file mode 100644 index 0000000..de5254e --- /dev/null +++ b/lib/i18n/dictionaries.ts @@ -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 }; diff --git a/lib/i18n/index.ts b/lib/i18n/index.ts new file mode 100644 index 0000000..5c874e0 --- /dev/null +++ b/lib/i18n/index.ts @@ -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 { + const store = await cookies(); + const value = store.get("locale")?.value; + return isLocale(value) ? value : DEFAULT_LOCALE; +} diff --git a/lib/i18n/locales.ts b/lib/i18n/locales.ts new file mode 100644 index 0000000..6711102 --- /dev/null +++ b/lib/i18n/locales.ts @@ -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 = { + de: "Deutsch", + en: "English", + pl: "Polski", +}; + +export function isLocale(value: string | undefined | null): value is Locale { + return !!value && (LOCALES as readonly string[]).includes(value); +} diff --git a/lib/i18n/shared.ts b/lib/i18n/shared.ts new file mode 100644 index 0000000..79aff4a --- /dev/null +++ b/lib/i18n/shared.ts @@ -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 { + if (!vars) return template; + return template.replace(/\{(\w+)\}/g, (match, key) => + key in vars ? String(vars[key]) : match + ); +}