From 5c9df6e33bf3db12c864740e6632206758f2eac0 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 18 Jul 2026 01:29:27 +0200 Subject: [PATCH 1/9] Fix: Passwort-wiederholen-Feld beim Bearbeiten erst nach Tippen sichtbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feld war beim Bearbeiten an form.password gebunden und erschien erst, nachdem im ersten Feld schon getippt wurde – wirkte, als reiche ein einziges Passwortfeld. Jetzt immer sichtbar, Pflicht bleibt weiterhin nur, wenn tatsächlich ein neues Passwort gesetzt wird. --- components/UserManager.tsx | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/components/UserManager.tsx b/components/UserManager.tsx index 0cc4491..75369ea 100644 --- a/components/UserManager.tsx +++ b/components/UserManager.tsx @@ -192,25 +192,23 @@ export default function UserManager() { autoComplete="new-password" /> - {(!form.id || form.password) && ( -
- - - setForm({ ...form, passwordRepeat: e.target.value }) - } - minLength={8} - required={!form.id || !!form.password} - autoComplete="new-password" - /> -
- )} +
+ + + setForm({ ...form, passwordRepeat: e.target.value }) + } + minLength={8} + required={!form.id || !!form.password} + autoComplete="new-password" + /> +
+ ))} +
+ ); +} 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 + ); +} From 1cc19de221e8c926132de0de826df7843174d082 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 18 Jul 2026 02:08:01 +0200 Subject: [PATCH 5/9] =?UTF-8?q?Migration:=20=C3=9Cbersetzungsspalten=20f?= =?UTF-8?q?=C3=BCr=20Inhalte=20+=20Rezeptvorschl=C3=A4ge-Tabelle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dishes/sections/allergens/additives bekommen _en/_pl-Spalten; die 14 EU-Allergene, 11 Zusatzstoff-Kennzeichnungen und 4 Bereiche sind direkt mit Referenz-Übersetzungen befüllt. localized() liefert den Text zur aktuellen Sprache mit Fallback auf Deutsch. Neue Tabelle dish_suggestions (RLS: jeder darf einreichen, nur Staff/Admin lesen). Lokale PostgreSQL-Kopie auf den neuen Stand gebracht. --- lib/types.ts | 35 ++++-- supabase/local/10_schema.sql | 66 +++++++++- supabase/local/20_data.sql | 118 ++++++++++-------- .../migrations/0005_i18n_and_suggestions.sql | 68 ++++++++++ 4 files changed, 220 insertions(+), 67 deletions(-) create mode 100644 supabase/migrations/0005_i18n_and_suggestions.sql diff --git a/lib/types.ts b/lib/types.ts index 16c33f3..3f496c9 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -12,6 +12,8 @@ export interface Profile { export interface Section { key: string; label: string; + label_en: string | null; + label_pl: string | null; time_range: string; sort: number; } @@ -19,6 +21,8 @@ export interface Section { export interface Dish { id: string; name: string; + name_en: string | null; + name_pl: string | null; price: number; allergens: string[]; additives: string[]; @@ -35,9 +39,29 @@ export interface MenuEntry { export interface CodeItem { code: string; name: string; + name_en: string | null; + name_pl: string | null; sort: number; } +export interface DishSuggestion { + id: string; + name: string; + description: string | null; + created_at: string; +} + +/** Übersetzten Namen für die aktuelle Sprache liefern, sonst Deutsch als Fallback. */ +export function localized( + item: { name?: string; label?: string; name_en?: string | null; name_pl?: string | null; label_en?: string | null; label_pl?: string | null }, + locale: string +): string { + const de = item.name ?? item.label ?? ""; + if (locale === "en") return item.name_en || item.label_en || de; + if (locale === "pl") return item.name_pl || item.label_pl || de; + return de; +} + // Farb-Defaults (r-hofmann.de: Blau #003388, Navy #0d315c, Orange #ff6900) export const DEFAULT_COLORS: Record = { primary: "#003388", @@ -61,17 +85,6 @@ export const COLOR_VARS: Record = { deleteBtn: "--c-del", }; -export const COLOR_LABELS: Record = { - primary: "Primärfarbe (Tabellenkopf)", - primaryDark: "Primärfarbe dunkel (Kopfleiste intern)", - accent: "Akzentfarbe", - headerText: "Schriftfarbe im Tabellenkopf", - rowBg: "Zellen-Hintergrund", - rowBgAlt: "Zellen-Hintergrund (alternierend)", - addBtn: "Button „Hinzufügen“", - deleteBtn: "Button „Löschen“", -}; - export function logoUrl(path: string | null | undefined): string | null { if (!path) return null; return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/branding/${path}`; diff --git a/supabase/local/10_schema.sql b/supabase/local/10_schema.sql index 7432544..cb7d922 100644 --- a/supabase/local/10_schema.sql +++ b/supabase/local/10_schema.sql @@ -22,6 +22,9 @@ SET row_security = off; -- Name: public; Type: SCHEMA; Schema: -; Owner: - -- +-- Das "public"-Schema existiert im offiziellen postgres-Image bereits; +-- CREATE SCHEMA würde dort mit ON_ERROR_STOP=1 das komplette Init-Skript +-- abbrechen. CREATE SCHEMA IF NOT EXISTS public; @@ -106,7 +109,9 @@ SET default_table_access_method = heap; CREATE TABLE public.additives ( code text NOT NULL, name text NOT NULL, - sort integer DEFAULT 0 NOT NULL + sort integer DEFAULT 0 NOT NULL, + name_en text, + name_pl text ); @@ -117,7 +122,21 @@ CREATE TABLE public.additives ( CREATE TABLE public.allergens ( code text NOT NULL, name text NOT NULL, - sort integer DEFAULT 0 NOT NULL + sort integer DEFAULT 0 NOT NULL, + name_en text, + name_pl text +); + + +-- +-- Name: dish_suggestions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.dish_suggestions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name text NOT NULL, + description text, + created_at timestamp with time zone DEFAULT now() NOT NULL ); @@ -132,7 +151,9 @@ CREATE TABLE public.dishes ( created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, allergens text[] DEFAULT '{}'::text[] NOT NULL, - additives text[] DEFAULT '{}'::text[] NOT NULL + additives text[] DEFAULT '{}'::text[] NOT NULL, + name_en text, + name_pl text ); @@ -173,7 +194,9 @@ CREATE TABLE public.sections ( key text NOT NULL, label text NOT NULL, time_range text NOT NULL, - sort integer NOT NULL + sort integer NOT NULL, + label_en text, + label_pl text ); @@ -207,6 +230,14 @@ ALTER TABLE ONLY public.allergens ADD CONSTRAINT allergens_pkey PRIMARY KEY (code); +-- +-- Name: dish_suggestions dish_suggestions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dish_suggestions + ADD CONSTRAINT dish_suggestions_pkey PRIMARY KEY (id); + + -- -- Name: dishes dishes_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -340,6 +371,33 @@ CREATE POLICY allergens_public_read ON public.allergens FOR SELECT TO authentica CREATE POLICY allergens_staff_write ON public.allergens TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))) WITH CHECK ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))); +-- +-- Name: dish_suggestions; Type: ROW SECURITY; Schema: public; Owner: - +-- + +ALTER TABLE public.dish_suggestions ENABLE ROW LEVEL SECURITY; + +-- +-- Name: dish_suggestions dish_suggestions_public_insert; Type: POLICY; Schema: public; Owner: - +-- + +CREATE POLICY dish_suggestions_public_insert ON public.dish_suggestions FOR INSERT TO authenticated, anon WITH CHECK (true); + + +-- +-- Name: dish_suggestions dish_suggestions_staff_delete; Type: POLICY; Schema: public; Owner: - +-- + +CREATE POLICY dish_suggestions_staff_delete ON public.dish_suggestions FOR DELETE TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))); + + +-- +-- Name: dish_suggestions dish_suggestions_staff_select; Type: POLICY; Schema: public; Owner: - +-- + +CREATE POLICY dish_suggestions_staff_select ON public.dish_suggestions FOR SELECT TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))); + + -- -- Name: dishes; Type: ROW SECURITY; Schema: public; Owner: - -- diff --git a/supabase/local/20_data.sql b/supabase/local/20_data.sql index 974e28f..5a38c99 100644 --- a/supabase/local/20_data.sql +++ b/supabase/local/20_data.sql @@ -2,7 +2,7 @@ -- PostgreSQL database dump -- -\restrict WmQEI7C1lM8AheyDeBFMpJzZHr1ZhZm5NphQHzcUeS7IAzg73qb94aso6w4qDad +\restrict m2Vnc4kNXrHeKWS4dSX5SqMU5nUOxMfipXDVyLuYabYYzEbAHutmaRS8m10Dsdh -- Dumped from database version 17.6 -- Dumped by pg_dump version 17.10 @@ -27,18 +27,18 @@ SET SESSION AUTHORIZATION DEFAULT; ALTER TABLE public.additives DISABLE TRIGGER ALL; -COPY public.additives (code, name, sort) FROM stdin; -1 mit Farbstoff 1 -2 mit Konservierungsstoff 2 -3 mit Antioxidationsmittel 3 -4 mit Geschmacksverstärker 4 -5 geschwefelt 5 -6 geschwärzt 6 -7 gewachst 7 -8 mit Phosphat 8 -9 mit Süßungsmittel(n) 9 -10 enthält eine Phenylalaninquelle 10 -11 koffeinhaltig 11 +COPY public.additives (code, name, sort, name_en, name_pl) FROM stdin; +1 mit Farbstoff 1 with colouring z barwnikiem +2 mit Konservierungsstoff 2 with preservative z konserwantem +3 mit Antioxidationsmittel 3 with antioxidant z przeciwutleniaczem +4 mit Geschmacksverstärker 4 with flavour enhancer ze wzmacniaczem smaku +5 geschwefelt 5 sulphured siarkowany +6 geschwärzt 6 blackened barwiony na czarno +7 gewachst 7 waxed woskowany +8 mit Phosphat 8 with phosphate z fosforanem +9 mit Süßungsmittel(n) 9 with sweetener(s) ze słodzikiem/słodzikami +10 enthält eine Phenylalaninquelle 10 contains a source of phenylalanine zawiera źródło fenyloalaniny +11 koffeinhaltig 11 caffeinated zawiera kofeinę \. @@ -50,50 +50,64 @@ ALTER TABLE public.additives ENABLE TRIGGER ALL; ALTER TABLE public.allergens DISABLE TRIGGER ALL; -COPY public.allergens (code, name, sort) FROM stdin; -A Glutenhaltiges Getreide 1 -B Krebstiere 2 -C Eier 3 -D Fisch 4 -E Erdnüsse 5 -F Sojabohnen 6 -G Milch (einschließlich Laktose) 7 -H Schalenfrüchte (Nüsse) 8 -I Sellerie 9 -J Senf 10 -K Sesamsamen 11 -L Schwefeldioxid und Sulfite 12 -M Lupinen 13 -N Weichtiere 14 +COPY public.allergens (code, name, sort, name_en, name_pl) FROM stdin; +A Glutenhaltiges Getreide 1 Cereals containing gluten Zboża zawierające gluten +B Krebstiere 2 Crustaceans Skorupiaki +C Eier 3 Eggs Jaja +D Fisch 4 Fish Ryby +E Erdnüsse 5 Peanuts Orzeszki ziemne +F Sojabohnen 6 Soybeans Soja +G Milch (einschließlich Laktose) 7 Milk (including lactose) Mleko (w tym laktoza) +H Schalenfrüchte (Nüsse) 8 Tree nuts Orzechy +I Sellerie 9 Celery Seler +J Senf 10 Mustard Gorczyca +K Sesamsamen 11 Sesame seeds Nasiona sezamu +L Schwefeldioxid und Sulfite 12 Sulphur dioxide and sulphites Dwutlenek siarki i siarczyny +M Lupinen 13 Lupin Łubin +N Weichtiere 14 Molluscs Mięczaki \. ALTER TABLE public.allergens ENABLE TRIGGER ALL; +-- +-- Data for Name: dish_suggestions; Type: TABLE DATA; Schema: public; Owner: - +-- + +ALTER TABLE public.dish_suggestions DISABLE TRIGGER ALL; + +COPY public.dish_suggestions (id, name, description, created_at) FROM stdin; +\. + + +ALTER TABLE public.dish_suggestions ENABLE TRIGGER ALL; + -- -- Data for Name: dishes; Type: TABLE DATA; Schema: public; Owner: - -- ALTER TABLE public.dishes DISABLE TRIGGER ALL; -COPY public.dishes (id, name, price, created_at, updated_at, allergens, additives) FROM stdin; -bf612ae7-8cdd-4149-8b21-f651631ccc02 Hackfleischbaguette 3.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} -81c58cc6-7298-4d4a-9997-71d17df5aedf Knusperhähnchen Wrap 3.70 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} -cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 Leberkäse im Brötchen 2.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} -14ef6893-93a7-4649-afe3-d6b6bdf5903c Schnitzel Baguette 3.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} -ec3c5e37-9cbc-4c92-ab05-1e3fb942c504 Penne mit Grünen Spargel und Garnelenspieße 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} -dbe5ee6e-8b04-4bf7-99bb-f936f6564217 Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} -c0613fe7-e5c6-489e-afe5-6da2533b2bd4 Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} -c5cfa5b4-e502-4b56-b579-b9fbbf817f25 Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} -f8ba1844-1f23-4b23-9e03-f59148f8b2ed Spaghetti mit fruchtiger Tomatensauce und Parmesan 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} -7a6fed05-e0f5-4e97-bdd4-a46c5ef1376a Gebackener Feta mit griechischen Bauernsalat und Baguette 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} -ff40ea65-63bc-4d01-b626-e0c9e5d2acb3 Gemüse-Curry mit Basmatireis 4.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} -ea26f18b-ddf3-4899-95e7-5dbd0878d90c Kartoffelcremesuppe 1.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} -6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 Gulaschsuppe 2.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} -3c8cf0c2-b0a9-43e3-8233-723d5718a157 Tomatensuppe mit Croutons 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} -2dd85835-5425-4271-9fc0-e5215bbce476 Kürbiscremesuppe 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} -06df753a-65ed-4bd0-a325-d32c78ec88fa Linseneintopf mit Würstchen 2.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} -a987c96f-25a0-4e5e-b8a8-aab683a01feb Chili con Carne mit Brötchen 3.90 2026-07-17 15:17:02.979184+00 2026-07-17 22:21:35.276672+00 {A} {4} +COPY public.dishes (id, name, price, created_at, updated_at, allergens, additives, name_en, name_pl) FROM stdin; +bf612ae7-8cdd-4149-8b21-f651631ccc02 Hackfleischbaguette 3.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N +81c58cc6-7298-4d4a-9997-71d17df5aedf Knusperhähnchen Wrap 3.70 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N +cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 Leberkäse im Brötchen 2.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N +14ef6893-93a7-4649-afe3-d6b6bdf5903c Schnitzel Baguette 3.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N +ec3c5e37-9cbc-4c92-ab05-1e3fb942c504 Penne mit Grünen Spargel und Garnelenspieße 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N +dbe5ee6e-8b04-4bf7-99bb-f936f6564217 Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N +c0613fe7-e5c6-489e-afe5-6da2533b2bd4 Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N +c5cfa5b4-e502-4b56-b579-b9fbbf817f25 Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N +f8ba1844-1f23-4b23-9e03-f59148f8b2ed Spaghetti mit fruchtiger Tomatensauce und Parmesan 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N +7a6fed05-e0f5-4e97-bdd4-a46c5ef1376a Gebackener Feta mit griechischen Bauernsalat und Baguette 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N +ff40ea65-63bc-4d01-b626-e0c9e5d2acb3 Gemüse-Curry mit Basmatireis 4.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N +ea26f18b-ddf3-4899-95e7-5dbd0878d90c Kartoffelcremesuppe 1.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N +6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 Gulaschsuppe 2.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N +3c8cf0c2-b0a9-43e3-8233-723d5718a157 Tomatensuppe mit Croutons 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N +2dd85835-5425-4271-9fc0-e5215bbce476 Kürbiscremesuppe 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N +06df753a-65ed-4bd0-a325-d32c78ec88fa Linseneintopf mit Würstchen 2.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N +a987c96f-25a0-4e5e-b8a8-aab683a01feb Chili con Carne mit Brötchen 3.90 2026-07-17 15:17:02.979184+00 2026-07-17 22:21:35.276672+00 {A} {4} \N \N +7ebdf56a-9482-4bde-bf60-e6e5424e397b Kürbisrisotto 0.00 2026-07-17 23:57:48.351754+00 2026-07-17 23:57:48.351754+00 {} {} \N \N +fd356340-e10f-42d7-ae5a-3521a9151bb8 Test 0.00 2026-07-17 23:58:04.141825+00 2026-07-17 23:58:19.411846+00 {} {} abc bca \. @@ -105,11 +119,11 @@ ALTER TABLE public.dishes ENABLE TRIGGER ALL; ALTER TABLE public.sections DISABLE TRIGGER ALL; -COPY public.sections (key, label, time_range, sort) FROM stdin; -brotzeit Brotzeit 09:00 - 10:00 1 -mittagessen Mittagessen 11:30 - 13:30 2 -mittagessen_veg Mittagessen vegetarisch 11:30 - 13:30 3 -suppe Suppe 11:30 - 13:30 4 +COPY public.sections (key, label, time_range, sort, label_en, label_pl) FROM stdin; +brotzeit Brotzeit 09:00 - 10:00 1 Breakfast Śniadanie +mittagessen Mittagessen 11:30 - 13:30 2 Lunch Obiad +mittagessen_veg Mittagessen vegetarisch 11:30 - 13:30 3 Lunch (vegetarian) Obiad (wegetariański) +suppe Suppe 11:30 - 13:30 4 Soup Zupa \. @@ -194,5 +208,5 @@ ALTER TABLE public.settings ENABLE TRIGGER ALL; -- PostgreSQL database dump complete -- -\unrestrict WmQEI7C1lM8AheyDeBFMpJzZHr1ZhZm5NphQHzcUeS7IAzg73qb94aso6w4qDad +\unrestrict m2Vnc4kNXrHeKWS4dSX5SqMU5nUOxMfipXDVyLuYabYYzEbAHutmaRS8m10Dsdh diff --git a/supabase/migrations/0005_i18n_and_suggestions.sql b/supabase/migrations/0005_i18n_and_suggestions.sql new file mode 100644 index 0000000..d8d1482 --- /dev/null +++ b/supabase/migrations/0005_i18n_and_suggestions.sql @@ -0,0 +1,68 @@ +-- ============================================================= +-- Mehrsprachigkeit (Inhalte) + Rezeptvorschläge +-- ============================================================= + +-- ---------- Übersetzungsspalten ---------- +-- Nullable: ohne Übersetzung fällt die Anzeige auf den deutschen +-- Text zurück. Werden manuell oder per DeepL-Anbindung befüllt. +alter table public.dishes add column name_en text, add column name_pl text; +alter table public.sections add column label_en text, add column label_pl text; +alter table public.allergens add column name_en text, add column name_pl text; +alter table public.additives add column name_en text, add column name_pl text; + +-- ---------- Referenz-Übersetzungen (feste EU-Kennzeichnung) ---------- +update public.sections set label_en = 'Breakfast', label_pl = 'Śniadanie' where key = 'brotzeit'; +update public.sections set label_en = 'Lunch', label_pl = 'Obiad' where key = 'mittagessen'; +update public.sections set label_en = 'Lunch (vegetarian)', label_pl = 'Obiad (wegetariański)' where key = 'mittagessen_veg'; +update public.sections set label_en = 'Soup', label_pl = 'Zupa' where key = 'suppe'; + +update public.allergens set name_en = 'Cereals containing gluten', name_pl = 'Zboża zawierające gluten' where code = 'A'; +update public.allergens set name_en = 'Crustaceans', name_pl = 'Skorupiaki' where code = 'B'; +update public.allergens set name_en = 'Eggs', name_pl = 'Jaja' where code = 'C'; +update public.allergens set name_en = 'Fish', name_pl = 'Ryby' where code = 'D'; +update public.allergens set name_en = 'Peanuts', name_pl = 'Orzeszki ziemne' where code = 'E'; +update public.allergens set name_en = 'Soybeans', name_pl = 'Soja' where code = 'F'; +update public.allergens set name_en = 'Milk (including lactose)', name_pl = 'Mleko (w tym laktoza)' where code = 'G'; +update public.allergens set name_en = 'Tree nuts', name_pl = 'Orzechy' where code = 'H'; +update public.allergens set name_en = 'Celery', name_pl = 'Seler' where code = 'I'; +update public.allergens set name_en = 'Mustard', name_pl = 'Gorczyca' where code = 'J'; +update public.allergens set name_en = 'Sesame seeds', name_pl = 'Nasiona sezamu' where code = 'K'; +update public.allergens set name_en = 'Sulphur dioxide and sulphites', name_pl = 'Dwutlenek siarki i siarczyny' where code = 'L'; +update public.allergens set name_en = 'Lupin', name_pl = 'Łubin' where code = 'M'; +update public.allergens set name_en = 'Molluscs', name_pl = 'Mięczaki' where code = 'N'; + +update public.additives set name_en = 'with colouring', name_pl = 'z barwnikiem' where code = '1'; +update public.additives set name_en = 'with preservative', name_pl = 'z konserwantem' where code = '2'; +update public.additives set name_en = 'with antioxidant', name_pl = 'z przeciwutleniaczem' where code = '3'; +update public.additives set name_en = 'with flavour enhancer', name_pl = 'ze wzmacniaczem smaku' where code = '4'; +update public.additives set name_en = 'sulphured', name_pl = 'siarkowany' where code = '5'; +update public.additives set name_en = 'blackened', name_pl = 'barwiony na czarno' where code = '6'; +update public.additives set name_en = 'waxed', name_pl = 'woskowany' where code = '7'; +update public.additives set name_en = 'with phosphate', name_pl = 'z fosforanem' where code = '8'; +update public.additives set name_en = 'with sweetener(s)', name_pl = 'ze słodzikiem/słodzikami' where code = '9'; +update public.additives set name_en = 'contains a source of phenylalanine', name_pl = 'zawiera źródło fenyloalaniny' where code = '10'; +update public.additives set name_en = 'caffeinated', name_pl = 'zawiera kofeinę' where code = '11'; + +-- ---------- Rezeptvorschläge ---------- +create table public.dish_suggestions ( + id uuid primary key default gen_random_uuid(), + name text not null, + description text, + created_at timestamptz not null default now() +); + +alter table public.dish_suggestions enable row level security; + +-- Jeder darf einen Vorschlag einreichen, aber niemand außer dem +-- Kantinen-Team darf die Liste einsehen (kein Lesezugriff für anon). +create policy "dish_suggestions_public_insert" on public.dish_suggestions + for insert to anon, authenticated + with check (true); + +create policy "dish_suggestions_staff_select" on public.dish_suggestions + for select to authenticated + using (public.app_role() in ('staff', 'admin')); + +create policy "dish_suggestions_staff_delete" on public.dish_suggestions + for delete to authenticated + using (public.app_role() in ('staff', 'admin')); From 30a02ba7b7c1c4049368a4b51b6450b54bd5b864 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 18 Jul 2026 02:08:02 +0200 Subject: [PATCH 6/9] =?UTF-8?q?DeepL-Anbindung=20f=C3=BCr=20automatische?= =?UTF-8?q?=20=C3=9Cbersetzung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/api/translate ruft DeepL für EN+PL auf und schreibt das Ergebnis in die jeweilige _en/_pl-Spalte (Staff/Admin-only, Service-Role-Key). Ohne DEEPL_API_KEY (neue Docker-Variable) antwortet die Route mit configured:false, das Frontend zeigt dann den Hinweis zur manuellen Eingabe statt eines Fehlers. --- .env.example | 6 +++ app/api/translate/route.ts | 100 +++++++++++++++++++++++++++++++++++++ docker-compose.yml | 5 ++ 3 files changed, 111 insertions(+) create mode 100644 app/api/translate/route.ts diff --git a/.env.example b/.env.example index 4a2fa4f..00f35de 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,9 @@ APP_URL=http://localhost:3000 # Titel im Browser-Tab und Überschrift, solange kein Logo hochgeladen ist. SITE_TITLE=Canteen Menu + +# Optional: DeepL-API-Key für automatische Übersetzung von Gerichten nach +# Englisch/Polnisch (kostenloser Free-Tier unter deepl.com/de/pro-api, +# Free-Keys enden auf ":fx"). Ohne Key bleibt "Automatisch übersetzen" +# inaktiv; Englisch/Polnisch lassen sich weiterhin manuell eintragen. +DEEPL_API_KEY= diff --git a/app/api/translate/route.ts b/app/api/translate/route.ts new file mode 100644 index 0000000..f0c916c --- /dev/null +++ b/app/api/translate/route.ts @@ -0,0 +1,100 @@ +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { isSameOrigin } from "@/lib/origin"; + +// Übersetzt einen deutschen Text nach Englisch/Polnisch via DeepL und +// schreibt das Ergebnis in die _en/_pl-Spalte der angegebenen Zeile. +// Ohne DEEPL_API_KEY läuft die Route kontrolliert ins Leere (configured: +// false), damit das UI die manuelle Eingabe anbieten kann. + +const ALLOWED_TABLES: Record = { + dishes: { idColumn: "id", textColumn: "name" }, + sections: { idColumn: "key", textColumn: "label" }, + allergens: { idColumn: "code", textColumn: "name" }, + additives: { idColumn: "code", textColumn: "name" }, +}; + +async function translateWithDeepL(text: string, targetLang: "EN" | "PL", apiKey: string) { + const host = apiKey.endsWith(":fx") + ? "https://api-free.deepl.com" + : "https://api.deepl.com"; + const res = await fetch(`${host}/v2/translate`, { + method: "POST", + headers: { + Authorization: `DeepL-Auth-Key ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + text: [text], + target_lang: targetLang, + source_lang: "DE", + }), + }); + if (!res.ok) { + throw new Error(`DeepL antwortete mit ${res.status}: ${await res.text()}`); + } + const data = await res.json(); + return data.translations?.[0]?.text as string | undefined; +} + +export async function POST(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + + const admin = createAdminClient(); + const { data: profile } = await admin + .from("profiles") + .select("role") + .eq("id", user.id) + .single(); + if (!profile || !["staff", "admin"].includes(profile.role)) { + return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); + } + + const body = await request.json().catch(() => null); + const { table, id, text } = body ?? {}; + const config = ALLOWED_TABLES[table]; + if (!config || typeof text !== "string" || !text.trim()) { + return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 }); + } + // Bei einem noch nicht gespeicherten (neuen) Eintrag gibt es keine + // Zeile zum Aktualisieren – nur übersetzen, das Speichern übernimmt + // anschließend das reguläre Formular. + const persist = typeof id === "string" && id.length > 0; + + const apiKey = process.env.DEEPL_API_KEY; + if (!apiKey) { + return NextResponse.json({ configured: false }); + } + + try { + const [en, pl] = await Promise.all([ + translateWithDeepL(text, "EN", apiKey), + translateWithDeepL(text, "PL", apiKey), + ]); + if (!en || !pl) throw new Error("Keine Übersetzung erhalten."); + + if (persist) { + const { error } = await admin + .from(table) + .update({ [`${config.textColumn}_en`]: en, [`${config.textColumn}_pl`]: pl }) + .eq(config.idColumn, id); + if (error) throw new Error(error.message); + } + + return NextResponse.json({ configured: true, en, pl }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Übersetzung fehlgeschlagen." }, + { status: 502 } + ); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 04a512e..61811cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,10 @@ services: APP_URL: ${APP_URL:-http://localhost:3000} # Titel im Browser-Tab und Überschrift, solange kein Logo hochgeladen ist. SITE_TITLE: ${SITE_TITLE:-Canteen Menu} + # Optional: DeepL-API-Key für automatische Übersetzung von Gerichten + # (Kürzel-Erklärungen, Bereichen) nach Englisch/Polnisch. Ohne Key + # bleibt "Automatisch übersetzen" inaktiv, manuelle Eingabe geht immer. + DEEPL_API_KEY: ${DEEPL_API_KEY:-} ports: - "0.0.0.0:3000:3000" restart: unless-stopped @@ -71,6 +75,7 @@ services: SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} APP_URL: ${APP_URL:-http://localhost:3000} SITE_TITLE: ${SITE_TITLE:-Canteen Menu} + DEEPL_API_KEY: ${DEEPL_API_KEY:-} ports: - "0.0.0.0:3000:3000" volumes: From a6165de10cad546ac50fb05d501e684d21bea553 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 18 Jul 2026 02:08:03 +0200 Subject: [PATCH 7/9] =?UTF-8?q?Mehrsprachige=20Oberfl=C3=A4che=20+=20lokal?= =?UTF-8?q?isierte=20Gericht-/Bereichsnamen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alle Seiten und Komponenten (öffentliche Speisekarte, Login, Kantinen- und Admin-Bereich, eigenes Konto) verwenden jetzt die Wörterbücher aus lib/i18n statt fest codierter deutscher Texte. Gerichte/Bereiche/ Allergene/Zusatzstoffe zeigen den Namen zur aktuell gewählten Sprache; Gerichte- und Kennzeichnungs-Formulare bekommen Englisch-/Polnisch- Felder mit Übersetzen-Button (siehe vorherigen Commit). Datumsformat- ierung (formatDateLong) nimmt jetzt ein locale-Argument statt fix 'de-DE'. --- app/intern/kennzeichnung/page.tsx | 12 +- app/login/page.tsx | 46 ++++---- app/page.tsx | 54 +++++---- components/AccountManager.tsx | 104 +++++++++--------- components/AppHeader.tsx | 46 ++++---- components/ChipPicker.tsx | 12 +- components/CodeListManager.tsx | 149 +++++++++++++++++++++---- components/DesignEditor.tsx | 83 ++++++++------ components/DishManager.tsx | 177 ++++++++++++++++++++++++------ components/PasskeyManager.tsx | 45 ++++---- components/PublicMobileMenu.tsx | 39 ++++--- components/UserManager.tsx | 53 ++++----- components/WeekEditor.tsx | 85 +++++++------- lib/menu.ts | 9 +- 14 files changed, 586 insertions(+), 328 deletions(-) diff --git a/app/intern/kennzeichnung/page.tsx b/app/intern/kennzeichnung/page.tsx index 69971f6..d965d2a 100644 --- a/app/intern/kennzeichnung/page.tsx +++ b/app/intern/kennzeichnung/page.tsx @@ -3,16 +3,8 @@ import CodeListManager from "@/components/CodeListManager"; export default function KennzeichnungPage() { return (
- - + +
); } diff --git a/app/login/page.tsx b/app/login/page.tsx index 2cb341f..f84e75e 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -4,20 +4,25 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { createClient } from "@/lib/supabase/client"; +import { formatMessage } from "@/lib/i18n/shared"; +import { useDict } from "@/components/i18n/LocaleProvider"; +import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; +import type { Dict } from "@/lib/i18n/shared"; -function germanAuthError(message: string): string { +function authErrorMessage(dict: Dict, message: string): string { if (/invalid login credentials/i.test(message)) - return "E-Mail-Adresse oder Passwort ist falsch."; + return dict.login.errorInvalidCredentials; if (/email not confirmed/i.test(message)) - return "Die E-Mail-Adresse wurde noch nicht bestätigt."; + return dict.login.errorEmailNotConfirmed; if (/cancelled|aborted|NotAllowedError/i.test(message)) - return "Passkey-Anmeldung wurde abgebrochen."; - return `Anmeldung fehlgeschlagen: ${message}`; + return dict.login.errorPasskeyCancelled; + return formatMessage(dict.login.errorGeneric, { message }); } export default function LoginPage() { const router = useRouter(); const supabase = createClient(); + const dict = useDict(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); @@ -42,7 +47,7 @@ export default function LoginPage() { password, }); if (error || !data.user) { - setError(germanAuthError(error?.message ?? "Unbekannter Fehler")); + setError(authErrorMessage(dict, error?.message ?? "Unbekannter Fehler")); setBusy(false); return; } @@ -54,9 +59,7 @@ export default function LoginPage() { .single(); if (profile?.password_auth_enabled === false) { await supabase.auth.signOut(); - setError( - "Die Anmeldung mit Passwort ist für dieses Konto deaktiviert. Bitte mit Passkey anmelden." - ); + setError(dict.login.errorPasswordDisabled); setBusy(false); return; } @@ -71,14 +74,14 @@ export default function LoginPage() { const { data, error } = await supabase.auth.signInWithPasskey(); if (error || !data?.user) { setError( - germanAuthError(error?.message ?? "Passkey-Anmeldung fehlgeschlagen") + authErrorMessage(dict, error?.message ?? "Passkey-Anmeldung fehlgeschlagen") ); setBusy(false); return; } await redirectByRole(data.user.id); } catch (err) { - setError(germanAuthError(err instanceof Error ? err.message : String(err))); + setError(authErrorMessage(dict, err instanceof Error ? err.message : String(err))); setBusy(false); } } @@ -86,13 +89,16 @@ export default function LoginPage() { return (
-

- Anmeldung -

+
+

+ {dict.login.title} +

+ +
{error &&

{error}

}
- oder + {dict.login.or}
@@ -136,12 +142,12 @@ export default function LoginPage() { className="btn btn-neutral w-full" disabled={busy} > - Mit Passkey anmelden + {dict.login.passkeyButton}

- ← Zur Speisekarte + {dict.login.backToMenu}

diff --git a/app/page.tsx b/app/page.tsx index a97af96..c6eb51a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -8,11 +8,12 @@ import { formatPrice, mondayOf, todayISO, - WEEKDAYS, } from "@/lib/menu"; -import { logoUrl, SITE_TITLE } from "@/lib/types"; +import { getDictionary, getLocale } from "@/lib/i18n"; +import { localized, logoUrl, SITE_TITLE } from "@/lib/types"; import type { CodeItem, MenuEntry, Section } from "@/lib/types"; import PublicMobileMenu from "@/components/PublicMobileMenu"; +import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; export default async function Home({ searchParams, @@ -23,7 +24,9 @@ export default async function Home({ const monday = mondayOf(woche); const friday = addDays(monday, 4); const currentWeek = mondayOf(todayISO()); - const days = WEEKDAYS.map((weekday, i) => ({ + const locale = await getLocale(); + const dict = getDictionary(locale); + const days = dict.weekdays.long.map((weekday, i) => ({ weekday, iso: addDays(monday, i), })); @@ -35,7 +38,7 @@ export default async function Home({ supabase .from("menu_entries") .select( - "id, menu_date, section_key, position, dish:dishes(id, name, price, allergens, additives)" + "id, menu_date, section_key, position, dish:dishes(id, name, name_en, name_pl, price, allergens, additives)" ) .gte("menu_date", monday) .lte("menu_date", friday) @@ -54,16 +57,18 @@ export default async function Home({ return (
- {/* Kopfbereich: Login-Link eigene Zeile, Logo zentriert darunter. - Kein absolutes Positionieren mehr – ein breites Logo (z. B. ein - Banner-Format) konnte sonst mit dem Login-Link kollidieren. */} + {/* Kopfbereich: Sprache + Login-Link eigene Zeile, Logo zentriert + darunter. Kein absolutes Positionieren mehr – ein breites Logo + (z. B. ein Banner-Format) konnte sonst mit dem Login-Link + kollidieren. */}
-
+
+ - Anmelden + {dict.common.login}
@@ -87,15 +92,18 @@ export default async function Home({ {/* Ab "sm": klassische Wochentabelle */}
-
+
- ← Vorherige Woche + {dict.publicMenu.prevWeek} - Heute + {dict.publicMenu.today} - Nächste Woche → + {dict.publicMenu.nextWeek} + + + {dict.publicMenu.suggestDish}
@@ -108,7 +116,7 @@ export default async function Home({ {d.weekday}
- {formatDateLong(d.iso)} + {formatDateLong(d.iso, dict.dateLocale)} ))} @@ -117,7 +125,7 @@ export default async function Home({ {sections.map((s) => ( -
{s.label}
+
{localized(s, locale)}
{s.time_range}
{days.map((d) => ( @@ -126,7 +134,9 @@ export default async function Home({ const codes = dishCodes(e.dish); return (
- {e.dish.name} + + {localized(e.dish, locale)} + {codes && {codes}} {" – "} {formatPrice(e.dish.price)} @@ -145,23 +155,27 @@ export default async function Home({ {/* Allergene & Zusatzstoffe: Legende zu den Kürzeln bei den Gerichten */}
-

Allergene

+

+ {dict.publicMenu.allergens} +

    {allergens.map((a) => (
  • - {a.code} – {a.name} + {a.code} –{" "} + {localized(a, locale)}
  • ))}

- Zusatzstoffe + {dict.publicMenu.additives}

    {additives.map((a) => (
  • - {a.code} – {a.name} + {a.code} –{" "} + {localized(a, locale)}
  • ))}
diff --git a/components/AccountManager.tsx b/components/AccountManager.tsx index 3a7dfb7..752d500 100644 --- a/components/AccountManager.tsx +++ b/components/AccountManager.tsx @@ -3,20 +3,17 @@ import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { createClient } from "@/lib/supabase/client"; +import { formatMessage } from "@/lib/i18n/shared"; +import type { Dict } from "@/lib/i18n/shared"; import PasskeyManager from "@/components/PasskeyManager"; +import { useDict } from "@/components/i18n/LocaleProvider"; -const ROLE_LABELS: Record = { - admin: "Administrator", - staff: "Kantinenmitarbeiter", -}; - -function germanAuthError(message: string): string { +function authErrorMessage(dict: Dict, message: string): string { if (/invalid login credentials/i.test(message)) - return "Das aktuelle Passwort ist falsch."; - if (/should be at least/i.test(message)) - return "Das neue Passwort ist zu kurz (mindestens 8 Zeichen)."; + return dict.account.wrongCurrentPassword; + if (/should be at least/i.test(message)) return dict.account.passwordTooShort; if (/email.*already|already.*registered/i.test(message)) - return "Diese E-Mail-Adresse wird bereits verwendet."; + return dict.account.emailAlreadyUsed; return message; } @@ -33,6 +30,8 @@ export default function AccountManager({ }) { const supabase = useMemo(() => createClient(), []); const router = useRouter(); + const dict = useDict(); + const roleLabel = role === "admin" ? dict.common.roleAdmin : dict.common.roleStaff; // Persönliche Daten const [fullName, setFullName] = useState(initialFullName); @@ -75,7 +74,7 @@ export default function AccountManager({ setNameInfo(null); const name = fullName.trim(); if (!name) { - setNameError("Bitte einen Namen angeben."); + setNameError(dict.account.nameRequired); return; } setNameBusy(true); @@ -83,9 +82,10 @@ export default function AccountManager({ .from("profiles") .update({ full_name: name }) .eq("id", (await supabase.auth.getUser()).data.user?.id ?? ""); - if (error) setNameError(`Speichern fehlgeschlagen: ${error.message}`); + if (error) + setNameError(formatMessage(dict.account.saveError, { message: error.message })); else { - setNameInfo("Name gespeichert."); + setNameInfo(dict.account.nameSaved); router.refresh(); } setNameBusy(false); @@ -100,11 +100,13 @@ export default function AccountManager({ setEmailBusy(true); const { error } = await supabase.auth.updateUser({ email: next }); if (error) { - setEmailError(`Ändern fehlgeschlagen: ${germanAuthError(error.message)}`); - } else { - setEmailInfo( - "Bestätigungs-E-Mails wurden verschickt. Die neue Adresse wird erst nach Bestätigung des Links aktiv." + setEmailError( + formatMessage(dict.account.changeError, { + message: authErrorMessage(dict, error.message), + }) ); + } else { + setEmailInfo(dict.account.emailSent); } setEmailBusy(false); } @@ -114,11 +116,11 @@ export default function AccountManager({ setPwError(null); setPwInfo(null); if (newPassword.length < 8) { - setPwError("Das neue Passwort muss mindestens 8 Zeichen lang sein."); + setPwError(dict.account.passwordTooShort); return; } if (newPassword !== newPasswordRepeat) { - setPwError("Die Passwörter stimmen nicht überein."); + setPwError(dict.account.passwordMismatch); return; } setPwBusy(true); @@ -129,16 +131,20 @@ export default function AccountManager({ password: currentPassword, }); if (verifyError) { - setPwError(germanAuthError(verifyError.message)); + setPwError(authErrorMessage(dict, verifyError.message)); setPwBusy(false); return; } const { error } = await supabase.auth.updateUser({ password: newPassword }); if (error) { - setPwError(`Ändern fehlgeschlagen: ${germanAuthError(error.message)}`); + setPwError( + formatMessage(dict.account.changeError, { + message: authErrorMessage(dict, error.message), + }) + ); } else { - setPwInfo("Passwort geändert."); + setPwInfo(dict.account.passwordChanged); setCurrentPassword(""); setNewPassword(""); setNewPasswordRepeat(""); @@ -151,18 +157,10 @@ export default function AccountManager({ setAuthInfo(null); const next = !passwordAuthEnabled; if (!next && (passkeyCount ?? 0) === 0) { - setAuthError( - "Ohne registrierten Passkey kannst du die Passwort-Anmeldung nicht deaktivieren – sonst wärst du ausgesperrt. Registriere zuerst einen Passkey weiter unten." - ); + setAuthError(dict.account.passwordAuthNeedsPasskey); return; } - if ( - !next && - !window.confirm( - "Anmeldung mit E-Mail und Passwort wirklich deaktivieren? Du kannst dich danach nur noch per Passkey anmelden." - ) - ) - return; + if (!next && !window.confirm(dict.account.confirmDisablePasswordAuth)) return; setAuthBusy(true); const { error } = await supabase @@ -170,11 +168,11 @@ export default function AccountManager({ .update({ password_auth_enabled: next }) .eq("id", (await supabase.auth.getUser()).data.user?.id ?? ""); if (error) { - setAuthError(`Speichern fehlgeschlagen: ${error.message}`); + setAuthError(formatMessage(dict.account.saveError, { message: error.message })); } else { setPasswordAuthEnabled(next); setAuthInfo( - next ? "Passwort-Anmeldung aktiviert." : "Passwort-Anmeldung deaktiviert." + next ? dict.account.passwordAuthEnabled : dict.account.passwordAuthDisabled ); } setAuthBusy(false); @@ -183,18 +181,20 @@ export default function AccountManager({ return (
-

Mein Konto

+

+ {dict.account.title} +

- {initialEmail} – {ROLE_LABELS[role] ?? role} + {initialEmail} – {roleLabel}

-

Persönliche Daten

+

{dict.account.personalTitle}

{nameInfo}

}
-

E-Mail-Adresse

+

{dict.account.emailTitle}

- E-Mail-Adresse ändern + {dict.account.changeEmail}
-

Passwort ändern

+

{dict.account.passwordTitle}

{pwInfo}

}
-

Anmeldung mit Passwort

-

- 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. -

+

{dict.account.passwordAuthTitle}

+

{dict.account.passwordAuthHint}

{authError && (

{authError} @@ -327,7 +323,7 @@ export default function AccountManager({ onChange={togglePasswordAuth} disabled={authBusy || passkeyCount === null} /> - Anmeldung mit E-Mail und Passwort erlaubt + {dict.account.passwordAuthCheckbox}

diff --git a/components/AppHeader.tsx b/components/AppHeader.tsx index 5577bee..4a651d1 100644 --- a/components/AppHeader.tsx +++ b/components/AppHeader.tsx @@ -8,17 +8,8 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import type { Role } from "@/lib/types"; - -const MENU_LINKS = [ - { href: "/intern", label: "Wochenplan" }, - { href: "/intern/gerichte", label: "Gerichte" }, - { href: "/intern/kennzeichnung", label: "Allergene & Zusatzstoffe" }, -]; - -const ADMIN_LINKS = [ - { href: "/admin", label: "Benutzer" }, - { href: "/admin/design", label: "Design" }, -]; +import { useDict } from "@/components/i18n/LocaleProvider"; +import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; export default function AppHeader({ role, @@ -28,6 +19,18 @@ export default function AppHeader({ displayName: string; }) { const pathname = usePathname(); + const dict = useDict(); + + const menuLinks = [ + { href: "/intern", label: dict.header.weekPlan }, + { href: "/intern/gerichte", label: dict.header.dishes }, + { href: "/intern/kennzeichnung", label: dict.header.labels }, + { href: "/intern/vorschlaege", label: dict.header.suggestions }, + ]; + const adminLinks = [ + { href: "/admin", label: dict.header.users }, + { href: "/admin/design", label: dict.header.design }, + ]; const linkClass = (href: string) => `rounded px-2 py-0.5 transition-colors hover:bg-white/10 ${ @@ -37,16 +40,16 @@ export default function AppHeader({ return (
- Kantine + {dict.header.canteen}
diff --git a/components/DesignEditor.tsx b/components/DesignEditor.tsx index 3afdf7b..11b83c5 100644 --- a/components/DesignEditor.tsx +++ b/components/DesignEditor.tsx @@ -3,7 +3,9 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { createClient } from "@/lib/supabase/client"; -import { COLOR_LABELS, DEFAULT_COLORS, faviconUrl, logoUrl } from "@/lib/types"; +import { formatMessage } from "@/lib/i18n/shared"; +import { DEFAULT_COLORS, faviconUrl, logoUrl } from "@/lib/types"; +import { useDict } from "@/components/i18n/LocaleProvider"; type AssetKind = "logo" | "favicon"; @@ -15,6 +17,17 @@ const ASSET_COLUMN: Record = { export default function DesignEditor() { const supabase = useMemo(() => createClient(), []); const router = useRouter(); + const dict = useDict(); + const colorLabels: Record = { + primary: dict.designEditor.colorPrimary, + primaryDark: dict.designEditor.colorPrimaryDark, + accent: dict.designEditor.colorAccent, + headerText: dict.designEditor.colorHeaderText, + rowBg: dict.designEditor.colorRowBg, + rowBgAlt: dict.designEditor.colorRowBgAlt, + addBtn: dict.designEditor.colorAddBtn, + deleteBtn: dict.designEditor.colorDeleteBtn, + }; const [colors, setColors] = useState>(DEFAULT_COLORS); const [logoPath, setLogoPath] = useState(null); const [faviconPath, setFaviconPath] = useState(null); @@ -29,13 +42,13 @@ export default function DesignEditor() { .eq("id", 1) .maybeSingle(); if (error) { - setError(`Laden fehlgeschlagen: ${error.message}`); + setError(formatMessage(dict.designEditor.loadError, { message: error.message })); return; } setColors({ ...DEFAULT_COLORS, ...(data?.colors ?? {}) }); setLogoPath(data?.logo_path ?? null); setFaviconPath(data?.favicon_path ?? null); - }, [supabase]); + }, [supabase, dict]); useEffect(() => { load(); @@ -49,9 +62,10 @@ export default function DesignEditor() { .from("settings") .update({ colors }) .eq("id", 1); - if (error) setError(`Speichern fehlgeschlagen: ${error.message}`); + if (error) + setError(formatMessage(dict.designEditor.saveError, { message: error.message })); else { - setInfo("Farben gespeichert."); + setInfo(dict.designEditor.colorsSaved); router.refresh(); } setBusy(false); @@ -66,7 +80,7 @@ export default function DesignEditor() { e.target.value = ""; if (!file) return; if (!/^image\//.test(file.type)) { - setError("Bitte eine Bilddatei auswählen (PNG, JPG, SVG, WebP, ICO)."); + setError(dict.designEditor.invalidImage); return; } setBusy(true); @@ -80,7 +94,7 @@ export default function DesignEditor() { .from("branding") .upload(path, file, { contentType: file.type }); if (uploadError) { - setError(`Upload fehlgeschlagen: ${uploadError.message}`); + setError(formatMessage(dict.designEditor.uploadError, { message: uploadError.message })); setBusy(false); return; } @@ -90,7 +104,7 @@ export default function DesignEditor() { .update({ [ASSET_COLUMN[kind]]: path }) .eq("id", 1); if (settingsError) { - setError(`Speichern fehlgeschlagen: ${settingsError.message}`); + setError(formatMessage(dict.designEditor.saveError, { message: settingsError.message })); setBusy(false); return; } @@ -98,7 +112,7 @@ export default function DesignEditor() { if (kind === "logo") setLogoPath(path); else setFaviconPath(path); - setInfo(kind === "logo" ? "Logo aktualisiert." : "Favicon aktualisiert."); + setInfo(kind === "logo" ? dict.designEditor.logoUpdated : dict.designEditor.faviconUpdated); router.refresh(); setBusy(false); } @@ -108,7 +122,9 @@ export default function DesignEditor() { if (!path) return; if ( !window.confirm( - `${kind === "logo" ? "Logo" : "Favicon"} wirklich entfernen?` + kind === "logo" + ? dict.designEditor.confirmRemoveLogo + : dict.designEditor.confirmRemoveFavicon ) ) return; @@ -119,12 +135,12 @@ export default function DesignEditor() { .update({ [ASSET_COLUMN[kind]]: null }) .eq("id", 1); if (error) { - setError(`Speichern fehlgeschlagen: ${error.message}`); + setError(formatMessage(dict.designEditor.saveError, { message: error.message })); } else { await supabase.storage.from("branding").remove([path]); if (kind === "logo") setLogoPath(null); else setFaviconPath(null); - setInfo(kind === "logo" ? "Logo entfernt." : "Favicon entfernt."); + setInfo(kind === "logo" ? dict.designEditor.logoRemoved : dict.designEditor.faviconRemoved); router.refresh(); } setBusy(false); @@ -135,7 +151,9 @@ export default function DesignEditor() { return (
-

Design

+

+ {dict.designEditor.title} +

{error && (

@@ -145,23 +163,21 @@ export default function DesignEditor() { {info &&

{info}

}
-

Logo

-

- Das Logo wird zentriert über der Speisekarte angezeigt. -

+

{dict.designEditor.logoTitle}

+

{dict.designEditor.logoHint}

{currentLogo ? ( // eslint-disable-next-line @next/next/no-img-element Aktuelles Logo ) : ( -

Kein Logo hinterlegt.

+

{dict.designEditor.logoNone}

)}
-

Favicon

-

- Wird als Icon im Browser-Tab angezeigt. Ohne eigenes Favicon gilt - das Standard-Icon der Anwendung. -

+

{dict.designEditor.faviconTitle}

+

{dict.designEditor.faviconHint}

{currentFavicon ? ( // eslint-disable-next-line @next/next/no-img-element Aktuelles Favicon ) : ( -

- Kein eigenes Favicon hinterlegt. -

+

{dict.designEditor.faviconNone}

)}
-

Farbgebung

+

{dict.designEditor.colorsTitle}

- {Object.entries(COLOR_LABELS).map(([key, label]) => ( + {Object.entries(colorLabels).map(([key, label]) => (
diff --git a/components/DishManager.tsx b/components/DishManager.tsx index 1851f3c..edf3c28 100644 --- a/components/DishManager.tsx +++ b/components/DishManager.tsx @@ -3,12 +3,17 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { createClient } from "@/lib/supabase/client"; import { dishCodes, formatPrice } from "@/lib/menu"; +import { formatMessage } from "@/lib/i18n/shared"; +import { localized } from "@/lib/types"; import type { CodeItem, Dish } from "@/lib/types"; import ChipPicker from "@/components/ChipPicker"; +import { useDict, useLocale } from "@/components/i18n/LocaleProvider"; interface DishForm { id: string | null; // null = neues Gericht name: string; + nameEn: string; + namePl: string; price: string; allergens: string[]; additives: string[]; @@ -17,6 +22,8 @@ interface DishForm { const EMPTY_FORM: DishForm = { id: null, name: "", + nameEn: "", + namePl: "", price: "", allergens: [], additives: [], @@ -24,6 +31,8 @@ const EMPTY_FORM: DishForm = { export default function DishManager() { const supabase = useMemo(() => createClient(), []); + const dict = useDict(); + const locale = useLocale(); const [dishes, setDishes] = useState([]); const [allergens, setAllergens] = useState([]); const [additives, setAdditives] = useState([]); @@ -33,6 +42,9 @@ export default function DishManager() { const [selected, setSelected] = useState>(new Set()); const [bulkBusy, setBulkBusy] = useState(false); const [search, setSearch] = useState(""); + const [translateState, setTranslateState] = useState< + "idle" | "busy" | "done" | "unavailable" + >("idle"); // Live-Suche: filtert die Liste bereits während des Tippens const visible = useMemo(() => { @@ -47,14 +59,15 @@ export default function DishManager() { supabase.from("additives").select("*").order("sort"), ]); const problem = dishesRes.error ?? allergensRes.error ?? additivesRes.error; - if (problem) setError(`Laden fehlgeschlagen: ${problem.message}`); + if (problem) + setError(formatMessage(dict.dishManager.loadError, { message: problem.message })); else { setDishes(dishesRes.data as Dish[]); setAllergens(allergensRes.data as CodeItem[]); setAdditives(additivesRes.data as CodeItem[]); } setLoading(false); - }, [supabase]); + }, [supabase, dict]); useEffect(() => { load(); @@ -66,11 +79,13 @@ export default function DishManager() { setError(null); const price = Number.parseFloat(form.price.replace(",", ".")); if (Number.isNaN(price) || price < 0) { - setError("Bitte einen gültigen Preis angeben, z. B. 3,50"); + setError(dict.dishManager.invalidPrice); return; } const payload = { name: form.name.trim(), + name_en: form.nameEn.trim() || null, + name_pl: form.namePl.trim() || null, price, allergens: form.allergens, additives: form.additives, @@ -79,7 +94,7 @@ export default function DishManager() { ? await supabase.from("dishes").update(payload).eq("id", form.id) : await supabase.from("dishes").insert(payload); if (error) { - setError(`Speichern fehlgeschlagen: ${error.message}`); + setError(formatMessage(dict.dishManager.saveError, { message: error.message })); return; } setForm(null); @@ -89,12 +104,13 @@ export default function DishManager() { async function remove(dish: Dish) { if ( !window.confirm( - `„${dish.name}“ wirklich löschen? Das Gericht wird auch aus allen Wochenplänen entfernt.` + formatMessage(dict.dishManager.confirmDelete, { name: dish.name }) ) ) return; const { error } = await supabase.from("dishes").delete().eq("id", dish.id); - if (error) setError(`Löschen fehlgeschlagen: ${error.message}`); + if (error) + setError(formatMessage(dict.dishManager.deleteError, { message: error.message })); setSelected((prev) => { const next = new Set(prev); next.delete(dish.id); @@ -128,7 +144,9 @@ export default function DishManager() { if (selected.size === 0) return; if ( !window.confirm( - `${selected.size} Gericht(e) wirklich löschen? Sie werden auch aus allen Wochenplänen entfernt.` + formatMessage(dict.dishManager.confirmDeleteSelected, { + count: selected.size, + }) ) ) return; @@ -138,7 +156,8 @@ export default function DishManager() { .from("dishes") .delete() .in("id", Array.from(selected)); - if (error) setError(`Löschen fehlgeschlagen: ${error.message}`); + if (error) + setError(formatMessage(dict.dishManager.deleteError, { message: error.message })); setSelected(new Set()); setBulkBusy(false); await load(); @@ -164,6 +183,36 @@ export default function DishManager() { }); } + async function autoTranslate() { + if (!form || !form.name.trim()) return; + setTranslateState("busy"); + setError(null); + try { + const res = await fetch("/api/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + table: "dishes", + id: form.id, + text: form.name.trim(), + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`); + if (data.configured === false) { + setTranslateState("unavailable"); + return; + } + setForm((prev) => + prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev + ); + setTranslateState("done"); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setTranslateState("idle"); + } + } + const allSelected = visible.length > 0 && visible.every((d) => selected.has(d.id)); @@ -171,14 +220,17 @@ export default function DishManager() {

- Gerichte + {dict.dishManager.title}

@@ -191,11 +243,11 @@ export default function DishManager() { {form && (

- {form.id ? "Gericht bearbeiten" : "Neues Gericht"} + {form.id ? dict.dishManager.editDish : dict.dishManager.newDish}

setForm({ ...form, price: e.target.value })} required />
+ +
+
+ + setForm({ ...form, nameEn: e.target.value })} + /> +
+
+ + setForm({ ...form, namePl: e.target.value })} + /> +
+
+
+ + {translateState === "done" && ( + + {dict.dishManager.translated} + + )} + {translateState === "unavailable" && ( + + {dict.dishManager.translateUnavailable} + + )} +
+
)} {loading ? ( -

Lade Gerichte…

+

{dict.common.loading}

) : ( <> {dishes.length > 0 && ( setSearch(e.target.value)} - aria-label="Gerichte durchsuchen" + aria-label={dict.dishManager.searchPlaceholder} /> )} {dishes.length > 0 && ( @@ -268,10 +368,10 @@ export default function DishManager() { checked={allSelected} onChange={toggleAll} /> - Alle auswählen + {dict.dishManager.selectAll} - {selected.size} ausgewählt + {formatMessage(dict.dishManager.selectedCount, { count: selected.size })}
)} @@ -292,10 +396,10 @@ export default function DishManager() { type="checkbox" checked={selected.has(dish.id)} onChange={() => toggleOne(dish.id)} - aria-label={`${dish.name} auswählen`} + aria-label={dish.name} /> - {dish.name} + {localized(dish, locale)} {codes && {codes}}{" "} – {formatPrice(dish.price)} @@ -304,36 +408,37 @@ export default function DishManager() { ); })} {dishes.length === 0 && ( -
  • - Noch keine Gerichte angelegt. -
  • +
  • {dict.dishManager.empty}
  • )} {dishes.length > 0 && visible.length === 0 && (
  • - Keine Treffer für „{search}“. + {formatMessage(dict.dishManager.noResults, { query: search })}
  • )} diff --git a/components/PasskeyManager.tsx b/components/PasskeyManager.tsx index 103c7b7..f745de8 100644 --- a/components/PasskeyManager.tsx +++ b/components/PasskeyManager.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { createClient } from "@/lib/supabase/client"; +import { formatMessage } from "@/lib/i18n/shared"; +import { useDict } from "@/components/i18n/LocaleProvider"; interface PasskeyItem { id: string; @@ -12,6 +14,7 @@ interface PasskeyItem { export default function PasskeyManager() { const supabase = useMemo(() => createClient(), []); + const dict = useDict(); const [passkeys, setPasskeys] = useState([]); const [error, setError] = useState(null); const [info, setInfo] = useState(null); @@ -19,9 +22,10 @@ export default function PasskeyManager() { const load = useCallback(async () => { const { data, error } = await supabase.auth.passkey.list(); - if (error) setError(`Passkeys konnten nicht geladen werden: ${error.message}`); + if (error) + setError(formatMessage(dict.passkeys.loadError, { message: error.message })); else setPasskeys((data ?? []) as PasskeyItem[]); - }, [supabase]); + }, [supabase, dict]); useEffect(() => { load(); @@ -34,37 +38,35 @@ export default function PasskeyManager() { try { const { error } = await supabase.auth.registerPasskey(); if (error) { - setError(`Registrierung fehlgeschlagen: ${error.message}`); + setError(formatMessage(dict.passkeys.registerError, { message: error.message })); } else { - setInfo("Passkey wurde erfolgreich registriert."); + setInfo(dict.passkeys.registered); await load(); } } catch (err) { setError( /NotAllowedError|abort/i.test(String(err)) - ? "Die Passkey-Registrierung wurde abgebrochen." - : `Registrierung fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}` + ? dict.passkeys.registerCancelled + : formatMessage(dict.passkeys.registerError, { + message: err instanceof Error ? err.message : String(err), + }) ); } setBusy(false); } async function remove(pk: PasskeyItem) { - if (!window.confirm("Diesen Passkey wirklich löschen?")) return; + if (!window.confirm(dict.passkeys.confirmDelete)) return; const { error } = await supabase.auth.passkey.delete({ passkeyId: pk.id }); - if (error) setError(`Löschen fehlgeschlagen: ${error.message}`); + if (error) + setError(formatMessage(dict.passkeys.deleteError, { message: error.message })); await load(); } return (
    -

    Passkeys

    -

    - 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. -

    +

    {dict.passkeys.title}

    +

    {dict.passkeys.hint}

    {error && (

    @@ -79,7 +81,7 @@ export default function PasskeyManager() { onClick={register} disabled={busy} > - Neuen Passkey registrieren + {dict.passkeys.register}

      @@ -90,8 +92,9 @@ export default function PasskeyManager() { {pk.friendly_name || "Passkey"} {" "} - – angelegt am{" "} - {new Date(pk.created_at).toLocaleDateString("de-DE")} + {formatMessage(dict.passkeys.createdOn, { + date: new Date(pk.created_at).toLocaleDateString(dict.dateLocale), + })} ))} {passkeys.length === 0 && ( -
    • - Noch keine Passkeys registriert. -
    • +
    • {dict.passkeys.empty}
    • )}
    diff --git a/components/PublicMobileMenu.tsx b/components/PublicMobileMenu.tsx index 6161c88..18a21b4 100644 --- a/components/PublicMobileMenu.tsx +++ b/components/PublicMobileMenu.tsx @@ -17,10 +17,11 @@ import { mondayOf, todayISO, weekdayIndex, - WEEKDAYS, - WEEKDAYS_SHORT, } from "@/lib/menu"; +import { localized } from "@/lib/types"; import type { MenuEntry, Section } from "@/lib/types"; +import { useDict, useLocale } from "@/components/i18n/LocaleProvider"; +import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; export default function PublicMobileMenu({ monday, @@ -32,9 +33,11 @@ export default function PublicMobileMenu({ entries: MenuEntry[]; }) { const router = useRouter(); - const days = WEEKDAYS.map((weekday, i) => ({ + const dict = useDict(); + const locale = useLocale(); + const days = dict.weekdays.long.map((weekday, i) => ({ weekday, - short: WEEKDAYS_SHORT[i], + short: dict.weekdays.short[i], iso: addDays(monday, i), })); const isCurrentWeek = monday === mondayOf(todayISO()); @@ -58,7 +61,7 @@ export default function PublicMobileMenu({ return (

    - {day.weekday} · {formatDateLong(day.iso)} + {day.weekday} · {formatDateLong(day.iso, dict.dateLocale)}

    {sections.map((s) => { @@ -69,18 +72,20 @@ export default function PublicMobileMenu({ className="rounded-md border border-gray-200 overflow-hidden" >
    - {s.label}{" "} + {localized(s, locale)}{" "} · {s.time_range}
    {items.length === 0 ? ( -

    –

    +

    {dict.publicMenu.noEntry}

    ) : ( items.map((e) => { const codes = dishCodes(e.dish); return (

    - {e.dish.name} + + {localized(e.dish, locale)} + {codes && {codes}}{" "} – {formatPrice(e.dish.price)}

    @@ -98,25 +103,29 @@ export default function PublicMobileMenu({ return (
    +
    + +
    +
    - ← Woche + {dict.publicMenu.prevWeekShort} - Woche → + {dict.publicMenu.nextWeekShort}
    @@ -128,7 +137,7 @@ export default function PublicMobileMenu({ onClick={() => setTab("tag")} className={`btn flex-1 ${tab === "tag" ? "btn-primary" : "btn-neutral"}`} > - Tag + {dict.publicMenu.dayTab}
    @@ -164,6 +173,10 @@ export default function PublicMobileMenu({ ) : (
    {days.map((d) => renderDay(d))}
    )} + + + {dict.publicMenu.suggestDish} +
    ); } diff --git a/components/UserManager.tsx b/components/UserManager.tsx index 75369ea..deeaf86 100644 --- a/components/UserManager.tsx +++ b/components/UserManager.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { createClient } from "@/lib/supabase/client"; +import { formatMessage } from "@/lib/i18n/shared"; import type { Profile, Role } from "@/lib/types"; +import { useDict } from "@/components/i18n/LocaleProvider"; interface UserForm { id: string | null; // null = neuer Benutzer @@ -22,13 +24,13 @@ const EMPTY_FORM: UserForm = { passwordRepeat: "", }; -const ROLE_LABELS: Record = { - admin: "Administrator", - staff: "Kantinenmitarbeiter", -}; - export default function UserManager() { const supabase = useMemo(() => createClient(), []); + const dict = useDict(); + const roleLabels: Record = { + admin: dict.common.roleAdmin, + staff: dict.common.roleStaff, + }; const [profiles, setProfiles] = useState([]); const [me, setMe] = useState(null); const [form, setForm] = useState(null); @@ -41,10 +43,11 @@ export default function UserManager() { supabase.from("profiles").select("*").order("created_at"), supabase.auth.getUser(), ]); - if (profileError) setError(`Laden fehlgeschlagen: ${profileError.message}`); + if (profileError) + setError(formatMessage(dict.userManager.loadError, { message: profileError.message })); else setProfiles(profileData as Profile[]); setMe(userData.user?.id ?? null); - }, [supabase]); + }, [supabase, dict]); useEffect(() => { load(); @@ -77,7 +80,7 @@ export default function UserManager() { e.preventDefault(); if (!form) return; if (form.password && form.password !== form.passwordRepeat) { - setError("Die Passwörter stimmen nicht überein."); + setError(dict.userManager.passwordMismatch); return; } const ok = form.id @@ -102,7 +105,7 @@ export default function UserManager() { async function remove(profile: Profile) { if ( !window.confirm( - `Benutzer „${profile.email}“ wirklich löschen? Der Zugang wird dauerhaft entfernt.` + formatMessage(dict.userManager.confirmDelete, { email: profile.email }) ) ) return; @@ -113,14 +116,14 @@ export default function UserManager() {

    - Benutzer + {dict.userManager.title}

    @@ -133,12 +136,12 @@ export default function UserManager() { {form && (

    - {form.id ? "Benutzer bearbeiten" : "Neuer Benutzer"} + {form.id ? dict.userManager.editUser : dict.userManager.newUser}

    {!form.id && (
    @@ -230,9 +233,9 @@ export default function UserManager() { {p.full_name || p.email}{" "} - – {p.email} – {ROLE_LABELS[p.role]} + – {p.email} – {roleLabels[p.role]} - {p.id === me && (du)} + {p.id === me && {dict.common.you}} {p.id !== me && ( )} diff --git a/components/WeekEditor.tsx b/components/WeekEditor.tsx index 1ca491e..d8ffaab 100644 --- a/components/WeekEditor.tsx +++ b/components/WeekEditor.tsx @@ -11,14 +11,18 @@ import { formatPrice, mondayOf, todayISO, - WEEKDAYS, } from "@/lib/menu"; +import { formatMessage } from "@/lib/i18n/shared"; +import { localized } from "@/lib/types"; import type { Dish, MenuEntry, Section } from "@/lib/types"; +import { useDict, useLocale } from "@/components/i18n/LocaleProvider"; export default function WeekEditor({ monday }: { monday: string }) { const supabase = useMemo(() => createClient(), []); + const dict = useDict(); + const locale = useLocale(); const friday = addDays(monday, 4); - const days = WEEKDAYS.map((weekday, i) => ({ + const days = dict.weekdays.long.map((weekday, i) => ({ weekday, iso: addDays(monday, i), })); @@ -42,7 +46,7 @@ export default function WeekEditor({ monday }: { monday: string }) { supabase .from("menu_entries") .select( - "id, menu_date, section_key, position, dish:dishes(id, name, price, allergens, additives)" + "id, menu_date, section_key, position, dish:dishes(id, name, name_en, name_pl, price, allergens, additives)" ) .gte("menu_date", monday) .lte("menu_date", friday) @@ -51,14 +55,14 @@ export default function WeekEditor({ monday }: { monday: string }) { ]); const problem = se.error ?? di.error ?? en.error; if (problem) { - setError(`Laden fehlgeschlagen: ${problem.message}`); + setError(formatMessage(dict.weekEditor.loadError, { message: problem.message })); } else { setSections(se.data as Section[]); setDishes(di.data as Dish[]); setEntries(en.data as unknown as MenuEntry[]); } setLoading(false); - }, [supabase, monday, friday]); + }, [supabase, monday, friday, dict]); useEffect(() => { setLoading(true); @@ -75,8 +79,8 @@ export default function WeekEditor({ monday }: { monday: string }) { if (error) { setError( /duplicate key/i.test(error.message) - ? "Dieses Gericht ist an diesem Tag bereits zugewiesen." - : `Speichern fehlgeschlagen: ${error.message}` + ? dict.weekEditor.duplicateEntry + : formatMessage(dict.weekEditor.saveError, { message: error.message }) ); } setAdding(null); @@ -86,27 +90,30 @@ export default function WeekEditor({ monday }: { monday: string }) { async function removeEntry(id: string) { const { error } = await supabase.from("menu_entries").delete().eq("id", id); - if (error) setError(`Löschen fehlgeschlagen: ${error.message}`); + if (error) + setError(formatMessage(dict.weekEditor.deleteError, { message: error.message })); await load(); } + const [hintBefore, hintAfter] = dict.weekEditor.newDishesHint.split("{link}"); + return (

    - Wochenplan + {dict.weekEditor.title}

    - ← Vorherige Woche + {dict.publicMenu.prevWeek} - Heute + {dict.publicMenu.today} - Nächste Woche → + {dict.publicMenu.nextWeek}
    @@ -116,7 +123,7 @@ export default function WeekEditor({ monday }: { monday: string }) {

    )} {loading ? ( -

    Lade Wochenplan…

    +

    {dict.weekEditor.loading}

    ) : ( @@ -126,7 +133,7 @@ export default function WeekEditor({ monday }: { monday: string }) { ))} @@ -135,7 +142,7 @@ export default function WeekEditor({ monday }: { monday: string }) { {sections.map((s) => ( {days.map((d) => { @@ -146,21 +153,23 @@ export default function WeekEditor({ monday }: { monday: string }) { {entriesFor(entries, d.iso, s.key).map((e) => { const codes = dishCodes(e.dish); return ( -
    - {e.dish.name} - {codes && {codes}} - {" – "} - {formatPrice(e.dish.price)} -
    - +
    + + {localized(e.dish, locale)} + + {codes && {codes}} + {" – "} + {formatPrice(e.dish.price)} +
    + +
    -
    ); })} {isAdding ? ( @@ -170,10 +179,10 @@ export default function WeekEditor({ monday }: { monday: string }) { value={selectedDish} onChange={(e) => setSelectedDish(e.target.value)} > - + {dishes.map((dish) => ( ))} @@ -184,7 +193,7 @@ export default function WeekEditor({ monday }: { monday: string }) { onClick={addEntry} disabled={!selectedDish} > - Hinzufügen + {dict.common.add}
    @@ -207,7 +216,7 @@ export default function WeekEditor({ monday }: { monday: string }) { setSelectedDish(""); }} > - Mahlzeit hinzufügen + {dict.weekEditor.addMeal} )} @@ -219,11 +228,11 @@ export default function WeekEditor({ monday }: { monday: string }) {
    {d.weekday}
    - {formatDateLong(d.iso)} + {formatDateLong(d.iso, dict.dateLocale)}
    -
    {s.label}
    +
    {localized(s, locale)}
    {s.time_range}
    )}

    - Neue Gerichte legst du unter{" "} + {hintBefore} - Gerichte - {" "} - an. + {dict.weekEditor.newDishesLinkLabel} + + {hintAfter}

    ); diff --git a/lib/menu.ts b/lib/menu.ts index c417dbb..9cc49fb 100644 --- a/lib/menu.ts +++ b/lib/menu.ts @@ -44,12 +44,9 @@ export function weekdayIndex(iso: string): number { return day <= 4 ? day : -1; } -export const WEEKDAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"]; -export const WEEKDAYS_SHORT = ["Mo", "Di", "Mi", "Do", "Fr"]; - -/** z. B. "13. Juli 2026" */ -export function formatDateLong(iso: string): string { - return toDate(iso).toLocaleDateString("de-DE", { +/** z. B. "13. Juli 2026" (dateLocale z. B. "de-DE", "en-GB", "pl-PL") */ +export function formatDateLong(iso: string, dateLocale: string): string { + return toDate(iso).toLocaleDateString(dateLocale, { day: "numeric", month: "long", year: "numeric", From babc55e7814d30a2ba0ec2eebf9900f7bc0fd80c Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 18 Jul 2026 02:08:03 +0200 Subject: [PATCH 8/9] =?UTF-8?q?Rezeptvorschl=C3=A4ge:=20=C3=B6ffentliches?= =?UTF-8?q?=20Formular=20+=20Sichtung=20im=20Kantinen-Bereich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nicht angemeldete Besucher können unter /vorschlag ein fehlendes Gericht vorschlagen (Name + optionale Beschreibung); Dublettenprüfung gegen bestehende Gerichte, einfacher Honeypot gegen Bot-Spam. Staff/ Admin sichten eingereichte Vorschläge unter /intern/vorschlaege und übernehmen sie als Gericht oder lehnen sie ab. --- app/intern/vorschlaege/page.tsx | 5 ++ app/vorschlag/page.tsx | 5 ++ components/SuggestionForm.tsx | 132 ++++++++++++++++++++++++++++++ components/SuggestionReview.tsx | 137 ++++++++++++++++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 app/intern/vorschlaege/page.tsx create mode 100644 app/vorschlag/page.tsx create mode 100644 components/SuggestionForm.tsx create mode 100644 components/SuggestionReview.tsx diff --git a/app/intern/vorschlaege/page.tsx b/app/intern/vorschlaege/page.tsx new file mode 100644 index 0000000..d07475e --- /dev/null +++ b/app/intern/vorschlaege/page.tsx @@ -0,0 +1,5 @@ +import SuggestionReview from "@/components/SuggestionReview"; + +export default function VorschlaegePage() { + return ; +} diff --git a/app/vorschlag/page.tsx b/app/vorschlag/page.tsx new file mode 100644 index 0000000..91c4d6e --- /dev/null +++ b/app/vorschlag/page.tsx @@ -0,0 +1,5 @@ +import SuggestionForm from "@/components/SuggestionForm"; + +export default function VorschlagPage() { + return ; +} diff --git a/components/SuggestionForm.tsx b/components/SuggestionForm.tsx new file mode 100644 index 0000000..390f0b3 --- /dev/null +++ b/components/SuggestionForm.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { createClient } from "@/lib/supabase/client"; +import { formatMessage } from "@/lib/i18n/shared"; +import { useDict } from "@/components/i18n/LocaleProvider"; +import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; + +export default function SuggestionForm() { + const supabase = createClient(); + const dict = useDict(); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + // Honeypot: für echte Nutzer unsichtbares Feld. Füllt ein Bot es aus, + // tun wir so, als sei alles gut gegangen, ohne wirklich zu speichern. + const [website, setWebsite] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [busy, setBusy] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + const trimmedName = name.trim(); + if (!trimmedName) { + setError(dict.suggestions.nameRequired); + return; + } + if (website.trim()) { + // Bot-Falle ausgelöst: keine echte Anfrage, aber Erfolg vortäuschen. + setSuccess(true); + return; + } + setBusy(true); + + const { data: existing } = await supabase + .from("dishes") + .select("id") + .ilike("name", trimmedName) + .limit(1); + if (existing && existing.length > 0) { + setError(dict.suggestions.duplicate); + setBusy(false); + return; + } + + const { error: insertError } = await supabase.from("dish_suggestions").insert({ + name: trimmedName, + description: description.trim() || null, + }); + if (insertError) { + setError(formatMessage(dict.suggestions.genericError, { message: insertError.message })); + setBusy(false); + return; + } + setSuccess(true); + setBusy(false); + } + + return ( +
    +
    +
    +

    + {dict.suggestions.formTitle} +

    + +
    +

    {dict.suggestions.formHint}

    + + {success ? ( +

    {dict.suggestions.success}

    + ) : ( +
    +
    + + setName(e.target.value)} + required + /> +
    +
    + +