Compare commits

..
38 Commits
Author SHA1 Message Date
florian.wilde dca4af2a0b Merge docs/features: Vorstellung des Funktionsumfangs 2026-08-16 00:04:31 +02:00
florian.wildeandClaude Opus 5 c168d3cca3 docs: FEATURES.md - Vorstellung des Funktionsumfangs
Dokumentiert alle implementierten Features als "Was kann die
Speisekarte?"-Vorstellung: oeffentliche Speisekarte, Kantinen-Bereich,
Admin-Bereich, Anmeldung/Sicherheit, Mehrsprachigkeit, Betrieb sowie
eine kompakte Versionstabelle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 00:04:24 +02:00
florian.wilde 10be022180 Merge docs/zeitaufwand: Zeitübersicht je Version 2026-07-20 12:52:16 +02:00
florian.wilde 1096993120 docs: ZEITAUFWAND.md - Zeitübersicht je Version
Vergleich der Umsetzungszeit mit Claude gegen eine grobe Schätzung
für einen erfahrenen Entwickler ohne KI-Unterstützung, je Version
seit v1.0.0. Wird bei jeder neuen Version ergänzt.
2026-07-20 12:52:01 +02:00
florian.wilde 8b3921c50e Merge fix/closed-day-icon-and-banner: Schloss-Symbol + durchgehendes Geschlossen-Banner 2026-07-20 12:49:46 +02:00
florian.wilde 63870ba2e6 fix: Schloss-Symbol statt Text + durchgehendes Geschlossen-Banner
Ersetzt den "Tag schließen"-Textlink durch ein Schloss-Symbol
(🔓/🔒) und lässt den "Geschlossen"-Hinweis eines geschlossenen
Tages über alle Essenszeiten-Zeilen durchlaufen (per rowSpan), statt
ihn in jeder Zeile einzeln zu wiederholen – sowohl im Wochenplan als
auch auf der öffentlichen Speisekarte.
2026-07-20 12:49:04 +02:00
florian.wilde e9d4b51fce Merge feature/v1.12.0: Tage als geschlossen markierbar (Feiertage o. Ä.) 2026-07-20 12:35:31 +02:00
florian.wilde 2a2c5affd5 feat: Tage als geschlossen markierbar (Feiertage o. Ä.)
Im Wochenplan lässt sich pro Tag ein optionaler, mehrsprachiger
Schließungsgrund hinterlegen. Geschlossene Tage werden auf der
öffentlichen Speisekarte (Desktop + mobil) und im RSS-Feed als
"Geschlossen" statt mit Essenszeiten angezeigt, im Wochenplan bleibt
"Mahlzeit hinzufügen" für diesen Tag deaktiviert.
2026-07-20 12:35:00 +02:00
florian.wilde 0c05a16f09 Merge fix/locale-fallback-inactive-german: Sprach-Fallback ignoriert deaktiviertes Deutsch 2026-07-20 11:23:30 +02:00
florian.wilde c98a3617a4 fix: Sprach-Fallback ignoriert deaktiviertes Deutsch
getLocale() fiel bei fehlendem/ungültigem Cookie bisher immer fest auf
Deutsch zurück, auch wenn Deutsch im Sprachen-Admin deaktiviert wurde.
Da der Sprachumschalter sich bei nur einer aktiven Sprache zusätzlich
ausblendet, gab es keine Möglichkeit, das über die Oberfläche zu
korrigieren. Fallback nutzt jetzt Deutsch nur noch, wenn es aktiv ist.
2026-07-20 11:22:49 +02:00
florian.wilde ede11e5463 Merge feature/v1.11.0: Speiseplan-Inhalte variabel mehrsprachig 2026-07-20 11:11:09 +02:00
florian.wilde cd8b0fab9f feat: Speiseplan-Inhalte variabel mehrsprachig (Teil 2)
Gerichtnamen, Essenszeiten und Allergen-/Zusatzstoff-Bezeichnungen sind
jetzt genauso dynamisch mehrsprachig wie die Bedienoberfläche aus
v1.10.0, statt fest auf Englisch/Polnisch begrenzt zu sein. Neue
Übersetzungstabellen ersetzen die alten name_en/name_pl-Spalten,
DeepL-Auto-Übersetzung und CSV-Import/-Export unterstützen jetzt
beliebig viele aktive Sprachen.
2026-07-20 11:10:06 +02:00
florian.wilde 0d69d77d4e Merge feature/v1.10.0: Dynamisches Sprachpaket-System für die Bedienoberfläche 2026-07-20 10:36:05 +02:00
florian.wildeandClaude Sonnet 5 236f8c9490 feat: Dynamisches Sprachpaket-System für die Bedienoberfläche
Teil 1 des "mehrsprachig variabel"-Umbaus (Speiseplan-Inhalte folgen
in einer späteren Version). Sprachen sind jetzt zur Laufzeit über eine
neue Admin-Seite "Sprachen" verwaltbar statt fest im Code zu stehen:

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

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

Version 1.10.0.

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

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

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

Version 1.9.5.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 00:54:05 +02:00
florian.wilde db868d1174 Merge feature/v1.9: SSO-Login per OpenID Connect (Authentik u.a.) 2026-07-20 00:34:11 +02:00
florian.wildeandClaude Sonnet 5 32ef93ae8e feat: SSO-Login per OpenID Connect (Authentik u.a.)
Dritte Login-Methode neben Passwort und Passkey, konfiguriert über
OIDC_ISSUER/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET (optional, Button bleibt
ohne diese Vars ausgeblendet). Authorization-Code-Flow mit PKCE,
ID-Token-Signaturprüfung gegen das JWKS des Providers via neuer
Abhängigkeit "jose" (RS256 fest gepinnt gegen Algorithm-Confusion),
State/Nonce kurzlebig und einmalig verwendbar in neuer Tabelle
oidc_states (Migration 0003, analog zu webauthn_challenges).

Funktioniert ausschließlich für bereits angelegte Benutzer (Abgleich
per E-Mail) – kein Auto-Provisioning, keine Rollenübernahme aus
Claims, beides bleibt exklusiv über die bestehende Benutzerverwaltung
steuerbar.

app/login/page.tsx zu Server-Component-Wrapper reduziert (prüft
isConfigured(), liest ?error= aus searchParams), bisheriger Inhalt
nach components/LoginForm.tsx verschoben.

Version 1.9.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 00:29:57 +02:00
florian.wilde 73cbda65a1 Merge feature/update-script: Update-Skript für Linux-Deployments (update.sh) 2026-07-20 00:08:18 +02:00
florian.wildeandClaude Sonnet 5 3b5fbd1bcd feat: Update-Skript fuer Linux-Deployments (update.sh)
Stoppt den Docker-Stack, sichert lokale Aenderungen an
docker-compose.yml per git stash (falls vorhanden), fuehrt git pull
aus, spielt die Aenderungen zurueck und startet den Stack neu im
Build-Modus. Bricht bei git-pull- oder Stash-Konflikten kontrolliert
ab statt mit einem halb aktualisierten Stand weiterzumachen.

Version 1.8.1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 00:05:56 +02:00
florian.wilde f561ac29ba Merge feature/v1.8: Umschaltbarer Hinweismodus für Allergene/Zusatzstoffe 2026-07-19 23:58:45 +02:00
florian.wildeandClaude Sonnet 5 de8914f9cc feat: Umschaltbarer Hinweismodus für Allergene/Zusatzstoffe
Neue Migration 0002_labeling_notice.sql: settings.labeling_mode
("detailed"/"notice") + labeling_notice(_en/_pl). Auf der Seite
"Allergene & Zusatzstoffe" (LabelingNoticeEditor) kann zwischen der
detaillierten Auswahl je Gericht und einem editierbaren Hinweistext
umgeschaltet werden. Im Hinweismodus entfällt auf der öffentlichen
Speisekarte (Desktop + Mobile) die Legende und die Kürzel an den
Gerichten, in der Gerichte-Verwaltung wird die Chip-Auswahl beim
Bearbeiten ausgeblendet. Umschalten/Bearbeiten ist für staff und admin
freigegeben, Farben in /api/settings bleiben admin-only.

Version 1.8.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 23:57:43 +02:00
florian.wilde 96eaf3989e Merge feature/v1.7: CSV-Import/-Export, RSS-Fix für mehrere Essenszeiten 2026-07-19 23:14:02 +02:00
florian.wildeandClaude Sonnet 5 474467811c feat: CSV-Import/-Export für Gerichte/Allergene/Zusatzstoffe, RSS-Fix für mehrere Essenszeiten
CSV-Import/-Export (Gerichte, Allergene, Zusatzstoffe): neue Export-/
Import-Routen unter app/api/*/export und */import, hand-geschriebener
CSV-Parser/-Serializer (lib/csv.ts, keine neue Abhängigkeit), Upsert
über Kürzel (Allergene/Zusatzstoffe) bzw. exakten Namen (Gerichte,
mit Ambiguitäts-Check bei Namensdopplung). UI-Buttons in DishManager
und CodeListManager.

RSS-Feed: <content:encoded> zusätzlich zu <description> ausgeben,
da mehrere Feed-Reader description als kürzbare Vorschau behandeln
und dadurch bei mehreren Essenszeiten pro Tag nur die erste zeigten.

Version 1.7.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 23:10:36 +02:00
florian.wilde 5874eb31b6 Merge fix/branding-dir-env: BRANDING_DIR im app-Dienst setzen (Upload-500-Fix) 2026-07-18 15:11:27 +02:00
florian.wildeandClaude Sonnet 5 358872d56c fix: BRANDING_DIR im app-Dienst setzen, sonst 500 beim Logo/Favicon-Upload
Ohne die Variable weicht lib/branding.ts auf ${cwd}/data/branding
(= /app/data/branding) aus statt das im Dockerfile vorbereitete und
gemountete /data/branding zu nutzen. /app gehört root, der App-Prozess
läuft aber als nicht-root – das Anlegen des Fallback-Verzeichnisses
schlägt mit EACCES fehl.

Version 1.6.3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 15:10:46 +02:00
florian.wilde 36be08cea7 Merge fix/bootstrap-admin-file-permission: Admin-Passwort nicht mehr verlieren bei EACCES
bootstrap-admin.mjs verliert das generierte Admin-Passwort nicht mehr,
wenn ZUGANGSDATEN.md nicht beschreibbar ist (Fallback: Log statt
Absturz nach erfolgreichem DB-Insert). DEPLOYMENT.md ergänzt um
chmod 666 in Schritt 2 sowie Fehlerbehebung für ECONNREFUSED und
diesen EACCES-Fall.
2026-07-18 14:51:55 +02:00
florian.wilde b63822060d Version 1.6.2 2026-07-18 14:51:29 +02:00
florian.wilde 94a2c98252 bootstrap-admin.mjs: Zugangsdaten nicht mehr verlieren, wenn ZUGANGSDATEN.md nicht beschreibbar ist
Der Admin-Benutzer wird immer in der Datenbank angelegt, bevor
versucht wird, die generierten Zugangsdaten in ZUGANGSDATEN.md zu
schreiben. Schlug dieser Schreibvorgang fehl (z. B. EACCES, weil die
per Bind-Mount eingebundene Host-Datei root gehört und der
App-Container als nicht-root läuft), flog bisher eine ungefangene
Exception – das Skript brach ab, BEVOR das Passwort irgendwo
protokolliert wurde. Der Admin existierte dann in der DB, aber niemand
kannte sein Passwort mehr.

appendFileSync() läuft jetzt in einem try/catch; schlägt der
Dateizugriff fehl, werden E-Mail und Passwort stattdessen direkt ins
Log geschrieben, und das Skript beendet sich weiterhin mit Exitcode 0
(die eigentlich kritische Operation – der DB-Insert – ist ja bereits
erfolgreich). Lokal verifiziert: Skript bricht bei einer nicht
beschreibbaren ZUGANGSDATEN.md nicht mehr ab, Zugangsdaten landen im
Log, zweiter Lauf bleibt idempotent.

DEPLOYMENT.md: Schritt 2 ergänzt um "chmod 666 ZUGANGSDATEN.md" (der
eigentliche Auslöser), neue Fehlerbehebung-Einträge für
ECONNREFUSED-Startreihenfolge und diesen EACCES-Fall inkl.
Recovery-Anleitung für einen bereits verwaisten Admin-Account.
2026-07-18 14:44:38 +02:00
florian.wilde 0d9d26b885 Merge fix/deployment-guide-pg-password: DB-Port nicht mehr exponiert, Deployment-Doku-Fixes
Datenbank ist nur noch im internen Compose-Netzwerk erreichbar (kein
Host-Port mehr). DEPLOYMENT.md: Fehlerbehebung für "password
authentication failed" (PostgreSQL übernimmt das Passwort nur beim
allerersten Start), .env.example warnt vor Sonderzeichen in
LOCAL_PG_PASSWORD.
2026-07-18 14:30:00 +02:00
florian.wilde 233027fd17 Version 1.6.1 2026-07-18 14:29:13 +02:00
florian.wilde 469b93ee05 Datenbank-Port nicht mehr auf den Host exponieren
docker-compose.yml: "ports: 5433:5432" beim db-Dienst entfernt. Die
Datenbank ist damit nur noch innerhalb des Compose-Netzwerks unter
"db:5432" erreichbar (für app/dev), nicht mehr vom Host oder von außen
– reduziert die Angriffsfläche, ohne dass sich am App-Betrieb etwas
ändert (Verbindung läuft ohnehin über den internen Service-Namen).
Zugriff für Debugging/Backups weiterhin über "docker compose exec db
psql"/"pg_dump" möglich.

Lokal verifiziert: App verbindet sich weiterhin erfolgreich (Migration/
Bootstrap/Login laufen durch), Verbindungsversuch vom Host auf Port
5433 wird jetzt abgelehnt (connection refused).

README.md an die entfallene Portangabe angepasst.
2026-07-18 14:25:55 +02:00
florian.wilde 536f51d516 DEPLOYMENT.md: Fehlerbehebung für "password authentication failed"
Beim Ausrollen auf einem echten Server aufgetreten: PostgreSQL setzt
sein Passwort nur beim allerersten Start auf leerem Datenverzeichnis –
lief "docker compose up" vorher schon einmal ohne "--env-file
.env.local" (oder wurde LOCAL_PG_PASSWORD nachträglich geändert),
bleibt im Volume ein anderes Passwort stehen als die App danach
verwendet.

Neue Fehlerbehebung-Sektion mit der konkreten Fehlermeldung und zwei
Fixes (Volume neu anlegen bei frischem Deployment, sonst Passwort im
laufenden Container angleichen). Warnhinweis in Schritt 3 ergänzt.

.env.example: Hinweis, dass LOCAL_PG_PASSWORD nur aus Buchstaben/
Zahlen bestehen sollte (#, $, Leerzeichen, Anführungszeichen können
beim .env-Parsing zu stillen Fehlern führen).
2026-07-18 14:20:01 +02:00
florian.wilde 3b8f7de5e4 Merge feature/v1.6: RSS-Feed, Vorschläge-Badge, Deployment-Anleitung
RSS-Feed des Speiseplans (/feed.xml, ein Eintrag pro Tag) mit
Autodiscovery-Link und sichtbarem Icon im Header. Anzahl offener
Rezeptvorschläge als Badge in der internen Navigation. Letzte
Supabase-Erwähnungen aus aktiver Dokumentation entfernt. Neues
DEPLOYMENT.md mit Schritt-für-Schritt-Anleitung für einen neuen Server.
2026-07-18 12:32:18 +02:00
florian.wilde c986327c1c Version 1.6.0
Verifiziert: RSS-Feed (/feed.xml, gültiges RSS 2.0, korrekte Tages-
Einträge inkl. Essenszeiten/Preisen), Autodiscovery-Link + sichtbares
Icon auf Desktop und Mobile, Vorschläge-Badge (Anzahl offener
Rezeptvorschläge, aktualisiert bei Navigation), keine verbliebenen
Supabase-Erwähnungen in aktiver Doku/Kommentaren.
2026-07-18 12:26:20 +02:00
florian.wilde 65bfd931cc Schritt-für-Schritt-Deployment-Anleitung für neuen Server
Neues DEPLOYMENT.md: Voraussetzungen, Repository holen, .env.local
konfigurieren, docker compose up --build, Admin-Zugangsdaten aus
ZUGANGSDATEN.md abholen, Reverse-Proxy/HTTPS (Caddy-Beispiel), Updates,
Backup der Docker-Volumes. Aus README.md verlinkt.

.env.example: LOCAL_PG_PASSWORD war bisher nirgends dokumentiert,
obwohl docker-compose.yml die DATABASE_URL des App-Containers daraus
zusammenbaut (die dortige DATABASE_URL-Zeile wird beim
Docker-Compose-Betrieb ignoriert) – beim Schreiben der Anleitung
aufgefallen und ergänzt.

README.md: RSS-Feed und Vorschläge-Badge in der Funktionstabelle
ergänzt.
2026-07-18 12:23:46 +02:00
florian.wilde 6f78762e27 Letzte Supabase-Erwähnungen aus aktiver Doku/Kommentaren entfernt
README.md, db/migrations/0001_init.sql, lib/auth/webauthn-config.ts,
lib/branding.ts nannten Supabase noch in Kommentaren, die erklären,
was diese Stellen ersetzen. Umformuliert, ohne den alten Anbieter beim
Namen zu nennen. CHANGELOG.md bleibt unverändert – die Erwähnungen dort
sind historischer Bestand (u. a. der v1.5.0-Eintrag über die
Supabase-Ablösung selbst) und sollen als Zeitdokument stehen bleiben.
2026-07-18 12:21:21 +02:00
florian.wilde 08a5ef8a2b v1.6: RSS-Feed für den Speiseplan + Badge für offene Rezeptvorschläge
app/feed.xml: RSS-2.0-Feed mit einem Eintrag pro Tag (aktuelle +
nächste Woche), Gerichte je Essenszeit im CDATA-Beschreibungstext.
Immer auf Deutsch, da Feedreader kein Sprach-Cookie mitschicken.

Autodiscovery-Link im <head> (app/layout.tsx) sowie sichtbares
RSS-Icon im Header der öffentlichen Speisekarte (neben Sprachumschalter/
Anmelden – die einzige Stelle, die auf allen Bildschirmgrößen sichtbar
ist).

AppHeader: "Vorschläge"-Navigationspunkt zeigt jetzt die Anzahl
offener Rezeptvorschläge als Zahl im roten Kreis (GET
/api/dish-suggestions, bereits staff/admin-beschränkt), aktualisiert
sich bei Navigation innerhalb des internen Bereichs.
2026-07-18 12:18:46 +02:00
80 changed files with 5586 additions and 1202 deletions
+38 -1
View File
@@ -1,7 +1,32 @@
# Vorlage für .env.local (Entwicklung) bzw. die Umgebungsvariablen
# beim Hoster (Live-Betrieb). Echte Werte niemals committen!
# Verbindung zur (selbst gehosteten) PostgreSQL-Datenbank.
# Nur für Docker Compose relevant: Passwort für den mitgelieferten
# PostgreSQL-Container (Dienst "db" in docker-compose.yml). Daraus baut
# docker-compose.yml die DATABASE_URL für die App-Container selbst
# zusammen – die Zeile unten wird beim Betrieb per Docker Compose
# NICHT verwendet, nur bei manuellem "npm run dev"/"npm run start"
# gegen eine selbst betriebene PostgreSQL-Instanz.
#
# Nur Buchstaben/Zahlen verwenden (z. B. mit "openssl rand -hex 24"
# erzeugen) – Zeichen wie #, $, Leerzeichen oder Anführungszeichen
# werden von Docker Compose beim Einlesen von .env-Dateien speziell
# behandelt (# beginnt z. B. einen Kommentar) und können das Passwort
# unbemerkt verstümmeln.
#
# WICHTIG: PostgreSQL setzt dieses Passwort nur EINMALIG beim allerersten
# Start auf einem leeren Datenverzeichnis. Wird der Wert hier später
# geändert (oder "docker compose up" auch nur einmal ohne
# "--env-file .env.local" ausgeführt, bevor er gesetzt war), bleibt im
# bereits angelegten Docker-Volume das alte Passwort stehen – die App
# bekommt dann "password authentication failed for user postgres". Fix:
# "docker compose down && docker volume rm canteen-menu_pgdata", dann
# neu starten (nur unbedenklich, solange noch keine echten Daten drin
# sind – siehe DEPLOYMENT.md).
LOCAL_PG_PASSWORD=<beliebiges-sicheres-passwort>
# Verbindung zur PostgreSQL-Datenbank (nur für den Betrieb OHNE Docker
# Compose relevant, s. o.).
DATABASE_URL=postgres://postgres:<passwort>@localhost:5432/canteen_menu
# Öffentliche URL, unter der die App erreichbar ist (Schema + Domain).
@@ -23,3 +48,15 @@ ADMIN_EMAIL=
# Free-Keys enden auf ":fx"). Ohne Key bleibt "Automatisch übersetzen"
# inaktiv; Englisch/Polnisch lassen sich weiterhin manuell eintragen.
DEEPL_API_KEY=
# Optional: SSO-Login per OpenID Connect (z. B. gegen ein selbst
# gehostetes Authentik). Ohne diese drei Werte bleibt der SSO-Button auf
# der Anmeldeseite ausgeblendet, Passwort/Passkey funktionieren
# unverändert. SSO-Login funktioniert NUR für bereits unter "Benutzer"
# angelegte E-Mail-Adressen – es werden keine neuen Konten angelegt und
# die Rolle (Admin/Kantinenmitarbeiter) bleibt ausschließlich dort
# steuerbar. Beim Identity Provider als Redirect-URI eintragen:
# <APP_URL>/api/auth/oidc/callback
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
+272
View File
@@ -4,6 +4,278 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert.
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
Versionierung nach [Semantic Versioning](https://semver.org/lang/de/).
## [1.12.1] – 2026-07-20
### Geändert
- Bedienung geschlossener Tage im Wochenplan überarbeitet: Statt eines
Text-Links am Datum markiert jetzt ein Schloss-Symbol (🔓 zum
Schließen, 🔒 zeigt „geschlossen" an und öffnet per Klick wieder) den
Tag. Ein geschlossener Tag zeigt sowohl im Wochenplan als auch auf
der öffentlichen Speisekarte (Desktop-Tabelle) einen einzigen, über
alle Essenszeiten-Zeilen durchgehenden „Geschlossen"-Hinweis statt
einer Wiederholung in jeder einzelnen Zeile.
## [1.12.0] – 2026-07-20
### Hinzugefügt
- Einzelne Tage als geschlossen markierbar (z. B. Feiertage,
Betriebsausflug) – im Wochenplan lässt sich pro Tag über „Tag
schließen" ein optionaler, mehrsprachiger Grund hinterlegen (inkl.
automatischer Übersetzung per DeepL, sofern konfiguriert). Ein
geschlossener Tag:
- blendet in der Gerichte-Verwaltung des Wochenplans „Mahlzeit
hinzufügen" für diesen Tag aus (bestehende Einträge lassen sich
weiterhin entfernen), lässt sich per „Wieder öffnen" jederzeit
rückgängig machen.
- zeigt auf der öffentlichen Speisekarte (Desktop-Tabelle und
mobile Ansicht) einen „Geschlossen"-Hinweis mit Grund statt der
Essenszeiten-Spalten/-Karte für diesen Tag.
- erscheint im RSS-Feed als eigener Eintrag mit Schließungsgrund
statt eines leeren oder fehlenden Tages.
- Neue Tabellen `closed_days`/`closed_day_translations` (Migration
`0007_closed_days.sql`).
## [1.11.1] – 2026-07-20
### Behoben
- Wurde Deutsch im Sprachen-Admin deaktiviert (z. B. um die App auf nur
Englisch zu reduzieren), blieb die gesamte App trotzdem auf Deutsch
stehen, sowohl im öffentlichen als auch im internen Bereich: `getLocale()`
fiel bei fehlendem/ungültigem Sprach-Cookie bisher immer fest auf
Deutsch zurück, unabhängig davon, ob Deutsch überhaupt noch aktiv war.
Da der Sprachumschalter sich bei nur einer aktiven Sprache zusätzlich
automatisch ausblendet, gab es keine Möglichkeit, das über die
Oberfläche zu korrigieren. Der Fallback nutzt jetzt Deutsch nur noch,
wenn es tatsächlich aktiv ist – sonst die erste aktive Sprache.
## [1.11.0] – 2026-07-20
### Hinzugefügt
- Speiseplan-Inhalte variabel gemacht (Teil 2 des "mehrsprachig
variabel"-Umbaus): Gerichtnamen, Essenszeiten-Bezeichnungen sowie
Allergen-/Zusatzstoff-Namen sind jetzt genauso dynamisch mehrsprachig
wie die Bedienoberfläche aus Version 1.10.0, statt auf feste
Englisch-/Polnisch-Spalten begrenzt zu sein:
- In den Verwaltungsseiten (Gerichte, Essenszeiten, Allergene &
Zusatzstoffe) erscheint jetzt automatisch ein Eingabefeld je aktiv
geschalteter Sprache – wird im Sprachen-Admin eine neue Sprache
angelegt, taucht dort sofort ein passendes Feld auf, ohne
Codeänderung.
- Der "Automatisch übersetzen"-Button (DeepL) übersetzt künftig in
alle aktiven, von DeepL unterstützten Sprachen statt fix nur
Englisch/Polnisch.
- CSV-Export/-Import für Gerichte, Allergene und Zusatzstoffe hat
jetzt eine Spalte je aktiver Sprache (`name_en`, `name_fr`, …)
statt der bisher fest verdrahteten `name_en`/`name_pl`-Spalten.
- Reduziert der Admin das Sprachenfeature im Sprachen-Bereich auf nur
Deutsch, verschwinden diese Zusatzfelder ebenfalls automatisch aus
allen drei Verwaltungsseiten.
### Geändert
- Neue Migration `0006_content_translations.sql` ersetzt die festen
Spalten `name_en`/`name_pl` (Gerichte, Allergene, Zusatzstoffe) bzw.
`label_en`/`label_pl` (Essenszeiten) durch je eine
Übersetzungstabelle (`dish_translations`, `section_translations`,
`allergen_translations`, `additive_translations`); bestehende
Übersetzungen werden dabei automatisch übernommen.
**Vor dem Einspielen auf einem Produktivsystem unbedingt ein Backup
der Datenbank anlegen** – die alten Spalten werden nach der
Übernahme entfernt.
## [1.10.0] – 2026-07-20
### Hinzugefügt
- Dynamisches Sprachpaket-System für die Bedienoberfläche (Teil 1 des
"mehrsprachig variabel"-Umbaus, Speiseplan-Inhalte folgen in einer
späteren Version). Sprachen sind jetzt zur Laufzeit über eine neue
Admin-Seite „Sprachen" verwaltbar statt fest im Code zu stehen:
- Vorlage der aktuellen deutschen Texte herunterladen, übersetzt
wieder hochladen – neue Übersetzungen werden sofort aktiv, ohne
Deployment.
- Fehlende Begriffe fallen automatisch auf Deutsch zurück statt leer
zu bleiben; die Admin-Seite zeigt pro Sprache Vollständigkeit in %
sowie „N neue Begriffe seit letztem Import", falls eine neue
App-Version zusätzliche Texte eingeführt hat.
- Sprachen lassen sich einzeln deaktivieren, bis auf eine einzige
reduzierbar (der Sprachumschalter blendet sich dann automatisch
aus) – mindestens eine muss aktiv bleiben.
- Neue Sprachen lassen sich direkt anlegen (Code + Anzeigename),
Deutsch bleibt als einzige garantiert vollständige Basis-Sprache
unlöschbar.
- Import validiert hochgeladene Übersetzungen gegen die aktuelle
Vorlage: Platzhalter wie `{message}` müssen erhalten bleiben,
unbekannte Schlüssel werden ignoriert statt Fehler zu werfen.
- Neue Tabellen `languages`/`ui_translations` (Migration `0004`),
bestehende Englisch-/Polnisch-Texte automatisch übernommen
(Migration `0005`) – keine manuelle Nacharbeit nötig.
## [1.9.5] – 2026-07-20
### Hinzugefügt
- Interner Bereich (Wochenplan, Essenszeiten, Gerichte, Allergene &
Zusatzstoffe, Vorschläge, Benutzer, Design, Mein Konto) für
Mobilgeräte nutzbar gemacht. `AppHeader.tsx` bekommt unterhalb der
`sm`-Breakpoint-Grenze ein Hamburger-Menü mit denselben Links
gestapelt statt der bisherigen, bei schmalen Bildschirmen unübersichtlich
umbrechenden einzeiligen Navigation. Der Wochenplan (`WeekEditor.tsx`)
zeigt auf Mobilgeräten eine Tag-für-Tag-Ansicht mit Tab-Leiste
(Mo–Fr) statt der für 375px viel zu schmalen 6-spaltigen Tabelle –
Gericht hinzufügen/löschen funktioniert unverändert, da beide
Ansichten denselben Bearbeitungs-Zustand teilen. In DishManager,
CodeListManager, UserManager, SectionManager und PasskeyManager
brechen Kopf- und Listenzeilen jetzt bei Bedarf um, statt Buttons
abzuschneiden.
## [1.9.0] – 2026-07-20
### Hinzugefügt
- SSO-Login per OpenID Connect (z. B. gegen ein selbst gehostetes
Authentik), zusätzlich zu Passwort und Passkey. Konfiguration über
drei optionale Env-Vars (`OIDC_ISSUER`, `OIDC_CLIENT_ID`,
`OIDC_CLIENT_SECRET`) – ohne sie bleibt der SSO-Button auf der
Anmeldeseite ausgeblendet. Authorization-Code-Flow mit PKCE, ID-Token-
Signaturprüfung gegen das JWKS des Providers (neue Abhängigkeit
`jose`), State/Nonce serverseitig kurzlebig und einmalig verwendbar
(neue Tabelle `oidc_states`, Migration `0003_oidc_state.sql`).
SSO-Login funktioniert ausschließlich für bereits unter „Benutzer“
angelegte E-Mail-Adressen – es werden nie neue Konten angelegt, und
die Rolle (Admin/Kantinenmitarbeiter) bleibt ausschließlich über die
bestehende Benutzerverwaltung steuerbar, nie aus SSO-Claims
übernommen. In DEPLOYMENT.md unter „SSO/OpenID Connect“ dokumentiert.
## [1.8.1] – 2026-07-20
### Hinzugefügt
- `update.sh`: Update-Skript für Linux-Deployments – stoppt den Docker-
Stack, sichert lokale Änderungen an `docker-compose.yml` per
`git stash` (falls vorhanden), führt `git pull` aus, spielt die
Änderungen zurück und startet den Stack neu im Build-Modus. Bricht
bei einem `git pull`- oder Stash-Konflikt kontrolliert ab, statt mit
einem halb aktualisierten Stand weiterzumachen. In DEPLOYMENT.md unter
„Updates einspielen“ dokumentiert.
## [1.8.0] – 2026-07-19
### Hinzugefügt
- Umschaltbarer Hinweismodus für Allergene/Zusatzstoffe: Auf der Seite
„Allergene & Zusatzstoffe“ kann zwischen der bisherigen detaillierten
Auswahl je Gericht und einem einfachen, in Deutsch/Englisch/Polnisch
editierbaren Hinweistext (z. B. „Bei Fragen zu Allergenen und
Zusatzstoffen wenden Sie sich bitte an das Kantinen-Team.“)
umgeschaltet werden. Im Hinweismodus entfällt auf der öffentlichen
Speisekarte die Legende und die Kürzel an den Gerichten, und in der
Gerichte-Verwaltung wird die Allergen-/Zusatzstoff-Auswahl beim
Bearbeiten ausgeblendet (bereits zugeordnete Daten bleiben in der
Datenbank erhalten). Umschalten und Hinweistext bearbeiten dürfen
sowohl Kantinenpersonal als auch Admins.
## [1.7.0] – 2026-07-19
### Hinzugefügt
- CSV-Import/-Export für Gerichte, Allergene und Zusatzstoffe (Buttons
„Exportieren“/„Importieren“ auf den jeweiligen Verwaltungsseiten).
Export lädt die aktuelle Liste als CSV herunter (Gerichte:
`name,name_en,name_pl,price,allergens,additives`, Allergene/
Zusatzstoffe: `code,name,name_en,name_pl`; Kürzel bei Gerichten
Semikolon-getrennt in einer Zelle, z. B. `A;G`). Import liest dieselbe
Spaltenstruktur (Reihenfolge egal, Groß-/Kleinschreibung der
Kopfzeile wird ignoriert) und aktualisiert bestehende Einträge
(Abgleich über Kürzel bei Allergenen/Zusatzstoffen, über den exakten
Namen bei Gerichten) bzw. legt neue an. Zeilen mit fehlenden
Pflichtfeldern, ungültigem Preis oder mehrdeutigem Gerichtsnamen
(mehrere Gerichte mit demselben Namen) werden übersprungen und mit
Grund in der Erfolgsmeldung aufgelistet, statt den ganzen Import
abzubrechen.
### Behoben
- RSS-Feed (`/feed.xml`): Mehrere Feed-Reader (u. a. Outlook,
Thunderbird) behandeln `<description>` als kurze Vorschau und kürzen
sie eigenmächtig – dadurch war bei Tagen mit mehreren Essenszeiten
oft nur die erste sichtbar, obwohl der Server bereits alle
Essenszeiten des Tages in der Beschreibung lieferte. Zusätzlich zu
`<description>` wird jetzt auch `<content:encoded>` (RSS-Content-
Modul) mit demselben vollständigen Inhalt ausgeliefert – von den
meisten Readern als vollständiger Artikeltext erkannt und ungekürzt
angezeigt.
## [1.6.3] – 2026-07-18
### Behoben
- `docker-compose.yml`: Der `app`-Dienst setzte `BRANDING_DIR` nicht, sodass
`lib/branding.ts` beim Hochladen von Logo/Favicon auf den Fallback-Pfad
`${cwd}/data/branding` (= `/app/data/branding`) statt auf das im
Dockerfile vorbereitete, gemountete Verzeichnis `/data/branding` auswich.
`/app` gehört im Container `root`, der App-Prozess läuft aber als
nicht-root-Nutzer `nextjs` – das Anlegen des Fallback-Verzeichnisses
scheiterte mit `EACCES`, der Upload endete mit „Upload fehlgeschlagen:
500". Jetzt wird `BRANDING_DIR=/data/branding` explizit gesetzt, passend
zum Volume-Mount und zum in `Dockerfile` per `chown` freigegebenen
Verzeichnis.
## [1.6.2] – 2026-07-18
### Behoben
- `scripts/bootstrap-admin.mjs`: Konnte `ZUGANGSDATEN.md` nicht
geschrieben werden (z. B. `EACCES`, weil die per Bind-Mount
eingebundene Host-Datei root gehört und der App-Container als
nicht-root läuft), brach das Skript ab, nachdem der Admin-Benutzer
bereits in der Datenbank angelegt war – das generierte Passwort war
dann nirgends mehr zu finden. Schreibt jetzt bei einem Fehler
stattdessen E-Mail/Passwort ins Log und beendet sich weiterhin
erfolgreich.
- DEPLOYMENT.md: Schritt 2 fehlte `chmod 666 ZUGANGSDATEN.md` (der
eigentliche Auslöser des Bugs oben). Neue Fehlerbehebung-Einträge für
`ECONNREFUSED` beim Start (harmlose Start-Reihenfolge, löst sich
durch den automatischen Neustart von selbst) und für diesen
`EACCES`-Fall inkl. Recovery-Anleitung für einen bereits verwaisten
Admin-Account.
## [1.6.1] – 2026-07-18
### Geändert
- PostgreSQL-Container ist nicht mehr über einen Host-Port erreichbar
(`docker-compose.yml`: `ports: 5433:5432` beim `db`-Dienst entfernt).
App und Dev-Server erreichen die Datenbank weiterhin über das von
Docker Compose automatisch angelegte interne Netzwerk (Hostname
`db`) – reduziert die Angriffsfläche, ohne den Betrieb zu ändern.
Debugging/Backup weiterhin über `docker compose exec db psql`/
`pg_dump` möglich.
### Behoben
- DEPLOYMENT.md: Fehlerbehebung-Abschnitt für
`password authentication failed for user "postgres"` ergänzt (tritt
auf, wenn `docker compose up` vor dem Setzen von `LOCAL_PG_PASSWORD`
läuft oder ohne `--env-file .env.local` – PostgreSQL übernimmt das
Passwort nur beim allerersten Start auf leerem Datenverzeichnis).
`.env.example` warnt jetzt vor Sonderzeichen in `LOCAL_PG_PASSWORD`,
die beim `.env`-Parsing zu stillen Fehlern führen können.
## [1.6.0] – 2026-07-18
### Hinzugefügt
- RSS-Feed des Speiseplans (`/feed.xml`, ein Eintrag pro Tag mit den
Gerichten je Essenszeit für die aktuelle und die nächste Woche) zum
Abonnieren in einem Feedreader. Autodiscovery-Link im Seiten-`<head>`
sowie sichtbares RSS-Icon im Kopfbereich der öffentlichen
Speisekarte.
- Kantinen-Bereich: „Vorschläge“-Navigationspunkt zeigt jetzt die
Anzahl offener Rezeptvorschläge als Zahl im roten Kreis.
- Neues [DEPLOYMENT.md](DEPLOYMENT.md): Schritt-für-Schritt-Anleitung
für die Inbetriebnahme auf einem neuen Server (Docker Compose,
Konfiguration, Reverse-Proxy/HTTPS, Updates, Backup).
### Geändert
- Letzte Erwähnungen von Supabase in aktiven Doku-Texten und
Code-Kommentaren entfernt/umformuliert (README, Migrationsschema,
Passkey-Konfiguration, Branding-Ablage) – die App läuft seit v1.5
vollständig ohne Supabase, das sollte sich jetzt auch durchgängig in
der Dokumentation widerspiegeln. Historische CHANGELOG-Einträge
bleiben unverändert.
- `.env.example`: `LOCAL_PG_PASSWORD` (von `docker-compose.yml`
verwendet, um die Datenbank-Verbindung der App-Container
zusammenzubauen) war bisher nirgends dokumentiert – beim Schreiben
der Deployment-Anleitung aufgefallen und ergänzt.
## [1.5.0] – 2026-07-18
### Hinzugefügt
+276
View File
@@ -0,0 +1,276 @@
# Deployment auf einem neuen Server
Schritt-für-Schritt-Anleitung, um Canteen Menu auf einem frischen
(Linux-)Server produktiv zu betreiben. Die App läuft vollständig selbst
gehostet (PostgreSQL + eigene Auth), einzige optionale externe
Abhängigkeit ist DeepL für automatische Übersetzungen.
## Voraussetzungen
- Ein Server (VM oder Root-Server) mit Docker und Docker Compose v2
(`docker compose version` sollte funktionieren; auf den meisten
aktuellen Distributionen per `apt install docker.io
docker-compose-v2` bzw. dem [offiziellen Docker-Install-Skript](https://docs.docker.com/engine/install/)
installierbar).
- Eine Domain, deren DNS-A/AAAA-Eintrag auf die Server-IP zeigt (z. B.
`menu.example.de`). Ohne eigene Domain funktioniert die App auch
unter der reinen Server-IP, dann aber ohne gültiges HTTPS-Zertifikat
und ohne funktionierende Passkeys (siehe unten).
- Mindestens 1 GB RAM frei (App + PostgreSQL sind genügsam, für ~250
Mitarbeiter reicht die kleinste übliche VM-Größe).
## 1. Repository auf den Server holen
```bash
git clone https://github.com/<dein-fork-oder-repo>/canteen-menu.git
cd canteen-menu
```
(Oder per `git pull`/`scp`, falls kein direkter Git-Zugriff vom Server
aus gewünscht ist.)
## 2. Konfiguration anlegen
```bash
cp .env.example .env.local
```
`.env.local` mit einem Editor öffnen und ausfüllen:
| Variable | Was rein muss |
| --- | --- |
| `LOCAL_PG_PASSWORD` | Ein neues, zufälliges Passwort **nur aus Buchstaben/Zahlen** (z. B. `openssl rand -hex 24`) – wird beim ersten Start zum Datenbank-Passwort des mitgelieferten PostgreSQL-Containers. Zeichen wie `#`, `$`, Leerzeichen oder Anführungszeichen können beim Einlesen der `.env`-Datei zu Problemen führen (`#` beginnt z. B. einen Kommentar) |
| `APP_URL` | Die öffentliche URL mit Schema, z. B. `https://menu.example.de` (auch wenn HTTPS erst in Schritt 5 eingerichtet wird – schon jetzt korrekt eintragen, da daraus u. a. die Passkey-Domain abgeleitet wird) |
| `SITE_TITLE` | Optional, Titel im Browser-Tab/Überschrift ohne Logo |
| `ADMIN_EMAIL` | Optional, E-Mail des automatisch angelegten Admin-Erstnutzers. Ohne Angabe: `admin@<Hostname aus APP_URL>` |
| `DEEPL_API_KEY` | Optional, nur für automatische Übersetzung |
`DATABASE_URL` in `.env.local` **nicht** anpassen – die wird nur für
den Betrieb ohne Docker gebraucht (siehe README) und beim
Docker-Compose-Start ignoriert.
Zusätzlich eine leere Datei für die automatisch generierten
Admin-Zugangsdaten anlegen, da sie per Bind-Mount in den Container
eingebunden wird. Der App-Container läuft als nicht-root-Nutzer –
damit er die Datei beschreiben darf, muss sie für alle schreibbar sein:
```bash
touch ZUGANGSDATEN.md
chmod 666 ZUGANGSDATEN.md
```
## 3. App starten
**Wichtig:** `.env.local` muss vollständig ausgefüllt sein, *bevor*
dieser Befehl zum ersten Mal läuft – und danach immer mit
`--env-file .env.local` aufgerufen werden. PostgreSQL setzt sein
Passwort nur beim allerersten Start auf einem leeren Datenverzeichnis;
läuft der Befehl vorher schon einmal ohne (oder mit einem anderen)
`--env-file`, landet ein falsches Passwort dauerhaft im Datenbank-Volume
(Symptom: `password authentication failed for user "postgres"` in den
App-Logs, siehe [Fehlerbehebung](#fehlerbehebung)).
```bash
docker compose --env-file .env.local up -d --build
```
Das baut das Docker-Image, startet PostgreSQL und die App, wendet beim
ersten Start automatisch das Datenbankschema an und legt – da die
Datenbank leer ist – einen einzigen Admin-Benutzer mit zufällig
generiertem Passwort an.
Kurz prüfen, ob beides läuft:
```bash
docker compose ps
docker compose logs app --tail 30
```
In den Logs sollte u. a. `bootstrap-admin – Admin angelegt: ...`
stehen.
## 4. Admin-Zugangsdaten abholen
```bash
cat ZUGANGSDATEN.md
```
Enthält E-Mail-Adresse und Passwort des automatisch angelegten
Admin-Kontos. Direkt nach dem ersten Login unter „Mein Konto“ das
Passwort ändern und optional einen Passkey registrieren.
Die App ist jetzt unter `http://<server-ip>:3000` erreichbar – für den
produktiven Betrieb fehlt noch HTTPS/die Domain (nächster Schritt).
## 5. Reverse-Proxy + HTTPS einrichten
Die App selbst spricht nur HTTP auf Port 3000; TLS/HTTPS übernimmt ein
Reverse-Proxy davor. Am einfachsten mit [Caddy](https://caddyserver.com/)
(automatisches Let’s-Encrypt-Zertifikat, keine manuelle Zertifikats-
verwaltung nötig):
```bash
sudo apt install caddy # oder: siehe caddyserver.com/docs/install
```
`/etc/caddy/Caddyfile`:
```
menu.example.de {
reverse_proxy localhost:3000
}
```
```bash
sudo systemctl reload caddy
```
Caddy holt beim ersten Aufruf automatisch ein Zertifikat und leitet
danach HTTP→HTTPS um. Alternative: nginx + certbot, falls bereits ein
nginx-Setup existiert (Standard-`proxy_pass`-Konfiguration auf
`http://127.0.0.1:3000`, Zertifikat separat per `certbot --nginx`).
Danach in `.env.local` sicherstellen, dass `APP_URL=https://menu.example.de`
gesetzt ist (Schema `https`, keine Portangabe), und die App neu
starten, falls diese Variable nach Schritt 2 noch geändert wurde:
```bash
docker compose --env-file .env.local up -d
```
## 6. Danach
- Passkeys funktionieren erst mit korrekt gesetzter `APP_URL` (RP-Domain
wird direkt daraus abgeleitet) und über HTTPS (Passkeys verlangen eine
sichere Origin, `localhost` ausgenommen).
- Weitere Kantinenmitarbeiter/Admins unter „Benutzer“ (nur als Admin
sichtbar) anlegen.
- Essenszeiten, Gerichte, Allergene/Zusatzstoffe sind zu Beginn leer und
werden im Kantinen-Bereich gepflegt.
- Logo/Favicon/Farben unter „Design“ (nur Admin) anpassen.
## SSO/OpenID Connect (optional)
Zusätzlich zu Passwort und Passkey kann ein SSO-Login gegen einen
beliebigen OpenID-Connect-Provider aktiviert werden (z. B. ein selbst
gehostetes [Authentik](https://goauthentik.io/)). Dazu in `.env.local`
setzen:
```bash
OIDC_ISSUER=https://authentik.example.de/application/o/canteen-menu/
OIDC_CLIENT_ID=<client-id-aus-authentik>
OIDC_CLIENT_SECRET=<client-secret-aus-authentik>
```
Beim Identity Provider als Redirect-URI hinterlegen:
`<APP_URL>/api/auth/oidc/callback` (also z. B.
`https://menu.example.de/api/auth/oidc/callback`).
**Wichtig:** SSO-Login funktioniert ausschließlich für E-Mail-Adressen,
die bereits unter „Benutzer“ angelegt sind – es werden nie neue Konten
angelegt, und die Rolle (Admin/Kantinenmitarbeiter) bleibt ausschließlich
dort steuerbar. Ein Mitarbeiter muss also zuerst ganz normal als
Benutzer angelegt werden, bevor er sich per SSO anmelden kann.
Ohne die drei Variablen bleibt der SSO-Button auf der Anmeldeseite
einfach ausgeblendet, Passwort/Passkey funktionieren unverändert.
## Fehlerbehebung
### `password authentication failed for user "postgres"` in den App-Logs
Ursache: Das Docker-Volume der Datenbank wurde bereits mit einem
anderen Passwort initialisiert, als aktuell in `.env.local` steht (z. B.
weil `docker compose up` einmal ohne `--env-file .env.local` lief, bevor
das Passwort gesetzt war, oder `LOCAL_PG_PASSWORD` nachträglich geändert
wurde – PostgreSQL übernimmt das Passwort nur beim allerersten Start).
Fix, solange noch keine echten Daten in der Datenbank sind (bei einem
frischen Deployment immer der Fall):
```bash
docker compose --env-file .env.local down
docker volume rm canteen-menu_pgdata
docker compose --env-file .env.local up -d --build
docker compose logs app --tail 30
```
Existieren bereits echte Daten, stattdessen das Passwort direkt im
laufenden Datenbank-Container an `.env.local` angleichen, statt das
Volume zu löschen:
```bash
docker compose exec db psql -U postgres -c "ALTER USER postgres WITH PASSWORD '<Wert aus LOCAL_PG_PASSWORD>';"
docker compose --env-file .env.local up -d
```
### `Error: connect ECONNREFUSED ...:5432` beim Start
Normal beim allerersten Start: Der App-Container versucht sich zu
verbinden, bevor PostgreSQL im `db`-Container fertig hochgefahren ist.
Dank `restart: unless-stopped` startet der App-Container automatisch
neu und verbindet sich, sobald die Datenbank bereit ist (kann ein paar
Sekunden bis wenige Minuten dauern). Mit `docker compose logs app -f`
verfolgen – taucht der Fehler dauerhaft (mehrere Minuten) weiter auf,
`docker compose ps` prüfen, ob der `db`-Container tatsächlich dauerhaft
läuft.
### `EACCES: permission denied, open '/app/ZUGANGSDATEN.md'`
Die Datei wurde ohne Schreibrecht für alle angelegt (Schritt 2 oben,
`chmod 666` fehlte). Der Admin-Benutzer wurde in diesem Fall trotzdem
in der Datenbank angelegt, nur die Zugangsdaten konnten nicht in die
Datei geschrieben werden – sie stehen dann stattdessen im Log
(`docker compose logs app` nach `bootstrap-admin – Admin-Passwort:`
durchsuchen). Für zukünftige Deployments zusätzlich die Berechtigung
korrigieren:
```bash
chmod 666 ZUGANGSDATEN.md
```
Falls das Passwort auch im Log nicht mehr auffindbar ist (z. B. weil
Logs schon rotiert wurden), Admin-Konto neu anlegen lassen:
```bash
docker compose exec db psql -U postgres -d canteen_menu -c "delete from users;"
chmod 666 ZUGANGSDATEN.md
docker compose restart app
docker compose logs app --tail 20
cat ZUGANGSDATEN.md
```
## Updates einspielen
```bash
git pull
docker compose --env-file .env.local up -d --build
```
Neue Datenbank-Migrationen (`db/migrations/*.sql`) werden beim Start
automatisch und idempotent angewendet, bestehende Daten bleiben
erhalten.
Alternativ übernimmt `./update.sh` den kompletten Ablauf: Stack stoppen,
lokale Änderungen an `docker-compose.yml` per `git stash` sichern (falls
vorhanden), `git pull`, Änderungen zurückspielen, Stack neu bauen und
starten:
```bash
./update.sh
```
## Backup
Zwei Docker-Volumes enthalten alle persistenten Daten:
- `canteen-menu_pgdata` – die komplette PostgreSQL-Datenbank
- `canteen-menu_branding_data` – hochgeladenes Logo/Favicon
```bash
docker run --rm -v canteen-menu_pgdata:/data -v "$PWD":/backup alpine \
tar czf /backup/pgdata-backup.tar.gz -C /data .
```
Für ein reines Datenbank-Backup eignet sich auch `docker compose exec db
pg_dump -U postgres canteen_menu > backup.sql`.
+305
View File
@@ -0,0 +1,305 @@
# Was kann die Speisekarte?
Vorstellung aller Funktionen der digitalen Kantinen-Speisekarte
(Stand Version 1.12.1). Technische Details zur Installation stehen in
[DEPLOYMENT.md](DEPLOYMENT.md), die Änderungen je Version in
[CHANGELOG.md](CHANGELOG.md).
## In einem Satz
Eine selbst gehostete Web-Anwendung, mit der das Kantinen-Team den
Wochenplan pflegt und alle Mitarbeiter ihn ohne Login am PC oder Handy
lesen können – dreisprachig, mit Allergen-Kennzeichnung, RSS-Abo,
Rezeptvorschlägen und eigenem Branding.
Drei Bereiche mit getrennten Rechten:
| Bereich | Wer | Zweck |
| --- | --- | --- |
| Öffentliche Speisekarte (`/`) | alle Mitarbeiter, ohne Login | Wochenplan lesen |
| Kantinen-Bereich (`/intern`) | Kantinen-Team (`staff`) und Admins | Gerichte und Wochenplan pflegen |
| Admin-Bereich (`/admin`) | nur Admins | Benutzer, Design, Sprachen |
---
## 1. Für alle Mitarbeiter: die öffentliche Speisekarte
Erreichbar ohne Anmeldung – Link oder QR-Code genügt.
**Wochenplan Montag bis Freitag**
Tabelle mit einer Spalte je Wochentag und einer Zeile je Essenszeit
(z. B. Brotzeit, Mittagessen, Mittagessen vegetarisch, Suppe). Pro
Zelle können mehrere Gerichte stehen. Jedes Gericht wird mit Preis
angezeigt.
**Navigation**
Buttons für vorherige Woche, nächste Woche und „Heute". Beliebige
Wochen lassen sich so vor- und zurückblättern – auch als direkter Link
(`/?woche=YYYY-MM-DD`), praktisch für Aushänge oder Bookmarks.
**Handy-Ansicht**
Auf schmalen Bildschirmen erscheint standardmäßig eine Tagesansicht mit
Tab-Leiste (Mo–Fr) statt der Tabelle; auf Wunsch kann pro Besucher auf
die Wochenansicht umgeschaltet werden. Die Desktop-Tabelle bleibt davon
unberührt.
**Allergene und Zusatzstoffe**
Je Gericht erscheinen die Kürzel (z. B. `A`, `G`) direkt neben dem
Namen, die vollständige Legende steht unterhalb der Karte. Alternativ
lässt sich die Kennzeichnung auf einen einzelnen Hinweistext umstellen
(siehe Abschnitt 2).
**Geschlossene Tage**
Feiertage, Betriebsausflüge oder Urlaub werden als „Geschlossen"
angezeigt – ein einziges, über alle Essenszeiten-Zeilen durchgehendes
Banner mit optionalem Grund („Betriebsferien"), statt leerer Zellen.
**Sprachumschalter**
Umschalten der kompletten Oberfläche zwischen den freigeschalteten
Sprachen (ausgeliefert mit Deutsch, Englisch, Polnisch). Die Auswahl
wird per Cookie gemerkt, die Seite muss dafür nicht neu geladen werden.
Ist nur eine Sprache aktiv, verschwindet der Umschalter automatisch.
**RSS-Feed zum Abonnieren**
`/feed.xml` liefert einen Eintrag pro Tag (aktuelle und nächste Woche)
mit allen Gerichten je Essenszeit inkl. Preis; geschlossene Tage
erscheinen mit Schließungsgrund. Sichtbares RSS-Icon im Kopfbereich
plus Autodiscovery im Seiten-`<head>`, damit Outlook, Thunderbird & Co.
den Feed automatisch finden. Der Inhalt wird zusätzlich als
`content:encoded` ausgeliefert, damit Reader ihn nicht nach der ersten
Essenszeit abschneiden.
**Rezept vorschlagen**
Jeder Besucher kann ohne Login ein fehlendes Gericht vorschlagen (Name
+ optionale Beschreibung). Eingebaut: Dublettenprüfung gegen bestehende
Gerichte und ein unsichtbares Honeypot-Feld als Spamschutz.
**Eigenes Erscheinungsbild**
Firmenlogo zentriert über der Karte, eigenes Favicon im Browser-Tab,
Farben nach Corporate Design – alles vom Admin einstellbar, ohne
Codeänderung.
---
## 2. Für das Kantinen-Team (`/intern`)
Nach dem Login sichtbar, gemeinsame Navigation für alle Unterseiten,
auf dem Handy als Hamburger-Menü.
### Wochenplan
- Gerichte per **Suchfeld** einer Zelle (Tag + Essenszeit) zuweisen –
keine langen Dropdown-Listen zum Durchscrollen.
- Mehrere Gerichte pro Zelle, einzeln wieder entfernbar.
- Wochen-Navigation inkl. „Heute"-Button wie in der öffentlichen Ansicht.
- **Tag schließen**: Schloss-Symbol am Datum (🔓 schließt, 🔒 öffnet
wieder) mit optionalem, mehrsprachigem Grund. Für geschlossene Tage
wird „Mahlzeit hinzufügen" ausgeblendet; bereits eingetragene
Gerichte bleiben entfernbar.
- Auf dem Handy Tag-für-Tag-Ansicht mit Tab-Leiste; beide Ansichten
teilen denselben Bearbeitungsstand.
### Gerichte
- Anlegen, bearbeiten, löschen – mit Name, Preis, Allergenen und
Zusatzstoffen (Chip-Auswahl).
- **Live-Suche** in der Gerichteliste.
- **Mehrfachauswahl** zum Löschen vieler Gerichte in einem Schritt.
- Ein Namensfeld je aktiver Sprache, mit Button **„Automatisch
übersetzen"** (DeepL) – das Ergebnis bleibt danach frei korrigierbar.
- **CSV-Export/-Import**: aktuelle Liste als CSV herunterladen,
bearbeiten, wieder hochladen. Spalten
`name,name_en,…,price,allergens,additives` (Kürzel semikolongetrennt,
z. B. `A;G`), Spaltenreihenfolge und Groß-/Kleinschreibung der
Kopfzeile egal. Bestehende Gerichte werden über den Namen erkannt und
aktualisiert, neue angelegt. Fehlerhafte Zeilen (Pflichtfeld fehlt,
ungültiger Preis, mehrdeutiger Name) werden übersprungen und mit
Grund aufgelistet, statt den ganzen Import abzubrechen.
### Essenszeiten
Vollständig verwaltbar statt fest im Code: anlegen, umbenennen (je
Sprache), Uhrzeit ändern, per Pfeil-Buttons neu anordnen, löschen.
Beim Löschen werden zugewiesene Gerichte automatisch aus allen
Wochenplänen entfernt.
### Allergene & Zusatzstoffe
- Globale Listen pflegen (Kürzel + Name je Sprache, Sortierung),
ebenfalls mit DeepL-Übersetzung und CSV-Export/-Import.
- **Zwei Betriebsmodi, umschaltbar:**
1. *Detailliert* – Auswahl je Gericht, Kürzel am Gericht, Legende
unter der Karte.
2. *Hinweistext* – ein einziger, mehrsprachig editierbarer Satz
(z. B. „Bei Fragen zu Allergenen und Zusatzstoffen wenden Sie sich
bitte an das Kantinen-Team."). Kürzel, Legende und die
Allergen-Auswahl in der Gerichteverwaltung verschwinden;
zugeordnete Daten bleiben in der Datenbank erhalten und sind beim
Zurückschalten wieder da.
### Rezeptvorschläge
Eingereichte Vorschläge sichten, direkt als Gericht **übernehmen** oder
**ablehnen**. Die Anzahl offener Vorschläge steht als rote Zahl im
Navigationspunkt „Vorschläge".
### Eigenes Konto
Name, E-Mail und Passwort ändern, Passkeys registrieren und entfernen,
Passwort-Anmeldung für das eigene Konto abschalten.
---
## 3. Für Admins (`/admin`)
### Benutzerverwaltung
Kantinenmitarbeiter anlegen, bearbeiten und löschen; Rolle vergeben
(Kantinenmitarbeiter oder Admin); Passwort zurücksetzen – jeweils mit
Wiederholungsfeld gegen Tippfehler.
### Design
- Acht Farben per Farbwähler einstellbar: Primär, Primär dunkel,
Akzent, Kopfzeilentext, Zeilenhintergrund, Zeilenhintergrund
alternierend, Hinzufügen-Button, Löschen-Button. Auf Werkszustand
zurücksetzbar.
- Logo und Favicon hochladen oder entfernen. Aus Sicherheitsgründen
nur Rasterformate (png/jpg/webp/gif/ico) – SVG ist bewusst nicht
erlaubt, da eingebettetes Script auf der eigenen Domain ausgeführt
würde.
### Sprachen
Sprachen sind zur Laufzeit verwaltbar, ohne neues Deployment:
- Vorlage der deutschen Oberflächentexte herunterladen, übersetzt
wieder hochladen – die neue Sprache ist sofort aktiv.
- Je Sprache wird **Vollständigkeit in %** angezeigt sowie „N neue
Begriffe seit letztem Import", falls eine App-Version zusätzliche
Texte mitgebracht hat. Fehlende Begriffe fallen automatisch auf
Deutsch zurück, bleiben also nie leer.
- Neue Sprachen selbst anlegen (Code + Anzeigename). Sobald eine
Sprache aktiv ist, erscheint in der Gerichte-, Essenszeiten- und
Kennzeichnungsverwaltung automatisch ein Eingabefeld dafür – und im
CSV-Export eine zusätzliche Spalte (`name_fr`, …).
- Sprachen einzeln deaktivieren, bis hinunter auf eine einzige;
Deutsch bleibt als garantiert vollständige Basissprache unlöschbar.
- Der Import prüft die Datei gegen die aktuelle Vorlage: Platzhalter
wie `{message}` müssen erhalten bleiben, unbekannte Schlüssel werden
ignoriert statt einen Fehler zu werfen.
---
## 4. Anmeldung und Sicherheit
**Drei Anmeldewege, kombinierbar**
1. **E-Mail + Passwort** – Hashing mit `scrypt` aus der
Node-Standardbibliothek.
2. **Passkey** (WebAuthn, z. B. Windows Hello, Face ID, Sicherheits-
schlüssel) – im eigenen Konto nach dem ersten Login registrierbar.
Die Passwort-Anmeldung lässt sich pro Konto abschalten, sobald
mindestens ein Passkey vorhanden ist; das wird serverseitig
erzwungen, nicht nur im Browser geprüft.
3. **SSO per OpenID Connect** (z. B. gegen ein selbst gehostetes
Authentik) – Authorization-Code-Flow mit PKCE, Signaturprüfung des
ID-Tokens gegen das JWKS des Providers, State/Nonce serverseitig
kurzlebig und nur einmal verwendbar. Aktiviert sich über drei
Umgebungsvariablen; ohne sie bleibt der SSO-Button unsichtbar.
Wichtig: SSO legt **nie** neue Konten an und übernimmt **keine**
Rollen aus Claims – nur bereits unter „Benutzer" angelegte
E-Mail-Adressen kommen herein, die Rolle bleibt allein in der
Benutzerverwaltung steuerbar.
**Sitzungen**
Opake Tokens, nur als Hash in der Datenbank, Cookie mit
`httpOnly`/`sameSite=lax`. Laufzeit 30 Tage mit gleitender
Verlängerung: fällt die Restlaufzeit unter 7 Tage, wird sie beim
nächsten Aufruf erneuert – statt bei jedem Request zu schreiben.
**Zugriffsschutz**
`/intern`, `/admin` und `/konto` sind serverseitig geschützt; jede
API-Route prüft zusätzlich die Rolle. Öffentlich lesbar sind nur
Speisekarte, Essenszeiten, Kennzeichnungen und Logo/Favicon.
**Weitere Härtung**
CSRF-Origin-Check, HTTP-Security-Header für alle Routen
(`X-Frame-Options: DENY`, `frame-ancestors 'none'`, `nosniff`,
`Referrer-Policy`, restriktive `Permissions-Policy`), Datenbank ohne
Host-Port (nur im internen Docker-Netz erreichbar), Upload-Whitelist
für Bildformate.
**Erster Start**
Ist die Datenbank leer, wird automatisch genau ein Admin mit
Zufallspasswort angelegt und die Zugangsdaten nach `ZUGANGSDATEN.md`
geschrieben (nicht im Repository). Klappt das Schreiben nicht, landen
sie im Container-Log statt verloren zu gehen.
---
## 5. Mehrsprachigkeit im Detail
Zwei Ebenen, beide zur Laufzeit erweiterbar:
| Ebene | Was | Verwaltung |
| --- | --- | --- |
| Oberfläche | alle Buttons, Beschriftungen, Meldungen | Admin → Sprachen (Vorlage exportieren/importieren) |
| Inhalte | Gerichtnamen, Essenszeiten, Allergene, Zusatzstoffe, Schließungsgründe | ein Feld je aktiver Sprache in der jeweiligen Verwaltung |
Beides ist **nicht** auf Deutsch/Englisch/Polnisch begrenzt – eine neu
angelegte Sprache erscheint automatisch überall. Übersetzungen können
per DeepL vorbefüllt werden (in alle aktiven, von DeepL unterstützten
Sprachen gleichzeitig) und bleiben danach manuell korrigierbar. Fehlt
eine Übersetzung, greift der Deutsch-Fallback; ist Deutsch selbst
deaktiviert, die erste aktive Sprache.
DeepL ist optional: ohne `DEEPL_API_KEY` verschwindet nur der Button
„Automatisch übersetzen", alle Felder bleiben von Hand ausfüllbar.
---
## 6. Betrieb
- **Vollständig selbst gehostet.** Einzige optionale externe
Abhängigkeit ist DeepL. Keine Cloud-Datenbank, kein
Auth-Dienstleister, kein Storage-Anbieter.
- **Stack:** Next.js 16 (App Router, TypeScript, Tailwind CSS),
PostgreSQL über `pg` mit eigenen SQL-Migrationen.
- **Docker Compose** für Produktion und Entwicklung (Dev-Profil mit
Hot-Reload, ohne lokal installiertes Node.js/PostgreSQL).
- **Migrationen laufen bei jedem Start automatisch** und idempotent –
ein Update braucht keinen manuellen Datenbankschritt.
- **`update.sh`** für Linux-Deployments: Stack stoppen, lokale
Änderungen an `docker-compose.yml` sichern, `git pull`, Änderungen
zurückspielen, neu bauen und starten. Bricht bei Konflikten
kontrolliert ab, statt mit halbem Stand weiterzulaufen.
- **`npm run db:seed-demo`** spielt einen Beispieldatensatz für Demos
ein (Essenszeiten, Kennzeichnungen, Testgerichte, zwei Wochen).
Frische Deployments starten bewusst leer.
- **Versionsnummer im Fußbereich** jeder Seite, Git-Tags markieren die
Versionsstände.
---
## 7. Wann kam was
| Version | Kern |
| --- | --- |
| 1.0.0 | Öffentliche Speisekarte, Login (Passwort + Passkey), Kantinen- und Admin-Bereich, Docker-Setup |
| 1.1.0 | Versionsnummer im Fußbereich |
| 1.2.0 | „Heute"-Button, mobile Tages-/Wochenansicht, Allergen-Chips, Passwort-Wiederholung, Favicon, `SITE_TITLE` |
| 1.3.0 | Mehrsprachigkeit DE/EN/PL, DeepL, Rezeptvorschläge inkl. Sichtung |
| 1.4.0 | Essenszeiten frei verwaltbar |
| 1.5.0 | Supabase komplett entfernt – eigene Datenbank, Auth, Passkeys, Datei-Ablage |
| 1.6.x | RSS-Feed, Vorschläge-Badge, DEPLOYMENT.md, DB-Port geschlossen, Bugfixes |
| 1.7.0 | CSV-Import/-Export für Gerichte, Allergene, Zusatzstoffe |
| 1.8.x | Umschaltbarer Hinweismodus für Kennzeichnungen, `update.sh` |
| 1.9.0 | SSO per OpenID Connect |
| 1.9.5 | Interner Bereich für Mobilgeräte nutzbar |
| 1.10.0 | Sprachen zur Laufzeit verwaltbar (Oberfläche) |
| 1.11.x | Sprachen zur Laufzeit verwaltbar (Inhalte), Sprach-Fallback-Fix |
| 1.12.x | Tage als geschlossen markierbar, Schloss-Symbol und durchgehendes Banner |
Ausführlich in [CHANGELOG.md](CHANGELOG.md), der Zeitaufwand je Version
in [ZEITAUFWAND.md](ZEITAUFWAND.md).
+8 -5
View File
@@ -11,14 +11,16 @@ DeepL als einzige externe API (optional). Läuft vollständig selbst
gehostet, keine Cloud-Abhängigkeit außer optional DeepL.
Änderungen je Version stehen in [CHANGELOG.md](CHANGELOG.md), Git-Tags
markieren die jeweiligen Versionsstände (`git tag`).
markieren die jeweiligen Versionsstände (`git tag`). Schritt-für-Schritt-
Anleitung für die Inbetriebnahme auf einem neuen Server:
[DEPLOYMENT.md](DEPLOYMENT.md).
## Funktionen
| Bereich | Funktionen |
| --- | --- |
| Öffentlich (ohne Login) | Wochenplan Mo–Fr mit frei konfigurierbaren Essenszeiten; Navigation vorherige/nächste Woche/Heute; auf schmalen Bildschirmen umschaltbare Tages-/Wochenansicht (Tag ist Standard); Logo zentriert über der Karte; Allergen-/Zusatzstoff-Kürzel je Gericht, Legende unterhalb der Karte; Sprachumschalter Deutsch/Englisch/Polnisch; Rezeptvorschläge einreichen |
| Kantinen-Team (`/intern`) | Essenszeiten anlegen/bearbeiten/löschen/neu anordnen; Gerichte anlegen/bearbeiten/löschen (inkl. Live-Suche, Mehrfachauswahl zum Batch-Löschen, englischem/polnischem Namen mit automatischer DeepL-Übersetzung) mit Allergenen/Zusatzstoffen je Gericht; Gerichte Tagen/Essenszeiten zuweisen (mehrere pro Zelle) und wieder entfernen; globale Allergen-/Zusatzstofflisten pflegen (ebenfalls mehrsprachig); eingereichte Rezeptvorschläge sichten, übernehmen oder ablehnen; eigenes Konto verwalten (Name, E-Mail, Passwort, Passkeys, Passwort-Anmeldung an/aus) |
| Öffentlich (ohne Login) | Wochenplan Mo–Fr mit frei konfigurierbaren Essenszeiten; Navigation vorherige/nächste Woche/Heute; auf schmalen Bildschirmen umschaltbare Tages-/Wochenansicht (Tag ist Standard); Logo zentriert über der Karte; Allergen-/Zusatzstoff-Kürzel je Gericht, Legende unterhalb der Karte; Sprachumschalter Deutsch/Englisch/Polnisch; Rezeptvorschläge einreichen; RSS-Feed (`/feed.xml`, Icon im Kopfbereich) mit einem Eintrag pro Tag zum Abonnieren |
| Kantinen-Team (`/intern`) | Essenszeiten anlegen/bearbeiten/löschen/neu anordnen; Gerichte anlegen/bearbeiten/löschen (inkl. Live-Suche, Mehrfachauswahl zum Batch-Löschen, englischem/polnischem Namen mit automatischer DeepL-Übersetzung) mit Allergenen/Zusatzstoffen je Gericht; Gerichte per Suche Tagen/Essenszeiten zuweisen (mehrere pro Zelle) und wieder entfernen; globale Allergen-/Zusatzstofflisten pflegen (ebenfalls mehrsprachig); eingereichte Rezeptvorschläge sichten, übernehmen oder ablehnen (Anzahl offener Vorschläge als Badge in der Navigation); eigenes Konto verwalten (Name, E-Mail, Passwort, Passkeys, Passwort-Anmeldung an/aus) |
| Admin (`/admin`) | Kantinenmitarbeiter anlegen/bearbeiten/löschen (inkl. Passwort-Reset mit Wiederholung und Rollenvergabe); Farbgebung der Speisekarte anpassen; Logo und Favicon hochladen/entfernen |
Anmeldung mit E-Mail + Passwort oder alternativ per **Passkey**
@@ -37,6 +39,7 @@ mit optionaler automatischer Übersetzung über DeepL (siehe
```
app/ Next.js-Routen
page.tsx Öffentliche Speisekarte
feed.xml/ RSS-Feed des Speiseplans (öffentlich)
login/ Login (Passwort + Passkey)
vorschlag/ Öffentliches Rezeptvorschlag-Formular
intern/ Kantinen-Bereich (Rolle: staff oder admin)
@@ -107,7 +110,7 @@ PostgreSQL müssen nicht installiert sein):
```bash
docker compose --env-file .env.local up -d --build
# App: Port 3000 (hinter Reverse-Proxy mit HTTPS betreiben)
# PostgreSQL: Port 5433
# PostgreSQL: kein Host-Port, nur intern im Compose-Netzwerk erreichbar
```
Vor dem ersten Start: eine leere `ZUGANGSDATEN.md` im Projektverzeichnis
@@ -131,7 +134,7 @@ müssen neu registriert werden.
## Datenbank
Reines PostgreSQL (kein Supabase, keine Row-Level-Security-Policies) –
Reines, selbst gehostetes PostgreSQL (keine Row-Level-Security-Policies) –
alle Zugriffsrechte werden in den Route-Handlern geprüft (`lib/auth/session.ts`,
`requireRole`).
+54
View File
@@ -0,0 +1,54 @@
# Zeitaufwand
Übersicht, wie lange die Umsetzung der einzelnen Versionen mit Claude
gedauert hat, zum Vergleich mit der Schätzung für einen erfahrenen
Entwickler ohne KI-Unterstützung. Wird bei jeder neuen Version
ergänzt.
## Methodik
- **„Zeit bei Claude"** basiert auf den Git-Commit-Zeitstempeln
zwischen zwei Versions-Tags. Das ist kein reiner Rechenzeit-Wert,
sondern enthält auch die Zeit, die der Nutzer zum Lesen/Antworten
gebraucht hat – aber es ist der einzige objektiv messbare Anhaltspunkt.
- Große Lücken zwischen zwei Tags (mehrere Stunden bis über einen Tag)
markieren eindeutig eine neue Sitzung, kein durchgehendes Arbeiten –
diese sind als „neue Sitzung" markiert statt als Zeitangabe.
- **„Erfahrener Entwickler"** ist eine grobe Schätzung für jemanden,
der mit dem Stack (Next.js, PostgreSQL, TypeScript) vertraut, aber
neu in diesem Projekt ist, inkl. Testen/Verifikation, exkl. Code-Review
durch Dritte.
## Übersicht
| Version | Datum | Kern der Version | Zeit bei Claude | Erfahrener Entwickler |
|---|---|---|---|---|
| v1.0.0/v1.1.0 | 17.07. | Grundgerüst: öffentliche Speisekarte, Login (Passwort+Passkey), Kantinen-/Admin-Bereich, Supabase-Schema+RLS, Docker-Setup, lokale PG-Kopie | nicht messbar (erster Commit) | 3–5 Tage (24–40 h) |
| v1.2.0 | 18.07. | Heute-Button, mobile Tages-/Wochenansicht, Allergen-Chips, Passwort-Wiederholung, Passkey-Pflicht, SITE_TITLE/Favicon, UX-Politur | 56 min | 1–1,5 Tage (8–12 h) |
| v1.3.0 | 18.07. | Volle Mehrsprachigkeit DE/EN/PL, DeepL-Anbindung, Rezeptvorschläge (öffentlich + Sichtung), 2 Bugfixes | 1 h 23 min | 1,5–2 Tage (12–16 h) |
| v1.4.0 | 18.07. | Essenszeiten variabel (CRUD + Sortierung), 2 Bugfixes | *(neue Sitzung)* | 0,5–1 Tag (4–8 h) |
| v1.5.0 | 18.07. | **Supabase komplett entfernt**: eigene DB/Auth/Passkeys/Storage, 2 Sicherheitsfixes (Passkey-Pflicht serverseitig, SVG-XSS) | 3 h 26 min | 2–3 Tage (16–24 h) |
| v1.6.0 | 18.07. | RSS-Feed, Vorschläge-Badge, DEPLOYMENT.md, Supabase-Doku-Reste entfernt | 33 min | 0,5–1 Tag (4–8 h) |
| v1.6.1 | 18.07. | DB-Port abgesichert, Troubleshooting-Doku | 1 h 58 min | 1–2 h |
| v1.6.2 | 18.07. | Bootstrap-Admin-Fehlerbehandlung + Doku-Fix | 22 min | 1–2 h |
| v1.6.3 | 18.07. | Docker `BRANDING_DIR`-Bugfix | 20 min | 0,5–1 h |
| v1.7.0 | 19.07. | CSV-Import/-Export (3 Ressourcen), RSS-Fix | *(neue Sitzung)* | 0,5–1 Tag (4–8 h) |
| v1.8.0 | 19.07. | Umschaltbarer Hinweismodus für Allergene/Zusatzstoffe | 45 min | 0,5–1 Tag (4–6 h) |
| v1.8.1 | 20.07. | `update.sh` Deployment-Skript | 10 min | 1–2 h |
| v1.9.0 | 20.07. | SSO-Login per OpenID Connect (PKCE, JWKS-Prüfung) | 26 min | 1–1,5 Tage (8–12 h) |
| v1.9.5 | 20.07. | Interner Bereich mobil nutzbar (Hamburger-Nav, Tab-Wochenansicht, 5 Komponenten) | 21 min | 1 Tag (6–10 h) |
| v1.10.0 | 20.07. | Dynamisches Sprachpaket-System (Teil 1 Mehrsprachig-Umbau) | *(neue Sitzung)* | 1–1,5 Tage (8–12 h) |
| v1.11.0 | 20.07. | Speiseplan-Inhalte variabel (Teil 2, inkl. Produktionsdaten-Migration) | 35 min | 1–1,5 Tage (8–12 h) |
| v1.11.1 | 20.07. | Sprach-Fallback-Bugfix (deaktiviertes Deutsch) | 12 min | 0,5–1,5 h |
| v1.12.0 | 20.07. | Tage als geschlossen markierbar (Feiertage o. Ä.), mehrsprachiger Grund, RSS-Integration | 1 h 12 min | 0,5–1 Tag (4–8 h) |
| v1.12.1 | 20.07. | Schloss-Symbol statt Textlink, durchgehendes Geschlossen-Banner über alle Essenszeiten | 14 min | 2–4 h |
| **Gesamt** | | | **~12,3 h messbare aktive Zeit** (+ 1 nicht messbarer Grundgerüst-Block) | **~114–187 h ≈ 15–24 Arbeitstage** (real eher 1–2 Monate Kalenderzeit) |
## Warum der Unterschied?
Der Zeitvorteil kommt vor allem daher, dass zwischen Planung,
Umsetzung und Test keine Wartezeit liegt (Code-Review, Freigaben,
andere Aufgaben dazwischen) – nicht daher, dass grundsätzlich
"schneller gedacht" wird. Ein Mensch bräuchte für dieselbe Sorgfalt
vermutlich nicht weniger Zeit pro Aufgabe, aber deutlich mehr
Kalenderzeit durch Kontextwechsel.
+5
View File
@@ -0,0 +1,5 @@
import LanguageManager from "@/components/LanguageManager";
export default function LanguagesPage() {
return <LanguageManager />;
}
+29
View File
@@ -0,0 +1,29 @@
import { listCodes } from "@/lib/db/code-lists";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { toCsv } from "@/lib/csv";
export async function GET() {
try {
await requireRole(["staff", "admin"]);
const [items, activeCodes] = await Promise.all([
listCodes("additives"),
listActiveLanguageCodes(),
]);
const langCodes = activeCodes.filter((c) => c !== "de");
const rows = [
["code", "name", ...langCodes.map((c) => `name_${c}`)],
...items.map((i) => [i.code, i.name, ...langCodes.map((c) => i.translations[c] ?? "")]),
];
return new Response(toCsv(rows), {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="zusatzstoffe.csv"',
},
});
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { importCodes, type CodeImportRow } from "@/lib/db/code-lists";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import { parseCsvWithHeader } from "@/lib/csv";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const { header, rows: cells } = parseCsvWithHeader(await file.text());
const codeIdx = header.indexOf("code");
const nameIdx = header.indexOf("name");
if (codeIdx === -1 || nameIdx === -1) {
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
}
const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
const langCols = activeCodes
.map((code) => ({ code, idx: header.indexOf(`name_${code}`) }))
.filter((c) => c.idx !== -1);
const rows: CodeImportRow[] = [];
const skipped: { row: number; reason: string }[] = [];
cells.forEach((c, i) => {
const rowNum = i + 2; // 1-indexiert + Kopfzeile
const code = (c[codeIdx] ?? "").trim();
const name = (c[nameIdx] ?? "").trim();
if (!code || !name) {
skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" });
return;
}
const translations: Record<string, string> = {};
for (const { code: lang, idx } of langCols) {
const value = c[idx]?.trim();
if (value) translations[lang] = value;
}
rows.push({ code, name, translations });
});
const result = rows.length > 0 ? await importCodes("additives", rows) : { created: 0, updated: 0 };
return NextResponse.json({ ...result, skipped });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { getLanguage, importTranslations } from "@/lib/db/languages";
import { currentTemplateHash, validateImport } from "@/lib/i18n/template";
import { ForbiddenError, requireRole, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function POST(
request: Request,
{ params }: { params: Promise<{ code: string }> }
) {
try {
if (!isSameOrigin(request)) throw new ForbiddenError();
await requireRole(["admin"]);
} catch (err) {
if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
}
throw err;
}
const { code } = await params;
const language = await getLanguage(code);
if (!language) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
if (language.is_base) {
return NextResponse.json({ error: "cannot_import_base" }, { status: 400 });
}
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
let parsed: unknown;
try {
parsed = JSON.parse(await file.text());
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}
const { entries, skipped } = validateImport(parsed as Record<string, unknown>);
if (entries.length > 0) {
await importTranslations(code, entries, currentTemplateHash());
}
return NextResponse.json({ imported: entries.length, skipped });
}
+75
View File
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import {
deleteLanguage,
setLanguageActive,
updateLanguageLabel,
} from "@/lib/db/languages";
import { ForbiddenError, requireRole, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
async function requireAdmin(request: Request) {
if (!isSameOrigin(request)) throw new ForbiddenError();
return requireRole(["admin"]);
}
function errorResponse(err: unknown) {
if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
}
return null;
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ code: string }> }
) {
try {
await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const { code } = await params;
const body = await request.json().catch(() => null);
try {
if (typeof body?.active === "boolean") {
await setLanguageActive(code, body.active);
}
if (typeof body?.label === "string" && body.label.trim()) {
await updateLanguageLabel(code, body.label.trim());
}
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof Error && err.message === "cannot_deactivate_last") {
return NextResponse.json({ error: "cannot_deactivate_last" }, { status: 400 });
}
throw err;
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ code: string }> }
) {
try {
await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const { code } = await params;
try {
await deleteLanguage(code);
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof Error && err.message === "cannot_delete_base") {
return NextResponse.json({ error: "cannot_delete_base" }, { status: 400 });
}
throw err;
}
}
+96
View File
@@ -0,0 +1,96 @@
import { NextResponse } from "next/server";
import {
createLanguage,
listLanguages,
listTranslations,
} from "@/lib/db/languages";
import { computeCompleteness, currentTemplateHash } from "@/lib/i18n/template";
import { ForbiddenError, requireRole, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
// Sprachverwaltung (nur Admin) – gleiches Auth-/CSRF-Muster wie
// app/api/admin/users/route.ts.
async function requireAdmin(request: Request) {
if (!isSameOrigin(request)) throw new ForbiddenError();
return requireRole(["admin"]);
}
function errorResponse(err: unknown) {
if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
}
return null;
}
const CODE_PATTERN = /^[a-z]{2,5}$/;
export async function GET(request: Request) {
try {
await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const languages = await listLanguages();
const hash = currentTemplateHash();
const items = await Promise.all(
languages.map(async (lang) => {
if (lang.is_base) {
return {
code: lang.code,
label: lang.label,
is_base: true,
active: lang.active,
percent: 100,
outdated: false,
missingCount: 0,
};
}
const rows = await listTranslations(lang.code);
const translated = Object.fromEntries(rows.map((r) => [r.key, r.value]));
const completeness = computeCompleteness(translated);
return {
code: lang.code,
label: lang.label,
is_base: false,
active: lang.active,
percent: completeness.percent,
outdated: lang.template_version !== hash,
missingCount: completeness.missingKeys.length,
};
})
);
return NextResponse.json(items);
}
export async function POST(request: Request) {
try {
await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const body = await request.json().catch(() => null);
const code = String(body?.code ?? "").trim().toLowerCase();
const label = String(body?.label ?? "").trim();
if (!CODE_PATTERN.test(code) || !label) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
try {
const language = await createLanguage(code, label);
return NextResponse.json(language, { status: 201 });
} catch (err) {
if (err instanceof Error && /duplicate key/i.test(err.message)) {
return NextResponse.json({ error: "duplicate_code" }, { status: 409 });
}
throw err;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { currentTemplate } from "@/lib/i18n/template";
import { ForbiddenError, requireRole, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
// Deutsche Vorlage (alle aktuellen Übersetzungsschlüssel + Text) zum
// Download – wird von einem Übersetzer ausgefüllt und anschließend
// über POST /api/admin/languages/[code]/import wieder hochgeladen.
export async function GET(request: Request) {
try {
if (!isSameOrigin(request)) throw new ForbiddenError();
await requireRole(["admin"]);
} catch (err) {
if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
}
throw err;
}
const template = currentTemplate();
return new NextResponse(JSON.stringify(template, null, 2), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": 'attachment; filename="sprachvorlage.json"',
},
});
}
+29
View File
@@ -0,0 +1,29 @@
import { listCodes } from "@/lib/db/code-lists";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { toCsv } from "@/lib/csv";
export async function GET() {
try {
await requireRole(["staff", "admin"]);
const [items, activeCodes] = await Promise.all([
listCodes("allergens"),
listActiveLanguageCodes(),
]);
const langCodes = activeCodes.filter((c) => c !== "de");
const rows = [
["code", "name", ...langCodes.map((c) => `name_${c}`)],
...items.map((i) => [i.code, i.name, ...langCodes.map((c) => i.translations[c] ?? "")]),
];
return new Response(toCsv(rows), {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="allergene.csv"',
},
});
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { importCodes, type CodeImportRow } from "@/lib/db/code-lists";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import { parseCsvWithHeader } from "@/lib/csv";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const { header, rows: cells } = parseCsvWithHeader(await file.text());
const codeIdx = header.indexOf("code");
const nameIdx = header.indexOf("name");
if (codeIdx === -1 || nameIdx === -1) {
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
}
const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
const langCols = activeCodes
.map((code) => ({ code, idx: header.indexOf(`name_${code}`) }))
.filter((c) => c.idx !== -1);
const rows: CodeImportRow[] = [];
const skipped: { row: number; reason: string }[] = [];
cells.forEach((c, i) => {
const rowNum = i + 2; // 1-indexiert + Kopfzeile
const code = (c[codeIdx] ?? "").trim();
const name = (c[nameIdx] ?? "").trim();
if (!code || !name) {
skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" });
return;
}
const translations: Record<string, string> = {};
for (const { code: lang, idx } of langCols) {
const value = c[idx]?.trim();
if (value) translations[lang] = value;
}
rows.push({ code, name, translations });
});
const result = rows.length > 0 ? await importCodes("allergens", rows) : { created: 0, updated: 0 };
return NextResponse.json({ ...result, skipped });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { queryOne } from "@/lib/db";
import { createSession } from "@/lib/auth/session";
import { isConfigured } from "@/lib/auth/oidc-config";
import { consumeState } from "@/lib/auth/oidc-state";
import { discover, exchangeCode, resolveEmail, verifyIdToken } from "@/lib/auth/oidc-client";
import type { Role } from "@/lib/types";
function errorRedirect(request: Request, code: string): NextResponse {
return NextResponse.redirect(new URL(`/login?error=${code}`, request.url));
}
// GET statt POST: der Identity Provider leitet den Browser per
// Redirect hierher (Cross-Origin-Navigation) – die übliche
// isSameOrigin()-CSRF-Prüfung greift hier nicht und wird bewusst nicht
// verwendet. Die Absicherung ist der einmalig verwendbare state-
// Parameter (Standard-OIDC-Mechanismus, siehe lib/auth/oidc-state.ts).
export async function GET(request: Request) {
if (!isConfigured()) {
return errorRedirect(request, "oidc_failed");
}
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const idpError = url.searchParams.get("error");
if (idpError || !code || !state) {
return errorRedirect(request, "oidc_failed");
}
const stored = await consumeState(state);
if (!stored) {
return errorRedirect(request, "oidc_invalid_state");
}
try {
const discovery = await discover();
const { idToken, accessToken } = await exchangeCode(discovery, code, stored.codeVerifier);
const payload = await verifyIdToken(discovery, idToken, stored.nonce);
const email = await resolveEmail(discovery, payload, accessToken);
if (!email) {
return errorRedirect(request, "oidc_failed");
}
const user = await queryOne<{ id: string; role: Role }>(
"select id, role from users where email = $1",
[email.trim().toLowerCase()]
);
if (!user) {
return errorRedirect(request, "oidc_unknown_user");
}
await createSession(user.id);
return NextResponse.redirect(
new URL(user.role === "admin" ? "/admin" : "/intern", request.url)
);
} catch {
return errorRedirect(request, "oidc_failed");
}
}
+31
View File
@@ -0,0 +1,31 @@
import { createHash, randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import { isConfigured } from "@/lib/auth/oidc-config";
import { storeState } from "@/lib/auth/oidc-state";
import { buildAuthorizeUrl, discover } from "@/lib/auth/oidc-client";
// Bewusst ein einfacher GET-Link statt eines CSRF-gesicherten POSTs:
// der Browser muss hier auf die fremde Login-Seite des Identity
// Providers navigieren (funktioniert nicht per fetch/XHR) – identisch
// zum Muster jeder "Login mit X"-Schaltfläche. Die Absicherung gegen
// Cross-Site-Missbrauch übernimmt der state-Parameter im Callback.
export async function GET(request: Request) {
if (!isConfigured()) {
return NextResponse.json({ error: "oidc_not_configured" }, { status: 404 });
}
const state = randomBytes(32).toString("base64url");
const nonce = randomBytes(32).toString("base64url");
const codeVerifier = randomBytes(32).toString("base64url");
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
try {
const discovery = await discover();
await storeState(state, nonce, codeVerifier);
return NextResponse.redirect(
buildAuthorizeUrl(discovery, { state, nonce, codeChallenge })
);
} catch {
return NextResponse.redirect(new URL("/login?error=oidc_failed", request.url));
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { removeClosedDay } from "@/lib/db/closed-days";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { date } = await params;
await removeClosedDay(date);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { listClosedDays, setClosedDay } from "@/lib/db/closed-days";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const from = searchParams.get("from");
const to = searchParams.get("to");
if (!from || !to) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
return NextResponse.json(await listClosedDays(from, to));
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const body = (await request.json()) as {
closed_date: string;
reason: string | null;
translations?: Record<string, string | null>;
};
const closedDay = await setClosedDay(body.closed_date, body.reason, body.translations);
return NextResponse.json(closedDay, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+24 -8
View File
@@ -1,9 +1,18 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Dish } from "@/lib/types";
interface DishRow {
id: string;
name: string;
price: string;
allergens: string[];
additives: string[];
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -16,19 +25,26 @@ export async function PATCH(
const { id } = await params;
const body = (await request.json()) as {
name: string;
name_en: string | null;
name_pl: string | null;
price: number;
allergens: string[];
additives: string[];
translations?: Record<string, string | null>;
};
const dish = await queryOne<Dish>(
`update dishes set name = $1, name_en = $2, name_pl = $3, price = $4,
allergens = $5, additives = $6
where id = $7 returning *`,
[body.name, body.name_en, body.name_pl, body.price, body.allergens, body.additives, id]
const row = await queryOne<DishRow>(
`update dishes set name = $1, price = $2, allergens = $3, additives = $4
where id = $5 returning id, name, price, allergens, additives`,
[body.name, body.price, body.allergens, body.additives, id]
);
if (!dish) return NextResponse.json({ error: "not_found" }, { status: 404 });
if (!row) return NextResponse.json({ error: "not_found" }, { status: 404 });
if (body.translations) {
await replaceTranslations("dish_translations", row.id, body.translations);
}
const translations = await loadTranslationsFor("dish_translations", [row.id]);
const dish: Dish = {
...row,
price: Number(row.price),
translations: translations.get(row.id) ?? {},
};
return NextResponse.json(dish);
} catch (err) {
const res = authErrorResponse(err);
+48
View File
@@ -0,0 +1,48 @@
import { query } from "@/lib/db";
import { loadTranslationsFor } from "@/lib/db/translations";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { toCsv } from "@/lib/csv";
interface DishRow {
id: string;
name: string;
price: string;
allergens: string[];
additives: string[];
}
export async function GET() {
try {
await requireRole(["staff", "admin"]);
const [dishes, activeCodes] = await Promise.all([
query<DishRow>("select id, name, price, allergens, additives from dishes order by name"),
listActiveLanguageCodes(),
]);
const langCodes = activeCodes.filter((c) => c !== "de");
const translations = await loadTranslationsFor(
"dish_translations",
dishes.map((d) => d.id)
);
const rows = [
["name", ...langCodes.map((c) => `name_${c}`), "price", "allergens", "additives"],
...dishes.map((d) => [
d.name,
...langCodes.map((c) => translations.get(d.id)?.[c] ?? ""),
d.price,
(d.allergens ?? []).join(";"),
(d.additives ?? []).join(";"),
]),
];
return new Response(toCsv(rows), {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="gerichte.csv"',
},
});
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+124
View File
@@ -0,0 +1,124 @@
import { NextResponse } from "next/server";
import { withTransaction } from "@/lib/db";
import { replaceTranslations } from "@/lib/db/translations";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import { parseCsvWithHeader, splitCodes } from "@/lib/csv";
interface ImportRow {
rowNum: number;
name: string;
translations: Record<string, string>;
price: number;
allergens: string[];
additives: string[];
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const { header, rows: cells } = parseCsvWithHeader(await file.text());
const nameIdx = header.indexOf("name");
const priceIdx = header.indexOf("price");
if (nameIdx === -1 || priceIdx === -1) {
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
}
const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
const langCols = activeCodes
.map((code) => ({ code, idx: header.indexOf(`name_${code}`) }))
.filter((c) => c.idx !== -1);
const allergensIdx = header.indexOf("allergens");
const additivesIdx = header.indexOf("additives");
const rows: ImportRow[] = [];
const skipped: { row: number; reason: string }[] = [];
cells.forEach((c, i) => {
const rowNum = i + 2; // 1-indexiert + Kopfzeile
const name = (c[nameIdx] ?? "").trim();
if (!name) {
skipped.push({ row: rowNum, reason: "Name fehlt" });
return;
}
const priceRaw = (c[priceIdx] ?? "").trim().replace(",", ".");
const price = Number.parseFloat(priceRaw);
if (Number.isNaN(price) || price < 0) {
skipped.push({ row: rowNum, reason: `Ungültiger Preis „${c[priceIdx] ?? ""}“` });
return;
}
const translations: Record<string, string> = {};
for (const { code, idx } of langCols) {
const value = c[idx]?.trim();
if (value) translations[code] = value;
}
rows.push({
rowNum,
name,
translations,
price,
allergens: allergensIdx >= 0 ? splitCodes(c[allergensIdx]) : [],
additives: additivesIdx >= 0 ? splitCodes(c[additivesIdx]) : [],
});
});
let created = 0;
let updated = 0;
const savedTranslations: { id: string; translations: Record<string, string> }[] = [];
if (rows.length > 0) {
await withTransaction(async (client) => {
for (const row of rows) {
// Gerichtsnamen sind nicht eindeutig (keine DB-Constraint) – bei
// mehreren Treffern lieber überspringen als versehentlich alle
// gleichnamigen Gerichte mit denselben Werten zu überschreiben.
const existing = await client.query<{ id: string }>(
"select id from dishes where name = $1",
[row.name]
);
if (existing.rows.length > 1) {
skipped.push({
row: row.rowNum,
reason: `„${row.name}“ ist mehrdeutig (mehrere Gerichte mit diesem Namen) – übersprungen`,
});
continue;
}
if (existing.rows.length === 1) {
await client.query(
`update dishes set price = $1, allergens = $2, additives = $3 where id = $4`,
[row.price, row.allergens, row.additives, existing.rows[0].id]
);
savedTranslations.push({ id: existing.rows[0].id, translations: row.translations });
updated++;
} else {
const inserted = await client.query<{ id: string }>(
`insert into dishes (name, price, allergens, additives)
values ($1, $2, $3, $4) returning id`,
[row.name, row.price, row.allergens, row.additives]
);
savedTranslations.push({ id: inserted.rows[0].id, translations: row.translations });
created++;
}
}
});
for (const { id, translations } of savedTranslations) {
if (Object.keys(translations).length > 0) {
await replaceTranslations("dish_translations", id, translations);
}
}
}
return NextResponse.json({ created, updated, skipped });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+35 -9
View File
@@ -1,12 +1,35 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Dish } from "@/lib/types";
interface DishRow {
id: string;
name: string;
price: string;
allergens: string[];
additives: string[];
}
async function withTranslations(rows: DishRow[]): Promise<Dish[]> {
const translations = await loadTranslationsFor(
"dish_translations",
rows.map((r) => r.id)
);
return rows.map((r) => ({
...r,
price: Number(r.price),
translations: translations.get(r.id) ?? {},
}));
}
export async function GET() {
const dishes = await query<Dish>("select * from dishes order by name");
return NextResponse.json(dishes);
const rows = await query<DishRow>(
"select id, name, price, allergens, additives from dishes order by name"
);
return NextResponse.json(await withTranslations(rows));
}
export async function POST(request: Request) {
@@ -17,17 +40,20 @@ export async function POST(request: Request) {
await requireRole(["staff", "admin"]);
const body = (await request.json()) as {
name: string;
name_en: string | null;
name_pl: string | null;
price: number;
allergens: string[];
additives: string[];
translations?: Record<string, string | null>;
};
const dish = await queryOne<Dish>(
`insert into dishes (name, name_en, name_pl, price, allergens, additives)
values ($1, $2, $3, $4, $5, $6) returning *`,
[body.name, body.name_en, body.name_pl, body.price, body.allergens, body.additives]
);
const row = (await queryOne<DishRow>(
`insert into dishes (name, price, allergens, additives)
values ($1, $2, $3, $4) returning id, name, price, allergens, additives`,
[body.name, body.price, body.allergens, body.additives]
))!;
if (body.translations) {
await replaceTranslations("dish_translations", row.id, body.translations);
}
const [dish] = await withTranslations([row]);
return NextResponse.json(dish, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
+3 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { isLocale, LOCALE_COOKIE } from "@/lib/i18n";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { isSameOrigin } from "@/lib/origin";
// Setzt das Sprach-Cookie. Kein Login nötig (auch anonyme Besucher
@@ -10,7 +11,8 @@ export async function POST(request: Request) {
}
const body = await request.json().catch(() => null);
const locale = body?.locale;
if (!isLocale(locale)) {
const active = await listActiveLanguageCodes();
if (!isLocale(locale, active)) {
return NextResponse.json({ error: "Ungültige Sprache." }, { status: 400 });
}
const response = NextResponse.json({ ok: true });
+28 -8
View File
@@ -1,9 +1,17 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Section } from "@/lib/types";
interface SectionRow {
key: string;
label: string;
time_range: string;
sort: number;
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ key: string }> }
@@ -15,25 +23,37 @@ export async function PATCH(
await requireRole(["staff", "admin"]);
const { key } = await params;
const body = (await request.json()) as Partial<
Pick<Section, "label" | "label_en" | "label_pl" | "time_range" | "sort">
>;
Pick<Section, "label" | "time_range" | "sort">
> & { translations?: Record<string, string | null> };
const fields: string[] = [];
const values: unknown[] = [];
for (const col of ["label", "label_en", "label_pl", "time_range", "sort"] as const) {
for (const col of ["label", "time_range", "sort"] as const) {
if (body[col] !== undefined) {
values.push(body[col]);
fields.push(`${col} = $${values.length}`);
}
}
if (fields.length === 0) return NextResponse.json({ error: "empty_update" }, { status: 400 });
let row: SectionRow | null;
if (fields.length > 0) {
values.push(key);
const section = await queryOne<Section>(
`update sections set ${fields.join(", ")} where key = $${values.length} returning *`,
row = await queryOne<SectionRow>(
`update sections set ${fields.join(", ")} where key = $${values.length}
returning key, label, time_range, sort`,
values
);
if (!section) return NextResponse.json({ error: "not_found" }, { status: 404 });
} else {
row = await queryOne<SectionRow>(
"select key, label, time_range, sort from sections where key = $1",
[key]
);
}
if (!row) return NextResponse.json({ error: "not_found" }, { status: 404 });
if (body.translations) {
await replaceTranslations("section_translations", row.key, body.translations);
}
const translations = await loadTranslationsFor("section_translations", [row.key]);
const section: Section = { ...row, translations: translations.get(row.key) ?? {} };
return NextResponse.json(section);
} catch (err) {
const res = authErrorResponse(err);
+30 -9
View File
@@ -1,12 +1,30 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Section } from "@/lib/types";
interface SectionRow {
key: string;
label: string;
time_range: string;
sort: number;
}
async function withTranslations(rows: SectionRow[]): Promise<Section[]> {
const translations = await loadTranslationsFor(
"section_translations",
rows.map((r) => r.key)
);
return rows.map((r) => ({ ...r, translations: translations.get(r.key) ?? {} }));
}
export async function GET() {
const sections = await query<Section>("select * from sections order by sort");
return NextResponse.json(sections);
const rows = await query<SectionRow>(
"select key, label, time_range, sort from sections order by sort"
);
return NextResponse.json(await withTranslations(rows));
}
export async function POST(request: Request) {
@@ -17,19 +35,22 @@ export async function POST(request: Request) {
await requireRole(["staff", "admin"]);
const body = (await request.json()) as {
label: string;
label_en: string | null;
label_pl: string | null;
time_range: string;
translations?: Record<string, string | null>;
};
const maxRow = await queryOne<{ sort: number }>(
"select sort from sections order by sort desc limit 1"
);
const section = await queryOne<Section>(
`insert into sections (label, label_en, label_pl, time_range, sort)
values ($1, $2, $3, $4, $5) returning *`,
[body.label, body.label_en, body.label_pl, body.time_range, (maxRow?.sort ?? 0) + 1]
);
const row = (await queryOne<SectionRow>(
`insert into sections (label, time_range, sort)
values ($1, $2, $3) returning key, label, time_range, sort`,
[body.label, body.time_range, (maxRow?.sort ?? 0) + 1]
))!;
if (body.translations) {
await replaceTranslations("section_translations", row.key, body.translations);
}
const [section] = await withTranslations([row]);
return NextResponse.json(section, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
+48 -4
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { getSettings } from "@/lib/db/settings";
import { getSettings, type LabelingMode } from "@/lib/db/settings";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
@@ -8,14 +8,58 @@ export async function GET() {
return NextResponse.json(await getSettings());
}
interface SettingsPatchBody {
colors?: Record<string, string>;
labeling_mode?: LabelingMode;
labeling_notice?: string;
labeling_notice_en?: string | null;
labeling_notice_pl?: string | null;
}
export async function PATCH(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["admin"]);
const { colors } = (await request.json()) as { colors: Record<string, string> };
await query("update settings set colors = $1 where id = 1", [JSON.stringify(colors)]);
// Farben bleiben admin-only wie bisher; die Allergen-/Zusatzstoff-
// Anzeige darf auch Kantinenpersonal umschalten/bearbeiten.
const session = await requireRole(["staff", "admin"]);
const body = (await request.json()) as SettingsPatchBody;
const sets: string[] = [];
const params: unknown[] = [];
if (body.colors !== undefined) {
if (session.user.role !== "admin") {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}
params.push(JSON.stringify(body.colors));
sets.push(`colors = $${params.length}`);
}
if (body.labeling_mode !== undefined) {
if (body.labeling_mode !== "detailed" && body.labeling_mode !== "notice") {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
params.push(body.labeling_mode);
sets.push(`labeling_mode = $${params.length}`);
}
if (body.labeling_notice !== undefined) {
params.push(body.labeling_notice);
sets.push(`labeling_notice = $${params.length}`);
}
if (body.labeling_notice_en !== undefined) {
params.push(body.labeling_notice_en);
sets.push(`labeling_notice_en = $${params.length}`);
}
if (body.labeling_notice_pl !== undefined) {
params.push(body.labeling_notice_pl);
sets.push(`labeling_notice_pl = $${params.length}`);
}
if (sets.length === 0) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
await query(`update settings set ${sets.join(", ")} where id = 1`, params);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
+43 -26
View File
@@ -1,21 +1,32 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { listActiveLanguageCodes } from "@/lib/db/languages";
import { replaceTranslations, type TranslationTable } from "@/lib/db/translations";
import { requireRole, UnauthorizedError, ForbiddenError } from "@/lib/auth/session";
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.
// Übersetzt einen deutschen Text via DeepL in alle aktiven, von DeepL
// unterstützten Sprachen und schreibt das Ergebnis in die passende
// *_translations-Tabelle. Ohne DEEPL_API_KEY läuft die Route kontrolliert
// ins Leere (configured: false), damit das UI die manuelle Eingabe anbietet.
const ALLOWED_TABLES: Record<string, { idColumn: string; textColumn: string }> = {
dishes: { idColumn: "id", textColumn: "name" },
sections: { idColumn: "key", textColumn: "label" },
allergens: { idColumn: "code", textColumn: "name" },
additives: { idColumn: "code", textColumn: "name" },
const ALLOWED_TABLES: Record<string, { idColumn: string; translationTable: TranslationTable }> = {
dishes: { idColumn: "id", translationTable: "dish_translations" },
sections: { idColumn: "key", translationTable: "section_translations" },
allergens: { idColumn: "code", translationTable: "allergen_translations" },
additives: { idColumn: "code", translationTable: "additive_translations" },
closed_days: { idColumn: "closed_date", translationTable: "closed_day_translations" },
};
async function translateWithDeepL(text: string, targetLang: "EN" | "PL", apiKey: string) {
// Unsere Sprachcodes -> DeepL target_lang. Sprachen ohne Eintrag hier sind
// von DeepL nicht unterstützt und bleiben bei manueller Eingabe.
const DEEPL_TARGET_LANG: Record<string, string> = {
en: "EN", pl: "PL", fr: "FR", es: "ES", it: "IT", nl: "NL", pt: "PT-PT",
ru: "RU", ja: "JA", zh: "ZH", cs: "CS", da: "DA", el: "EL", fi: "FI",
hu: "HU", id: "ID", ko: "KO", nb: "NB", ro: "RO", sk: "SK", sl: "SL",
sv: "SV", tr: "TR", uk: "UK", bg: "BG", et: "ET", lv: "LV", lt: "LT",
};
async function translateWithDeepL(text: string, targetLang: string, apiKey: string) {
const host = apiKey.endsWith(":fx")
? "https://api-free.deepl.com"
: "https://api.deepl.com";
@@ -71,25 +82,31 @@ export async function POST(request: Request) {
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.");
const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
const targets = activeCodes
.map((code) => ({ code, target: DEEPL_TARGET_LANG[code.toLowerCase()] }))
.filter((t): t is { code: string; target: string } => Boolean(t.target));
if (persist) {
// table/idColumn/textColumn kommen ausschließlich aus dem oben
// geprüften ALLOWED_TABLES-Whitelist, nie aus Nutzereingaben –
// sicher für die String-Interpolation in der Query.
await query(
`update ${table} set ${config.textColumn}_en = $1, ${config.textColumn}_pl = $2
where ${config.idColumn} = $3`,
[en, pl, id]
try {
const results = await Promise.all(
targets.map(async (t) => ({
code: t.code,
text: await translateWithDeepL(text, t.target, apiKey),
}))
);
const translations: Record<string, string> = {};
for (const r of results) {
if (r.text) translations[r.code] = r.text;
}
if (Object.keys(translations).length === 0) {
throw new Error("Keine Übersetzung erhalten.");
}
return NextResponse.json({ configured: true, en, pl });
if (persist) {
await replaceTranslations(config.translationTable, id, translations);
}
return NextResponse.json({ configured: true, translations });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Übersetzung fehlgeschlagen." },
+123
View File
@@ -0,0 +1,123 @@
import { query } from "@/lib/db";
import { loadMenuEntries } from "@/lib/db/menu-entries";
import { listClosedDays } from "@/lib/db/closed-days";
import { addDays, entriesFor, formatPrice, mondayOf, todayISO } from "@/lib/menu";
import { localized, SITE_TITLE } from "@/lib/types";
import type { Section } from "@/lib/types";
// RSS-Feed mit dem Speiseplan – ein Eintrag pro Tag mit Gerichten,
// damit man ihn in einem Feedreader abonnieren kann und täglich sieht,
// was es gibt. Immer auf Deutsch: Feedreader senden kein Sprach-Cookie,
// eine feste Sprache ist hier verständlicher als ein zufälliger Locale-Default.
export const dynamic = "force-dynamic";
function escapeXml(text: string): string {
return text.replace(/[&<>"']/g, (c) => {
switch (c) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
default:
return "&apos;";
}
});
}
function formatFeedDate(iso: string): string {
const d = new Date(`${iso}T00:00:00Z`);
return d.toLocaleDateString("de-DE", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC",
});
}
export async function GET() {
const appUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
const monday = mondayOf(todayISO());
// Aktuelle + nächste Woche, wie auch die öffentliche Speisekarte per
// Wochennavigation anzeigt.
const rangeEnd = addDays(monday, 11);
const [sections, entries, closedDays] = await Promise.all([
query<Section>("select * from sections order by sort"),
loadMenuEntries(monday, rangeEnd),
listClosedDays(monday, rangeEnd),
]);
const days = Array.from(
new Set([...entries.map((e) => e.menu_date), ...closedDays.map((c) => c.closed_date)])
).sort();
const items = days
.map((iso) => {
const closed = closedDays.find((c) => c.closed_date === iso);
const dayContent = closed
? `<p><strong>Geschlossen</strong>${
closed.reason ? ` – ${escapeXml(closed.reason)}` : ""
}</p>`
: sections
.map((s) => {
const dishes = entriesFor(entries, iso, s.key);
if (dishes.length === 0) return "";
const list = dishes
.map(
(e) =>
`<li>${escapeXml(localized(e.dish, "de"))} – ${formatPrice(e.dish.price)}</li>`
)
.join("");
return `<p><strong>${escapeXml(localized(s, "de"))} (${escapeXml(
s.time_range
)})</strong></p><ul>${list}</ul>`;
})
.join("");
if (!dayContent) return "";
const [y, m, d] = iso.split("-").map(Number);
const pubDate = new Date(Date.UTC(y, m - 1, d, 6, 0, 0)).toUTCString();
return ` <item>
<title>${escapeXml(formatFeedDate(iso))}</title>
<link>${appUrl}/?woche=${mondayOf(iso)}</link>
<guid isPermaLink="false">${appUrl}/feed.xml#${iso}</guid>
<pubDate>${pubDate}</pubDate>
<description><![CDATA[${dayContent}]]></description>
<content:encoded><![CDATA[${dayContent}]]></content:encoded>
</item>`;
})
.join("\n");
// "description" gilt bei vielen Feed-Readern (Thunderbird, Outlook,
// etliche Apps) als reine Kurzfassung/Vorschau und wird eigenmächtig
// gekürzt – dadurch war z. B. bei mehreren Essenszeiten pro Tag oft nur
// die erste sichtbar. "content:encoded" (RSS-Content-Modul) gilt als
// vollständiger Artikeltext und wird von den meisten Readern
// ungekürzt angezeigt, wenn vorhanden – redundant zu description für
// Reader ohne Unterstützung des Moduls.
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>${escapeXml(SITE_TITLE)} – Speiseplan</title>
<link>${appUrl}</link>
<description>Täglicher Speiseplan der Kantine</description>
<language>de</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>
`;
return new Response(xml, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=1800",
},
});
}
+4
View File
@@ -102,6 +102,10 @@ body {
font-size: 0.85rem;
}
.menu-table tr:nth-child(even) td.cell { background: var(--c-row-bg-alt); }
.menu-table td.cell.closed-cell {
vertical-align: middle;
background: #fee2e2;
}
.menu-table td.cell .dish { margin-bottom: 0.35rem; }
.menu-table td.cell .dish sup {
font-size: 0.6rem;
+4
View File
@@ -1,11 +1,15 @@
import CodeListManager from "@/components/CodeListManager";
import LabelingNoticeEditor from "@/components/LabelingNoticeEditor";
export default function KennzeichnungPage() {
const translateEnabled = !!process.env.DEEPL_API_KEY;
return (
<div className="flex flex-col gap-6">
<LabelingNoticeEditor />
<div className="grid gap-6 lg:grid-cols-2">
<CodeListManager table="allergens" translateEnabled={translateEnabled} />
<CodeListManager table="additives" translateEnabled={translateEnabled} />
</div>
</div>
);
}
+6 -1
View File
@@ -7,5 +7,10 @@ export default async function InternPage({
searchParams: Promise<{ woche?: string }>;
}) {
const { woche } = await searchParams;
return <WeekEditor monday={mondayOf(woche)} />;
return (
<WeekEditor
monday={mondayOf(woche)}
translateEnabled={!!process.env.DEEPL_API_KEY}
/>
);
}
+19 -3
View File
@@ -4,6 +4,7 @@ import "./globals.css";
import { getSettings } from "@/lib/db/settings";
import { COLOR_VARS, DEFAULT_COLORS, faviconUrl, SITE_TITLE } from "@/lib/types";
import { getDictionary, getLocale } from "@/lib/i18n";
import { listLanguages } from "@/lib/db/languages";
import { LocaleProvider } from "@/components/i18n/LocaleProvider";
import packageJson from "@/package.json";
@@ -25,6 +26,14 @@ export async function generateMetadata(): Promise<Metadata> {
// einem eigenen Favicon eingebunden (Next.js kombiniert Datei-
// Konvention und Metadata statt sie zu ersetzen).
icons: { icon: faviconUrl(settings?.favicon_path) ?? "/favicon.ico" },
// Autodiscovery für Feedreader (Browser zeigen dafür kein eigenes
// Icon mehr an, daher zusätzlich ein sichtbares RSS-Icon im Header
// der öffentlichen Speisekarte, s. app/page.tsx).
alternates: {
types: {
"application/rss+xml": [{ url: "/feed.xml", title: `${SITE_TITLE} – Speiseplan` }],
},
},
};
}
@@ -33,8 +42,15 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const [settings, locale] = await Promise.all([getSettings(), getLocale()]);
const dict = getDictionary(locale);
const [settings, locale, allLanguages] = await Promise.all([
getSettings(),
getLocale(),
listLanguages(),
]);
const dict = await getDictionary(locale);
const languages = allLanguages
.filter((l) => l.active)
.map((l) => ({ code: l.code, label: l.label }));
const colors: Record<string, string> = {
...DEFAULT_COLORS,
@@ -47,7 +63,7 @@ export default async function RootLayout({
return (
<html lang={locale} className={`${geistSans.variable} h-full antialiased`}>
<body style={style} className="min-h-full flex flex-col">
<LocaleProvider locale={locale} dict={dict}>
<LocaleProvider locale={locale} dict={dict} languages={languages}>
{children}
</LocaleProvider>
<footer className="mt-auto px-4 py-2 text-right text-xs text-gray-400 select-none">
+9 -143
View File
@@ -1,145 +1,11 @@
"use client";
import LoginForm from "@/components/LoginForm";
import { isConfigured } from "@/lib/auth/oidc-config";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { startAuthentication } from "@simplewebauthn/browser";
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 authErrorMessage(dict: Dict, code: string): string {
if (code === "invalid_credentials") return dict.login.errorInvalidCredentials;
if (code === "password_disabled") return dict.login.errorPasswordDisabled;
if (/cancelled|aborted|NotAllowedError/i.test(code))
return dict.login.errorPasskeyCancelled;
return formatMessage(dict.login.errorGeneric, { message: code });
}
function redirectPath(role: string): string {
return role === "admin" ? "/admin" : "/intern";
}
export default function LoginPage() {
const router = useRouter();
const dict = useDict();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function signInWithPassword(e: React.FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(authErrorMessage(dict, data.error ?? "unknown"));
setBusy(false);
return;
}
router.replace(redirectPath(data.role));
router.refresh();
}
async function signInWithPasskey() {
setError(null);
setBusy(true);
try {
const optionsRes = await fetch("/api/auth/passkeys/login-options", { method: "POST" });
const options = await optionsRes.json();
const response = await startAuthentication({ optionsJSON: options });
const verifyRes = await fetch("/api/auth/passkeys/login-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ response }),
});
const data = await verifyRes.json().catch(() => ({}));
if (!verifyRes.ok) {
setError(authErrorMessage(dict, data.error ?? "unknown"));
setBusy(false);
return;
}
router.replace(redirectPath(data.role));
router.refresh();
} catch (err) {
setError(authErrorMessage(dict, err instanceof Error ? err.message : String(err)));
setBusy(false);
}
}
return (
<div className="flex-1 flex items-center justify-center p-4">
<div className="card w-full max-w-sm">
<div className="flex justify-between items-center mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)]">
{dict.login.title}
</h1>
<LanguageSwitcher variant="light" />
</div>
<form onSubmit={signInWithPassword} className="space-y-3">
<div>
<label className="label" htmlFor="email">
{dict.common.email}
</label>
<input
id="email"
type="email"
className="input"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username webauthn"
required
/>
</div>
<div>
<label className="label" htmlFor="password">
{dict.common.password}
</label>
<input
id="password"
type="password"
className="input"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
{error && <p className="text-sm text-[var(--c-del)]">{error}</p>}
<button type="submit" className="btn btn-primary w-full" disabled={busy}>
{dict.login.submit}
</button>
</form>
<div className="my-4 flex items-center gap-3 text-xs text-gray-400">
<span className="h-px flex-1 bg-gray-200" />
{dict.login.or}
<span className="h-px flex-1 bg-gray-200" />
</div>
<button
type="button"
onClick={signInWithPasskey}
className="btn btn-neutral w-full"
disabled={busy}
>
{dict.login.passkeyButton}
</button>
<p className="mt-4 text-center text-sm">
<Link href="/" className="text-gray-500 hover:text-[var(--c-primary)]">
{dict.login.backToMenu}
</Link>
</p>
</div>
</div>
);
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
return <LoginForm oidcEnabled={isConfigured()} initialError={error ?? null} />;
}
+111 -11
View File
@@ -1,6 +1,8 @@
import Link from "next/link";
import { query } from "@/lib/db";
import { loadMenuEntries } from "@/lib/db/menu-entries";
import { loadTranslations } from "@/lib/db/translations";
import { listClosedDays } from "@/lib/db/closed-days";
import { getSettings } from "@/lib/db/settings";
import {
addDays,
@@ -27,21 +29,50 @@ export default async function Home({
const friday = addDays(monday, 4);
const currentWeek = mondayOf(todayISO());
const locale = await getLocale();
const dict = getDictionary(locale);
const dict = await getDictionary(locale);
const days = dict.weekdays.long.map((weekday, i) => ({
weekday,
iso: addDays(monday, i),
}));
const [sections, entries, allergens, additives, settings] = await Promise.all([
query<Section>("select * from sections order by sort"),
const [
sectionRows,
entries,
allergenRows,
additiveRows,
settings,
sectionTranslations,
allergenTranslations,
additiveTranslations,
closedDays,
] = await Promise.all([
query<Omit<Section, "translations">>("select key, label, time_range, sort from sections order by sort"),
loadMenuEntries(monday, friday),
query<CodeItem>("select * from allergens order by sort"),
query<CodeItem>("select * from additives order by sort"),
query<Omit<CodeItem, "translations">>("select code, name, sort from allergens order by sort"),
query<Omit<CodeItem, "translations">>("select code, name, sort from additives order by sort"),
getSettings(),
loadTranslations("section_translations"),
loadTranslations("allergen_translations"),
loadTranslations("additive_translations"),
listClosedDays(monday, friday),
]);
const closedDayFor = (iso: string) => closedDays.find((c) => c.closed_date === iso);
const sections: Section[] = sectionRows.map((s) => ({
...s,
translations: sectionTranslations.get(s.key) ?? {},
}));
const allergens: CodeItem[] = allergenRows.map((a) => ({
...a,
translations: allergenTranslations.get(a.code) ?? {},
}));
const additives: CodeItem[] = additiveRows.map((a) => ({
...a,
translations: additiveTranslations.get(a.code) ?? {},
}));
const logo = logoUrl(settings?.logo_path);
const labelingDetailed = (settings?.labeling_mode ?? "detailed") !== "notice";
return (
<div className="flex-1 px-4 py-6 max-w-[1600px] mx-auto w-full">
@@ -56,6 +87,25 @@ export default async function Home({
<div className="hidden sm:block">
<LanguageSwitcher variant="light" />
</div>
<a
href="/feed.xml"
title={dict.publicMenu.rssFeed}
aria-label={dict.publicMenu.rssFeed}
className="text-gray-400 hover:text-[#ee802f]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="18"
height="18"
fill="currentColor"
aria-hidden="true"
>
<circle cx="5.5" cy="18.5" r="2.5" />
<path d="M4 11a9 9 0 0 1 9 9h-3a6 6 0 0 0-6-6v-3z" />
<path d="M4 4a16 16 0 0 1 16 16h-3A13 13 0 0 0 4 7V4z" />
</svg>
</a>
<Link
href="/login"
className="text-sm text-gray-500 hover:text-[var(--c-primary)]"
@@ -80,7 +130,13 @@ export default async function Home({
</header>
{/* Mobil: Tag (Standard) oder Woche umschaltbar */}
<PublicMobileMenu monday={monday} sections={sections} entries={entries} />
<PublicMobileMenu
monday={monday}
sections={sections}
entries={entries}
closedDays={closedDays}
showLabeling={labelingDetailed}
/>
{/* Ab "sm": klassische Wochentabelle */}
<div className="hidden sm:block">
@@ -114,13 +170,33 @@ export default async function Home({
</tr>
</thead>
<tbody>
{sections.map((s) => (
{sections.map((s, sIdx) => (
<tr key={s.key}>
<th className="section-head">
<div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => (
{days.map((d) => {
const closed = closedDayFor(d.iso);
if (closed) {
if (sIdx > 0) return null;
return (
<td key={d.iso} className="cell closed-cell" rowSpan={sections.length}>
<div className="flex flex-col items-center justify-center gap-1 py-4 text-red-700">
<span className="text-2xl" aria-hidden>
🔒
</span>
<span className="font-semibold">{dict.publicMenu.closedLabel}</span>
{localized(closed, locale) && (
<span className="text-sm px-2 text-center">
{localized(closed, locale)}
</span>
)}
</div>
</td>
);
}
return (
<td key={d.iso} className="cell">
{entriesFor(entries, d.iso, s.key).map((e) => {
const codes = dishCodes(e.dish);
@@ -129,14 +205,17 @@ export default async function Home({
<span className="font-bold">
{localized(e.dish, locale)}
</span>
{codes && <sup className="dish-codes">{codes}</sup>}
{labelingDetailed && codes && (
<sup className="dish-codes">{codes}</sup>
)}
{" – "}
{formatPrice(e.dish.price)}
</div>
);
})}
</td>
))}
);
})}
</tr>
))}
</tbody>
@@ -144,7 +223,9 @@ export default async function Home({
</div>
</div>
{/* Allergene & Zusatzstoffe: Legende zu den Kürzeln bei den Gerichten */}
{/* Allergene & Zusatzstoffe: entweder Legende zu den Kürzeln bei den
Gerichten, oder – falls im Hinweismodus – nur ein Hinweistext. */}
{labelingDetailed ? (
<section className="mt-8 grid gap-6 sm:grid-cols-2 text-sm text-gray-700">
<div>
<h2 className="font-bold text-[var(--c-primary)] mb-2">
@@ -173,6 +254,25 @@ export default async function Home({
</ul>
</div>
</section>
) : (
settings && (
<section className="mt-8 text-sm text-gray-700">
<h2 className="font-bold text-[var(--c-primary)] mb-2">
{dict.publicMenu.labelingNoticeTitle}
</h2>
<p>
{localized(
{
name: settings.labeling_notice,
name_en: settings.labeling_notice_en,
name_pl: settings.labeling_notice_pl,
},
locale
)}
</p>
</section>
)
)}
</div>
);
}
+125 -14
View File
@@ -3,14 +3,22 @@
// Gemeinsamer Kopfbereich für alle internen Seiten. Statt zweier
// getrennter Bereiche (Kantine/Verwaltung) gibt es eine Navigation:
// Admins sehen zusätzlich die Verwaltungs-Links, der aktive Punkt
// wird hervorgehoben.
// wird hervorgehoben. Ab "sm" die klassische einzeilige Navigation,
// darunter ein Hamburger-Menü mit denselben Links gestapelt.
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import type { Role } from "@/lib/types";
import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
interface NavLink {
href: string;
label: string;
}
export default function AppHeader({
role,
displayName,
@@ -20,17 +28,32 @@ export default function AppHeader({
}) {
const pathname = usePathname();
const dict = useDict();
const [suggestionCount, setSuggestionCount] = useState(0);
const [mobileOpen, setMobileOpen] = useState(false);
const menuLinks = [
useEffect(() => {
fetch("/api/dish-suggestions")
.then((res) => (res.ok ? res.json() : []))
.then((data) => setSuggestionCount(Array.isArray(data) ? data.length : 0))
.catch(() => {});
}, [pathname]);
// Hamburger-Panel bei jeder Navigation automatisch schließen.
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
const menuLinks: NavLink[] = [
{ href: "/intern", label: dict.header.weekPlan },
{ href: "/intern/essenszeiten", label: dict.header.sections },
{ href: "/intern/gerichte", label: dict.header.dishes },
{ href: "/intern/kennzeichnung", label: dict.header.labels },
{ href: "/intern/vorschlaege", label: dict.header.suggestions },
];
const adminLinks = [
const adminLinks: NavLink[] = [
{ href: "/admin", label: dict.header.users },
{ href: "/admin/design", label: dict.header.design },
{ href: "/admin/sprachen", label: dict.header.languages },
];
const linkClass = (href: string) =>
@@ -38,9 +61,105 @@ export default function AppHeader({
pathname === href ? "bg-white/20 font-semibold" : "text-white/90"
}`;
// Von Desktop- und Mobile-Navigation gemeinsam genutzt, damit die
// Vorschläge-Badge-Logik nicht doppelt gepflegt werden muss.
function renderLinks(links: NavLink[]) {
return links.map((l) => (
<Link key={l.href} href={l.href} className={`relative ${linkClass(l.href)}`}>
{l.label}
{l.href === "/intern/vorschlaege" && suggestionCount > 0 && (
<span
className="absolute -top-1.5 -right-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--c-del)] px-1 text-[10px] font-bold leading-none text-white"
aria-label={formatMessage(dict.header.openSuggestions, {
count: suggestionCount,
})}
>
{suggestionCount}
</span>
)}
</Link>
));
}
return (
<header className="bg-[var(--c-primary-dark)] text-white">
<div className="max-w-[1600px] mx-auto w-full px-4 py-3 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
{/* Mobil (< "sm"): kompakte Kopfzeile mit Hamburger-Toggle */}
<div className="sm:hidden flex items-center justify-between px-4 py-3">
<span className="font-bold text-base">{dict.header.canteen}</span>
<button
type="button"
className="p-1 -m-1"
onClick={() => setMobileOpen((v) => !v)}
aria-label={dict.header.menuToggle}
aria-expanded={mobileOpen}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
{mobileOpen ? (
<path d="M6 6l12 12M18 6L6 18" />
) : (
<path d="M4 7h16M4 12h16M4 17h16" />
)}
</svg>
</button>
</div>
{mobileOpen && (
<div className="sm:hidden px-4 pb-4 flex flex-col gap-4 text-sm border-t border-white/10">
<nav className="flex flex-col gap-1" aria-label={dict.header.menuGroup}>
<span className="mt-3 text-xs uppercase tracking-wider text-white/50">
{dict.header.menuGroup}
</span>
{renderLinks(menuLinks)}
</nav>
{role === "admin" && (
<nav className="flex flex-col gap-1" aria-label={dict.header.adminGroup}>
<span className="text-xs uppercase tracking-wider text-white/50">
{dict.header.adminGroup}
</span>
{renderLinks(adminLinks)}
</nav>
)}
<Link
href="/"
className="w-fit rounded px-2 py-0.5 text-white/70 hover:bg-white/10"
>
{dict.header.viewMenu}
</Link>
<div className="flex items-center gap-3 border-t border-white/10 pt-3">
<LanguageSwitcher />
<Link
href="/konto"
className={`rounded px-2 py-0.5 hover:bg-white/10 ${
pathname === "/konto" ? "bg-white/20 font-semibold" : "text-white/80"
}`}
>
{displayName}
</Link>
</div>
<form action="/logout" method="post">
<button type="submit" className="btn btn-del w-full">
{dict.common.logout}
</button>
</form>
</div>
)}
{/* Ab "sm": bisherige einzeilige Navigation */}
<div className="hidden sm:flex max-w-[1600px] mx-auto w-full px-4 py-3 flex-wrap items-center gap-x-6 gap-y-2 text-sm">
<span className="font-bold text-base">{dict.header.canteen}</span>
<nav
@@ -50,11 +169,7 @@ export default function AppHeader({
<span className="mr-1 text-xs uppercase tracking-wider text-white/50">
{dict.header.menuGroup}
</span>
{menuLinks.map((l) => (
<Link key={l.href} href={l.href} className={linkClass(l.href)}>
{l.label}
</Link>
))}
{renderLinks(menuLinks)}
</nav>
{role === "admin" && (
@@ -65,11 +180,7 @@ export default function AppHeader({
<span className="mr-1 text-xs uppercase tracking-wider text-white/50">
{dict.header.adminGroup}
</span>
{adminLinks.map((l) => (
<Link key={l.href} href={l.href} className={linkClass(l.href)}>
{l.label}
</Link>
))}
{renderLinks(adminLinks)}
</nav>
)}
+93 -24
View File
@@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types";
import type { CodeItem } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
export default function CodeListManager({
table,
@@ -18,6 +18,7 @@ export default function CodeListManager({
}) {
const dict = useDict();
const locale = useLocale();
const languages = useLanguages().filter((l) => l.code !== "de");
const title =
table === "allergens" ? dict.codeList.allergensTitle : dict.codeList.additivesTitle;
const codeLabel =
@@ -30,10 +31,11 @@ export default function CodeListManager({
original: string | null; // code vor der Bearbeitung, null = neu
code: string;
name: string;
nameEn: string;
namePl: string;
translations: Record<string, string>;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [ioBusy, setIoBusy] = useState(false);
const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
"idle"
);
@@ -58,8 +60,7 @@ export default function CodeListManager({
const payload = {
code,
name,
name_en: editing.nameEn.trim() || null,
name_pl: editing.namePl.trim() || null,
translations: editing.translations,
};
const res = editing.original
@@ -116,7 +117,9 @@ export default function CodeListManager({
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
setEditing((prev) =>
prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev
prev
? { ...prev, translations: { ...prev.translations, ...(data.translations ?? {}) } }
: prev
);
setTranslateState("done");
} catch (err) {
@@ -125,17 +128,82 @@ export default function CodeListManager({
}
}
async function exportItems() {
setError(null);
setNotice(null);
const res = await fetch(`/api/${table}/export`);
if (!res.ok) {
setError(formatMessage(dict.codeList.exportError, { message: String(res.status) }));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = table === "allergens" ? "allergene.csv" : "zusatzstoffe.csv";
a.click();
URL.revokeObjectURL(url);
}
async function importItems(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setError(null);
setNotice(null);
setIoBusy(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`/api/${table}/import`, { method: "POST", body: fd });
setIoBusy(false);
if (!res.ok) {
setError(formatMessage(dict.codeList.importError, { message: String(res.status) }));
return;
}
const data = (await res.json()) as {
created: number;
updated: number;
skipped: { row: number; reason: string }[];
};
let msg = formatMessage(dict.codeList.importSuccess, {
created: data.created,
updated: data.updated,
});
if (data.skipped.length > 0) {
const reasons = data.skipped
.map((s) => formatMessage(dict.codeList.importSkippedRow, s))
.join("; ");
msg +=
" " + formatMessage(dict.codeList.importSkipped, { count: data.skipped.length, reasons });
}
setNotice(msg);
await load();
}
return (
<section className="card">
<div className="flex items-center gap-3 mb-3">
<div className="flex flex-wrap items-center gap-3 mb-3">
<h2 className="text-lg font-bold text-[var(--c-primary)] mr-auto">
{title}
</h2>
<button type="button" className="btn btn-neutral" onClick={exportItems}>
{dict.codeList.exportButton}
</button>
<label className="btn btn-neutral cursor-pointer">
{dict.codeList.importButton}
<input
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={importItems}
disabled={ioBusy}
/>
</label>
<button
type="button"
className="btn btn-add"
onClick={() => {
setEditing({ original: null, code: "", name: "", nameEn: "", namePl: "" });
setEditing({ original: null, code: "", name: "", translations: {} });
setTranslateState("idle");
}}
>
@@ -148,6 +216,7 @@ export default function CodeListManager({
{error}
</p>
)}
{notice && <p className="mb-2 text-sm text-green-700">{notice}</p>}
{editing && (
<form onSubmit={save} className="mb-3 space-y-2 border-b border-gray-200 pb-3">
@@ -169,25 +238,26 @@ export default function CodeListManager({
required
/>
</div>
{languages.length > 0 && (
<div className="grid gap-2 sm:grid-cols-2">
<div>
<label className="label">{dict.dishManager.nameEnLabel}</label>
{languages.map((lang) => (
<div key={lang.code}>
<label className="label">{lang.label}</label>
<input
className="input"
value={editing.nameEn}
onChange={(e) => setEditing({ ...editing, nameEn: e.target.value })}
value={editing.translations[lang.code] ?? ""}
onChange={(e) =>
setEditing({
...editing,
translations: { ...editing.translations, [lang.code]: e.target.value },
})
}
/>
</div>
<div>
<label className="label">{dict.dishManager.namePlLabel}</label>
<input
className="input"
value={editing.namePl}
onChange={(e) => setEditing({ ...editing, namePl: e.target.value })}
/>
))}
</div>
</div>
{translateEnabled && (
)}
{translateEnabled && languages.length > 0 && (
<div>
<button
type="button"
@@ -223,7 +293,7 @@ export default function CodeListManager({
<ul className="divide-y divide-gray-100">
{items.map((item) => (
<li key={item.code} className="flex items-center gap-2 py-1.5 text-sm">
<li key={item.code} className="flex flex-wrap items-center gap-2 py-1.5 text-sm">
<span className="mr-auto">
<span className="font-semibold">{item.code}</span> –{" "}
{localized(item, locale)}
@@ -236,8 +306,7 @@ export default function CodeListManager({
original: item.code,
code: item.code,
name: item.name,
nameEn: item.name_en ?? "",
namePl: item.name_pl ?? "",
translations: { ...item.translations },
});
setTranslateState("idle");
}}
+109 -32
View File
@@ -6,13 +6,12 @@ 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";
import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
interface DishForm {
id: string | null; // null = neues Gericht
name: string;
nameEn: string;
namePl: string;
translations: Record<string, string>;
price: string;
allergens: string[];
additives: string[];
@@ -21,8 +20,7 @@ interface DishForm {
const EMPTY_FORM: DishForm = {
id: null,
name: "",
nameEn: "",
namePl: "",
translations: {},
price: "",
allergens: [],
additives: [],
@@ -35,15 +33,19 @@ export default function DishManager({
}) {
const dict = useDict();
const locale = useLocale();
const languages = useLanguages().filter((l) => l.code !== "de");
const [dishes, setDishes] = useState<Dish[]>([]);
const [allergens, setAllergens] = useState<CodeItem[]>([]);
const [additives, setAdditives] = useState<CodeItem[]>([]);
const [form, setForm] = useState<DishForm | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [ioBusy, setIoBusy] = useState(false);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const [search, setSearch] = useState("");
const [labelingDetailed, setLabelingDetailed] = useState(true);
const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
"idle"
);
@@ -55,10 +57,11 @@ export default function DishManager({
}, [dishes, search]);
const load = useCallback(async () => {
const [dishesRes, allergensRes, additivesRes] = await Promise.all([
const [dishesRes, allergensRes, additivesRes, settingsRes] = await Promise.all([
fetch("/api/dishes"),
fetch("/api/allergens"),
fetch("/api/additives"),
fetch("/api/settings"),
]);
if (!dishesRes.ok || !allergensRes.ok || !additivesRes.ok) {
setError(formatMessage(dict.dishManager.loadError, { message: "load failed" }));
@@ -67,6 +70,10 @@ export default function DishManager({
setAllergens(await allergensRes.json());
setAdditives(await additivesRes.json());
}
if (settingsRes.ok) {
const settings = await settingsRes.json();
setLabelingDetailed((settings?.labeling_mode ?? "detailed") !== "notice");
}
setLoading(false);
}, [dict]);
@@ -85,11 +92,10 @@ export default function DishManager({
}
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,
translations: form.translations,
};
const res = form.id
? await fetch(`/api/dishes/${form.id}`, {
@@ -210,7 +216,9 @@ export default function DishManager({
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
setForm((prev) =>
prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev
prev
? { ...prev, translations: { ...prev.translations, ...(data.translations ?? {}) } }
: prev
);
setTranslateState("done");
} catch (err) {
@@ -219,15 +227,80 @@ export default function DishManager({
}
}
async function exportDishes() {
setError(null);
setNotice(null);
const res = await fetch("/api/dishes/export");
if (!res.ok) {
setError(formatMessage(dict.dishManager.exportError, { message: String(res.status) }));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "gerichte.csv";
a.click();
URL.revokeObjectURL(url);
}
async function importDishes(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setError(null);
setNotice(null);
setIoBusy(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/dishes/import", { method: "POST", body: fd });
setIoBusy(false);
if (!res.ok) {
setError(formatMessage(dict.dishManager.importError, { message: String(res.status) }));
return;
}
const data = (await res.json()) as {
created: number;
updated: number;
skipped: { row: number; reason: string }[];
};
let msg = formatMessage(dict.dishManager.importSuccess, {
created: data.created,
updated: data.updated,
});
if (data.skipped.length > 0) {
const reasons = data.skipped
.map((s) => formatMessage(dict.dishManager.importSkippedRow, s))
.join("; ");
msg +=
" " + formatMessage(dict.dishManager.importSkipped, { count: data.skipped.length, reasons });
}
setNotice(msg);
await load();
}
const allSelected =
visible.length > 0 && visible.every((d) => selected.has(d.id));
return (
<div className="max-w-3xl">
<div className="flex items-center gap-3 mb-4">
<div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.dishManager.title}
</h1>
<button type="button" className="btn btn-neutral" onClick={exportDishes}>
{dict.dishManager.exportButton}
</button>
<label className="btn btn-neutral cursor-pointer">
{dict.dishManager.importButton}
<input
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={importDishes}
disabled={ioBusy}
/>
</label>
<button
type="button"
className="btn btn-add"
@@ -245,6 +318,7 @@ export default function DishManager({
{error}
</p>
)}
{notice && <p className="mb-3 text-sm text-green-700">{notice}</p>}
{form && (
<form onSubmit={save} className="card mb-4 space-y-4">
@@ -278,31 +352,29 @@ export default function DishManager({
/>
</div>
{languages.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label" htmlFor="dish-name-en">
{dict.dishManager.nameEnLabel}
{languages.map((lang) => (
<div key={lang.code}>
<label className="label" htmlFor={`dish-name-${lang.code}`}>
{lang.label}
</label>
<input
id="dish-name-en"
id={`dish-name-${lang.code}`}
className="input"
value={form.nameEn}
onChange={(e) => setForm({ ...form, nameEn: e.target.value })}
value={form.translations[lang.code] ?? ""}
onChange={(e) =>
setForm({
...form,
translations: { ...form.translations, [lang.code]: e.target.value },
})
}
/>
</div>
<div>
<label className="label" htmlFor="dish-name-pl">
{dict.dishManager.namePlLabel}
</label>
<input
id="dish-name-pl"
className="input"
value={form.namePl}
onChange={(e) => setForm({ ...form, namePl: e.target.value })}
/>
))}
</div>
</div>
{translateEnabled && (
)}
{translateEnabled && languages.length > 0 && (
<div>
<button
type="button"
@@ -322,6 +394,8 @@ export default function DishManager({
</div>
)}
{labelingDetailed ? (
<>
<ChipPicker
label={dict.codeList.allergensTitle}
items={allergens}
@@ -334,6 +408,10 @@ export default function DishManager({
selected={form.additives}
onToggle={toggleAdditive}
/>
</>
) : (
<p className="text-sm text-gray-500">{dict.dishManager.labelingDisabledHint}</p>
)}
<div className="flex gap-2">
<button type="submit" className="btn btn-add">
{dict.common.save}
@@ -364,7 +442,7 @@ export default function DishManager({
/>
)}
{dishes.length > 0 && (
<div className="flex items-center gap-3 mb-2 text-sm">
<div className="flex flex-wrap items-center gap-3 mb-2 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
@@ -394,7 +472,7 @@ export default function DishManager({
{visible.map((dish) => {
const codes = dishCodes(dish);
return (
<li key={dish.id} className="flex items-center gap-3 px-4 py-2">
<li key={dish.id} className="flex flex-wrap items-center gap-3 px-4 py-2">
<input
type="checkbox"
checked={selected.has(dish.id)}
@@ -415,8 +493,7 @@ export default function DishManager({
setForm({
id: dish.id,
name: dish.name,
nameEn: dish.name_en ?? "",
namePl: dish.name_pl ?? "",
translations: { ...dish.translations },
price: String(dish.price).replace(".", ","),
allergens: dish.allergens ?? [],
additives: dish.additives ?? [],
+142
View File
@@ -0,0 +1,142 @@
"use client";
// Umschaltung zwischen der detaillierten Allergen-/Zusatzstoff-Auswahl
// je Gericht und einem einfachen, editierbaren Hinweistext.
import { useCallback, useEffect, useState } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
import type { LabelingMode } from "@/lib/db/settings";
export default function LabelingNoticeEditor() {
const dict = useDict();
const [mode, setMode] = useState<LabelingMode>("detailed");
const [noticeDe, setNoticeDe] = useState("");
const [noticeEn, setNoticeEn] = useState("");
const [noticePl, setNoticePl] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const load = useCallback(async () => {
const res = await fetch("/api/settings");
if (!res.ok) {
setError(formatMessage(dict.labelingNotice.loadError, { message: String(res.status) }));
setLoading(false);
return;
}
const data = await res.json();
setMode(data.labeling_mode ?? "detailed");
setNoticeDe(data.labeling_notice ?? "");
setNoticeEn(data.labeling_notice_en ?? "");
setNoticePl(data.labeling_notice_pl ?? "");
setLoading(false);
}, [dict]);
useEffect(() => {
load();
}, [load]);
async function save() {
setBusy(true);
setError(null);
setInfo(null);
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
labeling_mode: mode,
labeling_notice: noticeDe.trim(),
labeling_notice_en: noticeEn.trim() || null,
labeling_notice_pl: noticePl.trim() || null,
}),
});
setBusy(false);
if (!res.ok) {
setError(formatMessage(dict.labelingNotice.saveError, { message: String(res.status) }));
return;
}
setInfo(dict.labelingNotice.saved);
}
if (loading) return null;
return (
<section className="card">
<h2 className="font-semibold mb-2">{dict.labelingNotice.title}</h2>
<p className="text-sm text-gray-600 mb-3">{dict.labelingNotice.hint}</p>
{error && (
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{info && <p className="mb-3 text-sm text-green-700">{info}</p>}
<div className="flex flex-col gap-2 mb-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="labeling-mode"
checked={mode === "detailed"}
onChange={() => setMode("detailed")}
/>
{dict.labelingNotice.modeDetailed}
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="labeling-mode"
checked={mode === "notice"}
onChange={() => setMode("notice")}
/>
{dict.labelingNotice.modeNotice}
</label>
</div>
<div className="space-y-3 mb-4">
<div>
<label className="label" htmlFor="labeling-notice-de">
{dict.labelingNotice.noticeDeLabel}
</label>
<textarea
id="labeling-notice-de"
className="input"
rows={2}
value={noticeDe}
onChange={(e) => setNoticeDe(e.target.value)}
/>
</div>
<div>
<label className="label" htmlFor="labeling-notice-en">
{dict.labelingNotice.noticeEnLabel}
</label>
<textarea
id="labeling-notice-en"
className="input"
rows={2}
value={noticeEn}
onChange={(e) => setNoticeEn(e.target.value)}
/>
</div>
<div>
<label className="label" htmlFor="labeling-notice-pl">
{dict.labelingNotice.noticePlLabel}
</label>
<textarea
id="labeling-notice-pl"
className="input"
rows={2}
value={noticePl}
onChange={(e) => setNoticePl(e.target.value)}
/>
</div>
</div>
<button type="button" className="btn btn-add" onClick={save} disabled={busy}>
{dict.labelingNotice.save}
</button>
</section>
);
}
+293
View File
@@ -0,0 +1,293 @@
"use client";
// Verwaltung der Sprachpakete (nur Admin): Sprachen aktivieren/
// deaktivieren (bis auf eine einzige reduzierbar), Vorlage der
// aktuellen Begriffe herunterladen, Übersetzungsdatei hochladen, neue
// Sprache anlegen/löschen. Deutsch ist die feste Basis-Sprache und
// nicht löschbar.
import { useCallback, useEffect, useState } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
interface LanguageItem {
code: string;
label: string;
is_base: boolean;
active: boolean;
percent: number;
outdated: boolean;
missingCount: number;
}
export default function LanguageManager() {
const dict = useDict();
const [languages, setLanguages] = useState<LanguageItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [newCode, setNewCode] = useState("");
const [newLabel, setNewLabel] = useState("");
const load = useCallback(async () => {
const res = await fetch("/api/admin/languages");
if (!res.ok) {
setError(formatMessage(dict.languageManager.loadError, { message: String(res.status) }));
} else {
setLanguages(await res.json());
}
setLoading(false);
}, [dict]);
useEffect(() => {
load();
}, [load]);
async function toggleActive(item: LanguageItem) {
setError(null);
setNotice(null);
setBusy(true);
const res = await fetch(`/api/admin/languages/${item.code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active: !item.active }),
});
setBusy(false);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(
data.error === "cannot_deactivate_last"
? dict.languageManager.cannotDeactivateLast
: formatMessage(dict.languageManager.saveError, { message: data.error ?? String(res.status) })
);
return;
}
await load();
}
async function deleteLanguage(item: LanguageItem) {
if (!window.confirm(formatMessage(dict.languageManager.confirmDelete, { label: item.label })))
return;
setError(null);
setNotice(null);
setBusy(true);
const res = await fetch(`/api/admin/languages/${item.code}`, { method: "DELETE" });
setBusy(false);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(
data.error === "cannot_delete_base"
? dict.languageManager.cannotDeleteBase
: formatMessage(dict.languageManager.deleteError, { message: data.error ?? String(res.status) })
);
return;
}
await load();
}
async function downloadTemplate() {
setError(null);
const res = await fetch("/api/admin/languages/template");
if (!res.ok) {
setError(formatMessage(dict.languageManager.loadError, { message: String(res.status) }));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "sprachvorlage.json";
a.click();
URL.revokeObjectURL(url);
}
async function uploadImport(code: string, e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setError(null);
setNotice(null);
setBusy(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`/api/admin/languages/${code}/import`, {
method: "POST",
body: fd,
});
setBusy(false);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(formatMessage(dict.languageManager.importError, { message: data.error ?? String(res.status) }));
return;
}
const data = (await res.json()) as {
imported: number;
skipped: { key: string; reason: string }[];
};
let msg = formatMessage(dict.languageManager.importSuccess, { count: data.imported });
if (data.skipped.length > 0) {
const first = data.skipped[0];
const [reason, placeholder] = first.reason.split(":");
if (reason === "missing_placeholder") {
msg +=
" " +
formatMessage(dict.languageManager.importMissingPlaceholder, {
key: first.key,
placeholder: `{${placeholder}}`,
});
if (data.skipped.length > 1) msg += ` (+${data.skipped.length - 1})`;
}
}
setNotice(msg);
await load();
}
async function createLanguage(e: React.FormEvent) {
e.preventDefault();
setError(null);
setNotice(null);
const code = newCode.trim().toLowerCase();
const label = newLabel.trim();
if (!/^[a-z]{2,5}$/.test(code)) {
setError(dict.languageManager.invalidCode);
return;
}
if (!label) return;
setBusy(true);
const res = await fetch("/api/admin/languages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, label }),
});
setBusy(false);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(
data.error === "duplicate_code"
? formatMessage(dict.languageManager.duplicateCode, { code })
: formatMessage(dict.languageManager.saveError, { message: data.error ?? String(res.status) })
);
return;
}
setNewCode("");
setNewLabel("");
await load();
}
return (
<div className="max-w-3xl space-y-6">
<div>
<h1 className="text-xl font-bold text-[var(--c-primary)] mb-1">
{dict.languageManager.title}
</h1>
<p className="text-sm text-gray-600">{dict.languageManager.hint}</p>
</div>
{error && (
<p className="text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{notice && <p className="text-sm text-green-700">{notice}</p>}
<section className="card">
<div className="flex flex-wrap items-center gap-3 mb-3">
<h2 className="font-semibold mr-auto">{dict.languageManager.title}</h2>
<button type="button" className="btn btn-neutral" onClick={downloadTemplate}>
{dict.languageManager.downloadTemplate}
</button>
</div>
{loading ? (
<p className="text-gray-500">{dict.common.loading}</p>
) : (
<ul className="divide-y divide-gray-100">
{languages.map((item) => (
<li key={item.code} className="flex flex-wrap items-center gap-3 py-2 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={item.active}
onChange={() => toggleActive(item)}
disabled={busy}
/>
<span className="font-semibold uppercase">{item.code}</span>
</label>
<span className="mr-auto">
{item.label}
{item.is_base ? (
<span className="ml-2 text-xs text-gray-500">
({dict.languageManager.baseBadge})
</span>
) : (
<span className="ml-2 text-xs text-gray-500">
{item.outdated
? formatMessage(dict.languageManager.outdated, { count: item.missingCount })
: formatMessage(dict.languageManager.completeness, { percent: item.percent })}
</span>
)}
</span>
{!item.is_base && (
<>
<label className="btn btn-neutral cursor-pointer">
{dict.languageManager.uploadTranslation}
<input
type="file"
accept=".json,application/json"
className="hidden"
onChange={(e) => uploadImport(item.code, e)}
disabled={busy}
/>
</label>
<button
type="button"
className="btn btn-del"
onClick={() => deleteLanguage(item)}
disabled={busy}
>
{dict.languageManager.delete}
</button>
</>
)}
</li>
))}
</ul>
)}
</section>
<section className="card">
<h2 className="font-semibold mb-3">{dict.languageManager.newLanguageTitle}</h2>
<form onSubmit={createLanguage} className="flex flex-wrap items-end gap-3">
<div className="max-w-40">
<label className="label" htmlFor="new-lang-code">
{dict.languageManager.newLanguageCodeLabel}
</label>
<input
id="new-lang-code"
className="input"
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
required
/>
</div>
<div>
<label className="label" htmlFor="new-lang-label">
{dict.languageManager.newLanguageLabelLabel}
</label>
<input
id="new-lang-label"
className="input"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
required
/>
</div>
<button type="submit" className="btn btn-add" disabled={busy}>
{dict.languageManager.create}
</button>
</form>
</section>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { startAuthentication } from "@simplewebauthn/browser";
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 authErrorMessage(dict: Dict, code: string): string {
if (code === "invalid_credentials") return dict.login.errorInvalidCredentials;
if (code === "password_disabled") return dict.login.errorPasswordDisabled;
if (code === "oidc_invalid_state") return dict.login.errorOidcInvalidState;
if (code === "oidc_unknown_user") return dict.login.errorOidcUnknownUser;
if (code === "oidc_failed") return dict.login.errorOidcFailed;
if (/cancelled|aborted|NotAllowedError/i.test(code))
return dict.login.errorPasskeyCancelled;
return formatMessage(dict.login.errorGeneric, { message: code });
}
function redirectPath(role: string): string {
return role === "admin" ? "/admin" : "/intern";
}
export default function LoginForm({
oidcEnabled,
initialError,
}: {
oidcEnabled: boolean;
initialError: string | null;
}) {
const router = useRouter();
const dict = useDict();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(() =>
initialError ? authErrorMessage(dict, initialError) : null
);
const [busy, setBusy] = useState(false);
async function signInWithPassword(e: React.FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(authErrorMessage(dict, data.error ?? "unknown"));
setBusy(false);
return;
}
router.replace(redirectPath(data.role));
router.refresh();
}
async function signInWithPasskey() {
setError(null);
setBusy(true);
try {
const optionsRes = await fetch("/api/auth/passkeys/login-options", { method: "POST" });
const options = await optionsRes.json();
const response = await startAuthentication({ optionsJSON: options });
const verifyRes = await fetch("/api/auth/passkeys/login-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ response }),
});
const data = await verifyRes.json().catch(() => ({}));
if (!verifyRes.ok) {
setError(authErrorMessage(dict, data.error ?? "unknown"));
setBusy(false);
return;
}
router.replace(redirectPath(data.role));
router.refresh();
} catch (err) {
setError(authErrorMessage(dict, err instanceof Error ? err.message : String(err)));
setBusy(false);
}
}
return (
<div className="flex-1 flex items-center justify-center p-4">
<div className="card w-full max-w-sm">
<div className="flex justify-between items-center mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)]">
{dict.login.title}
</h1>
<LanguageSwitcher variant="light" />
</div>
<form onSubmit={signInWithPassword} className="space-y-3">
<div>
<label className="label" htmlFor="email">
{dict.common.email}
</label>
<input
id="email"
type="email"
className="input"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username webauthn"
required
/>
</div>
<div>
<label className="label" htmlFor="password">
{dict.common.password}
</label>
<input
id="password"
type="password"
className="input"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
{error && <p className="text-sm text-[var(--c-del)]">{error}</p>}
<button type="submit" className="btn btn-primary w-full" disabled={busy}>
{dict.login.submit}
</button>
</form>
<div className="my-4 flex items-center gap-3 text-xs text-gray-400">
<span className="h-px flex-1 bg-gray-200" />
{dict.login.or}
<span className="h-px flex-1 bg-gray-200" />
</div>
<button
type="button"
onClick={signInWithPasskey}
className="btn btn-neutral w-full"
disabled={busy}
>
{dict.login.passkeyButton}
</button>
{oidcEnabled && (
<a href="/api/auth/oidc/login" className="btn btn-neutral w-full mt-2">
{dict.login.ssoButton}
</a>
)}
<p className="mt-4 text-center text-sm">
<Link href="/" className="text-gray-500 hover:text-[var(--c-primary)]">
{dict.login.backToMenu}
</Link>
</p>
</div>
</div>
);
}
+1 -1
View File
@@ -96,7 +96,7 @@ export default function PasskeyManager() {
<ul className="divide-y divide-gray-100">
{passkeys.map((pk) => (
<li key={pk.id} className="flex items-center gap-3 py-1.5 text-sm">
<li key={pk.id} className="flex flex-wrap items-center gap-3 py-1.5 text-sm">
<span className="mr-auto">
<span className="font-semibold">{pk.name || "Passkey"}</span>{" "}
<span className="text-gray-500">
+17 -2
View File
@@ -19,7 +19,7 @@ import {
weekdayIndex,
} from "@/lib/menu";
import { localized } from "@/lib/types";
import type { MenuEntry, Section } from "@/lib/types";
import type { ClosedDay, MenuEntry, Section } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
@@ -27,10 +27,14 @@ export default function PublicMobileMenu({
monday,
sections,
entries,
closedDays,
showLabeling,
}: {
monday: string;
sections: Section[];
entries: MenuEntry[];
closedDays: ClosedDay[];
showLabeling: boolean;
}) {
const router = useRouter();
const dict = useDict();
@@ -58,11 +62,19 @@ export default function PublicMobileMenu({
}
function renderDay(day: { weekday: string; iso: string }) {
const closed = closedDays.find((c) => c.closed_date === day.iso);
return (
<div key={day.iso} className="mb-4 last:mb-0">
<h3 className="font-bold text-[var(--c-primary)] mb-2">
{day.weekday} · {formatDateLong(day.iso, dict.dateLocale)}
</h3>
{closed ? (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700 text-center">
<span aria-hidden>🔒</span>{" "}
<span className="font-semibold">{dict.publicMenu.closedLabel}</span>
{localized(closed, locale) && <span> – {localized(closed, locale)}</span>}
</div>
) : (
<div className="space-y-3">
{sections.map((s) => {
const items = entriesFor(entries, day.iso, s.key);
@@ -86,7 +98,9 @@ export default function PublicMobileMenu({
<span className="font-semibold">
{localized(e.dish, locale)}
</span>
{codes && <sup className="dish-codes">{codes}</sup>}{" "}
{showLabeling && codes && (
<sup className="dish-codes">{codes}</sup>
)}{" "}
– {formatPrice(e.dish.price)}
</p>
);
@@ -97,6 +111,7 @@ export default function PublicMobileMenu({
);
})}
</div>
)}
</div>
);
}
+23 -28
View File
@@ -7,13 +7,12 @@ import { useCallback, useEffect, useState } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types";
import type { Section } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
interface SectionForm {
key: string | null; // null = neue Essenszeit
label: string;
labelEn: string;
labelPl: string;
translations: Record<string, string>;
timeFrom: string;
timeTo: string;
}
@@ -21,8 +20,7 @@ interface SectionForm {
const EMPTY_FORM: SectionForm = {
key: null,
label: "",
labelEn: "",
labelPl: "",
translations: {},
timeFrom: "",
timeTo: "",
};
@@ -35,6 +33,7 @@ function splitTimeRange(range: string): { from: string; to: string } {
export default function SectionManager() {
const dict = useDict();
const locale = useLocale();
const languages = useLanguages().filter((l) => l.code !== "de");
const [sections, setSections] = useState<Section[]>([]);
const [form, setForm] = useState<SectionForm | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -61,9 +60,8 @@ export default function SectionManager() {
if (!label || !form.timeFrom || !form.timeTo) return;
const payload = {
label,
label_en: form.labelEn.trim() || null,
label_pl: form.labelPl.trim() || null,
time_range: `${form.timeFrom} - ${form.timeTo}`,
translations: form.translations,
};
const res = form.key
@@ -124,7 +122,7 @@ export default function SectionManager() {
return (
<div className="max-w-3xl">
<div className="flex items-center gap-3 mb-4">
<div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.sectionManager.title}
</h1>
@@ -160,30 +158,28 @@ export default function SectionManager() {
required
/>
</div>
{languages.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label" htmlFor="section-label-en">
{dict.sectionManager.nameEnLabel}
{languages.map((lang) => (
<div key={lang.code}>
<label className="label" htmlFor={`section-label-${lang.code}`}>
{lang.label}
</label>
<input
id="section-label-en"
id={`section-label-${lang.code}`}
className="input"
value={form.labelEn}
onChange={(e) => setForm({ ...form, labelEn: e.target.value })}
/>
</div>
<div>
<label className="label" htmlFor="section-label-pl">
{dict.sectionManager.namePlLabel}
</label>
<input
id="section-label-pl"
className="input"
value={form.labelPl}
onChange={(e) => setForm({ ...form, labelPl: e.target.value })}
value={form.translations[lang.code] ?? ""}
onChange={(e) =>
setForm({
...form,
translations: { ...form.translations, [lang.code]: e.target.value },
})
}
/>
</div>
))}
</div>
)}
<div className="flex gap-3">
<div className="max-w-32">
<label className="label" htmlFor="section-time-from">
@@ -232,7 +228,7 @@ export default function SectionManager() {
) : (
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{sections.map((section, idx) => (
<li key={section.key} className="flex items-center gap-3 px-4 py-2">
<li key={section.key} className="flex flex-wrap items-center gap-3 px-4 py-2">
<div className="flex flex-col">
<button
type="button"
@@ -267,8 +263,7 @@ export default function SectionManager() {
setForm({
key: section.key,
label: section.label,
labelEn: section.label_en ?? "",
labelPl: section.label_pl ?? "",
translations: { ...section.translations },
timeFrom: from,
timeTo: to,
});
+2 -2
View File
@@ -106,7 +106,7 @@ export default function UserManager({ meId }: { meId: string }) {
return (
<div className="max-w-3xl">
<div className="flex items-center gap-3 mb-4">
<div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.userManager.title}
</h1>
@@ -221,7 +221,7 @@ export default function UserManager({ meId }: { meId: string }) {
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{profiles.map((p) => (
<li key={p.id} className="flex items-center gap-3 px-4 py-2 text-sm">
<li key={p.id} className="flex flex-wrap items-center gap-3 px-4 py-2 text-sm">
<span className="mr-auto">
<span className="font-semibold">{p.full_name || p.email}</span>{" "}
<span className="text-gray-500">
+366 -64
View File
@@ -10,24 +10,36 @@ import {
formatPrice,
mondayOf,
todayISO,
weekdayIndex,
} 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";
import type { ClosedDay, Dish, MenuEntry, Section } from "@/lib/types";
import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
export default function WeekEditor({ monday }: { monday: string }) {
export default function WeekEditor({
monday,
translateEnabled,
}: {
monday: string;
translateEnabled: boolean;
}) {
const dict = useDict();
const locale = useLocale();
const languages = useLanguages().filter((l) => l.code !== "de");
const friday = addDays(monday, 4);
const days = dict.weekdays.long.map((weekday, i) => ({
weekday,
short: dict.weekdays.short[i],
iso: addDays(monday, i),
}));
const isCurrentWeek = monday === mondayOf(todayISO());
const todayIdx = isCurrentWeek ? weekdayIndex(todayISO()) : -1;
const [sections, setSections] = useState<Section[]>([]);
const [dishes, setDishes] = useState<Dish[]>([]);
const [entries, setEntries] = useState<MenuEntry[]>([]);
const [closedDays, setClosedDays] = useState<ClosedDay[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Zelle, in der gerade ein Gericht hinzugefügt wird
@@ -36,6 +48,21 @@ export default function WeekEditor({ monday }: { monday: string }) {
);
const [selectedDish, setSelectedDish] = useState("");
const [dishSearch, setDishSearch] = useState("");
// Mobil: ein Tag auf einmal statt aller fünf gleichzeitig
const [selectedDayIdx, setSelectedDayIdx] = useState(todayIdx >= 0 ? todayIdx : 0);
// Tag, für den gerade das Schließen-Formular offen ist
const [closingIso, setClosingIso] = useState<string | null>(null);
const [closeReason, setCloseReason] = useState("");
const [closeTranslations, setCloseTranslations] = useState<Record<string, string>>({});
const [closeTranslateState, setCloseTranslateState] = useState<"idle" | "busy" | "done">(
"idle"
);
const [closeBusy, setCloseBusy] = useState(false);
function closedDayFor(iso: string): ClosedDay | undefined {
return closedDays.find((c) => c.closed_date === iso);
}
function startAdding(iso: string, section: string) {
setAdding({ iso, section });
@@ -51,17 +78,19 @@ export default function WeekEditor({ monday }: { monday: string }) {
const load = useCallback(async () => {
setError(null);
const [se, di, en] = await Promise.all([
const [se, di, en, cd] = await Promise.all([
fetch("/api/sections"),
fetch("/api/dishes"),
fetch(`/api/menu-entries?from=${monday}&to=${friday}`),
fetch(`/api/closed-days?from=${monday}&to=${friday}`),
]);
if (!se.ok || !di.ok || !en.ok) {
if (!se.ok || !di.ok || !en.ok || !cd.ok) {
setError(formatMessage(dict.weekEditor.loadError, { message: "load failed" }));
} else {
setSections(await se.json());
setDishes(await di.json());
setEntries(await en.json());
setClosedDays(await cd.json());
}
setLoading(false);
}, [monday, friday, dict]);
@@ -101,6 +130,77 @@ export default function WeekEditor({ monday }: { monday: string }) {
await load();
}
function startClosing(iso: string) {
const existing = closedDayFor(iso);
setClosingIso(iso);
setCloseReason(existing?.reason ?? "");
setCloseTranslations({ ...(existing?.translations ?? {}) });
setCloseTranslateState("idle");
}
function cancelClosing() {
setClosingIso(null);
}
async function saveClosing() {
if (!closingIso) return;
setCloseBusy(true);
setError(null);
const res = await fetch("/api/closed-days", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
closed_date: closingIso,
reason: closeReason.trim() || null,
translations: closeTranslations,
}),
});
setCloseBusy(false);
if (!res.ok) {
setError(formatMessage(dict.weekEditor.saveError, { message: String(res.status) }));
return;
}
setClosingIso(null);
await load();
}
async function reopenDay(iso: string) {
setCloseBusy(true);
setError(null);
const res = await fetch(`/api/closed-days/${iso}`, { method: "DELETE" });
setCloseBusy(false);
if (!res.ok) {
setError(formatMessage(dict.weekEditor.deleteError, { message: String(res.status) }));
return;
}
if (closingIso === iso) setClosingIso(null);
await load();
}
async function autoTranslateClosing() {
if (!closingIso || !closeReason.trim()) return;
setCloseTranslateState("busy");
setError(null);
try {
const res = await fetch("/api/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
table: "closed_days",
id: closedDayFor(closingIso) ? closingIso : null,
text: closeReason.trim(),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
setCloseTranslations((prev) => ({ ...prev, ...(data.translations ?? {}) }));
setCloseTranslateState("done");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setCloseTranslateState("idle");
}
}
const [hintBefore, hintAfter] = dict.weekEditor.newDishesHint.split("{link}");
const selectedDishObj = dishes.find((d) => d.id === selectedDish) ?? null;
const query = dishSearch.trim().toLowerCase();
@@ -108,66 +208,146 @@ export default function WeekEditor({ monday }: { monday: string }) {
? dishes.filter((d) => localized(d, locale).toLowerCase().includes(query))
: dishes;
// Kleines Schloss-Symbol neben dem Tagesdatum: 🔓 zum Schließen
// anklicken, 🔒 zeigt einen geschlossenen Tag und öffnet ihn per Klick
// wieder – der Zustand des Symbols selbst ist die Beschriftung.
function renderClosureToggle(iso: string) {
const closed = Boolean(closedDayFor(iso));
return (
<div>
<div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.weekEditor.title}
</h1>
<Link href={`/intern?woche=${addDays(monday, -7)}`} className="btn btn-add">
{dict.publicMenu.prevWeek}
</Link>
<Link
href={`/intern?woche=${mondayOf(todayISO())}`}
className="btn btn-primary"
<button
type="button"
className="mt-0.5 text-base leading-none align-middle opacity-70 hover:opacity-100 disabled:opacity-40"
onClick={() => (closed ? reopenDay(iso) : startClosing(iso))}
disabled={closeBusy}
title={closed ? dict.weekEditor.reopenDay : dict.weekEditor.closeDay}
aria-label={closed ? dict.weekEditor.reopenDay : dict.weekEditor.closeDay}
>
{dict.publicMenu.today}
</Link>
<Link href={`/intern?woche=${addDays(monday, 7)}`} className="btn btn-add">
{dict.publicMenu.nextWeek}
</Link>
</div>
{closed ? "🔒" : "🔓"}
</button>
);
}
{error && (
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{loading ? (
<p className="text-gray-500">{dict.weekEditor.loading}</p>
) : (
<table className="menu-table">
<thead>
<tr>
<th className="section-head w-52" aria-hidden />
{days.map((d) => (
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso, dict.dateLocale)}
</th>
))}
</tr>
</thead>
<tbody>
{sections.map((s) => (
<tr key={s.key}>
<th className="section-head">
<div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => {
const isAdding =
adding?.iso === d.iso && adding?.section === s.key;
// Banner, das bei einem geschlossenen Tag alle Essenszeiten-Zellen
// dieses Tages ersetzt (Desktop: eine über rowSpan verbundene Zelle,
// Mobil: anstelle der Essenszeiten-Karten) – "Bearbeiten" öffnet das
// Formular erneut, um nur den Grund anzupassen, ohne den Tag über das
// Symbol im Header erst wieder öffnen zu müssen.
function renderClosedBanner(iso: string, closed: ClosedDay) {
return (
<td key={d.iso} className="cell">
{entriesFor(entries, d.iso, s.key).map((e) => {
<div className="flex flex-col items-center justify-center gap-1 py-4 text-red-700">
<span className="text-2xl" aria-hidden>
🔒
</span>
<span className="font-semibold">{dict.weekEditor.closedLabel}</span>
{closed.reason && <span className="text-sm px-2 text-center">{closed.reason}</span>}
<button
type="button"
className="mt-1 text-xs text-gray-500 hover:text-[var(--c-primary)] underline"
onClick={() => startClosing(iso)}
>
{dict.common.edit}
</button>
</div>
);
}
// Formular zum Schließen/Bearbeiten eines Tages – ein gemeinsames
// Formular für Desktop-Tabelle und mobile Tagesansicht, da immer nur
// ein Tag gleichzeitig bearbeitet werden kann.
function renderClosingForm() {
if (!closingIso) return null;
const day = days.find((d) => d.iso === closingIso);
return (
<form
onSubmit={(e) => {
e.preventDefault();
saveClosing();
}}
className="card mb-4 space-y-3"
>
<h2 className="font-semibold">
{dict.weekEditor.closeDay}: {day?.weekday} ·{" "}
{formatDateLong(closingIso, dict.dateLocale)}
</h2>
<div>
<label className="label" htmlFor="closed-reason">
{dict.weekEditor.closedReasonLabel}
</label>
<input
id="closed-reason"
className="input"
placeholder={dict.weekEditor.closedReasonPlaceholder}
value={closeReason}
onChange={(e) => setCloseReason(e.target.value)}
/>
</div>
{languages.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{languages.map((lang) => (
<div key={lang.code}>
<label className="label" htmlFor={`closed-reason-${lang.code}`}>
{lang.label}
</label>
<input
id={`closed-reason-${lang.code}`}
className="input"
value={closeTranslations[lang.code] ?? ""}
onChange={(e) =>
setCloseTranslations({
...closeTranslations,
[lang.code]: e.target.value,
})
}
/>
</div>
))}
</div>
)}
{translateEnabled && languages.length > 0 && (
<div>
<button
type="button"
className="btn btn-neutral"
onClick={autoTranslateClosing}
disabled={closeTranslateState === "busy" || !closeReason.trim()}
>
{closeTranslateState === "busy"
? dict.dishManager.translating
: dict.dishManager.autoTranslate}
</button>
{closeTranslateState === "done" && (
<span className="ml-2 text-sm text-green-700">
{dict.dishManager.translated}
</span>
)}
</div>
)}
<div className="flex gap-2">
<button type="submit" className="btn btn-add" disabled={closeBusy}>
{dict.common.save}
</button>
<button type="button" className="btn btn-neutral" onClick={cancelClosing}>
{dict.common.cancel}
</button>
</div>
</form>
);
}
// Inhalt einer Tag/Essenszeit-Zelle: Eintragsliste + "hinzufügen"-Flow.
// Identisches Fragment für Desktop-Tabelle und Mobile-Karten, damit der
// Bearbeitungs-Zustand (adding/selectedDish/dishSearch) nicht dupliziert
// werden muss – nur eine Zelle kann gleichzeitig bearbeitet werden,
// unabhängig davon, welche Ansicht gerade rendert.
function renderCell(iso: string, sectionKey: string) {
const isAdding = adding?.iso === iso && adding?.section === sectionKey;
return (
<>
{entriesFor(entries, iso, sectionKey).map((e) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish">
<span className="font-bold">
{localized(e.dish, locale)}
</span>
<span className="font-bold">{localized(e.dish, locale)}</span>
{codes && <sup className="dish-codes">{codes}</sup>}
{" – "}
{formatPrice(e.dish.price)}
@@ -245,11 +425,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
>
{dict.common.add}
</button>
<button
type="button"
className="btn btn-neutral"
onClick={cancelAdding}
>
<button type="button" className="btn btn-neutral" onClick={cancelAdding}>
{dict.common.cancel}
</button>
</div>
@@ -258,11 +434,135 @@ export default function WeekEditor({ monday }: { monday: string }) {
<button
type="button"
className="btn btn-add mt-1"
onClick={() => startAdding(d.iso, s.key)}
onClick={() => startAdding(iso, sectionKey)}
>
{dict.weekEditor.addMeal}
</button>
)}
</>
);
}
const selectedDay = days[selectedDayIdx];
return (
<div>
<div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.weekEditor.title}
</h1>
<Link href={`/intern?woche=${addDays(monday, -7)}`} className="btn btn-add">
{dict.publicMenu.prevWeek}
</Link>
<Link
href={`/intern?woche=${mondayOf(todayISO())}`}
className="btn btn-primary"
>
{dict.publicMenu.today}
</Link>
<Link href={`/intern?woche=${addDays(monday, 7)}`} className="btn btn-add">
{dict.publicMenu.nextWeek}
</Link>
</div>
{error && (
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{renderClosingForm()}
{loading ? (
<p className="text-gray-500">{dict.weekEditor.loading}</p>
) : (
<>
{/* Mobil (< "sm"): ein Tag auf einmal, per Tabs gewählt */}
<div className="sm:hidden">
<div className="flex gap-1 mb-3">
{days.map((d, i) => (
<button
key={d.iso}
type="button"
onClick={() => setSelectedDayIdx(i)}
className={`flex-1 rounded-md py-1.5 text-sm font-semibold border transition-colors ${
i === selectedDayIdx
? "bg-[var(--c-primary)] text-white border-[var(--c-primary)]"
: "bg-white text-gray-600 border-gray-200"
} ${i === todayIdx ? "ring-2 ring-[var(--c-accent)] ring-offset-1" : ""}`}
>
{d.short}
</button>
))}
</div>
<h2 className="font-bold text-[var(--c-primary)] mb-1">
{selectedDay.weekday} · {formatDateLong(selectedDay.iso, dict.dateLocale)}{" "}
{renderClosureToggle(selectedDay.iso)}
</h2>
{(() => {
const closed = closedDayFor(selectedDay.iso);
if (closed) {
return (
<div className="rounded-md border border-red-200 bg-red-50">
{renderClosedBanner(selectedDay.iso, closed)}
</div>
);
}
return (
<div className="space-y-3">
{sections.map((s) => (
<div key={s.key} className="rounded-md border border-gray-200 overflow-hidden">
<div className="bg-[var(--c-primary)] text-[var(--c-header-text)] px-3 py-1.5 text-sm">
<span className="font-semibold">{localized(s, locale)}</span>{" "}
<span className="opacity-80">· {s.time_range}</span>
</div>
<div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm text-center">
{renderCell(selectedDay.iso, s.key)}
</div>
</div>
))}
</div>
);
})()}
</div>
{/* Ab "sm": klassische Wochentabelle */}
<div className="hidden sm:block">
<table className="menu-table">
<thead>
<tr>
<th className="section-head w-52" aria-hidden />
{days.map((d) => (
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso, dict.dateLocale)} {renderClosureToggle(d.iso)}
</th>
))}
</tr>
</thead>
<tbody>
{sections.map((s, sIdx) => (
<tr key={s.key}>
<th className="section-head">
<div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => {
const closed = closedDayFor(d.iso);
if (closed) {
if (sIdx > 0) return null;
return (
<td
key={d.iso}
className="cell closed-cell"
rowSpan={sections.length}
>
{renderClosedBanner(d.iso, closed)}
</td>
);
}
return (
<td key={d.iso} className="cell">
{renderCell(d.iso, s.key)}
</td>
);
})}
@@ -270,6 +570,8 @@ export default function WeekEditor({ monday }: { monday: string }) {
))}
</tbody>
</table>
</div>
</>
)}
<p className="mt-4 text-sm text-gray-500">
{hintBefore}
+13 -11
View File
@@ -2,9 +2,7 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { LOCALES, LOCALE_LABELS } from "@/lib/i18n/shared";
import type { Locale } from "@/lib/i18n/shared";
import { useLocale } from "@/components/i18n/LocaleProvider";
import { useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
export default function LanguageSwitcher({
className = "",
@@ -14,10 +12,11 @@ export default function LanguageSwitcher({
variant?: "dark" | "light";
}) {
const locale = useLocale();
const languages = useLanguages();
const router = useRouter();
const [busy, setBusy] = useState(false);
async function change(next: Locale) {
async function change(next: string) {
if (next === locale || busy) return;
setBusy(true);
await fetch("/api/locale", {
@@ -29,6 +28,9 @@ export default function LanguageSwitcher({
setBusy(false);
}
// Bei nur einer aktiven Sprache gibt es nichts umzuschalten.
if (languages.length <= 1) return null;
const activeCls =
variant === "dark" ? "bg-white/25 text-white" : "bg-gray-200 text-gray-900";
const inactiveCls =
@@ -38,19 +40,19 @@ export default function LanguageSwitcher({
return (
<div className={`flex gap-1 text-xs ${className}`} aria-label="Sprache">
{LOCALES.map((l) => (
{languages.map((l) => (
<button
key={l}
key={l.code}
type="button"
onClick={() => change(l)}
onClick={() => change(l.code)}
disabled={busy}
aria-current={l === locale}
title={LOCALE_LABELS[l]}
aria-current={l.code === locale}
title={l.label}
className={`rounded px-1.5 py-0.5 uppercase font-semibold transition-colors ${
l === locale ? activeCls : inactiveCls
l.code === locale ? activeCls : inactiveCls
}`}
>
{l}
{l.code}
</button>
))}
</div>
+20 -4
View File
@@ -1,16 +1,23 @@
"use client";
// Stellt Sprache + Wörterbuch für Client-Komponenten bereit. Wird einmal
// im Root-Layout mit dem serverseitig aus dem Cookie gelesenen Locale
// instanziiert; Client-Komponenten lesen darüber via useDict()/useLocale().
// Stellt Sprache + Wörterbuch + Liste der aktiven Sprachen für Client-
// Komponenten bereit. Wird einmal im Root-Layout mit den serverseitig
// ermittelten Werten instanziiert; Client-Komponenten lesen darüber
// via useDict()/useLocale()/useLanguages().
import { createContext, useContext, useMemo } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import type { Dict, Locale } from "@/lib/i18n/shared";
export interface LanguageInfo {
code: string;
label: string;
}
interface LocaleContextValue {
locale: Locale;
dict: Dict;
languages: LanguageInfo[];
}
const LocaleContext = createContext<LocaleContextValue | null>(null);
@@ -18,9 +25,13 @@ const LocaleContext = createContext<LocaleContextValue | null>(null);
export function LocaleProvider({
locale,
dict,
languages,
children,
}: LocaleContextValue & { children: React.ReactNode }) {
const value = useMemo(() => ({ locale, dict }), [locale, dict]);
const value = useMemo(
() => ({ locale, dict, languages }),
[locale, dict, languages]
);
return (
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
);
@@ -40,6 +51,11 @@ export function useLocale(): Locale {
return useLocaleContext().locale;
}
/** Alle aktiven Sprachen (Code + Anzeigename), z. B. für den Sprachumschalter. */
export function useLanguages(): LanguageInfo[] {
return useLocaleContext().languages;
}
/** t("Text mit {var}", { var: "Wert" }) für Platzhalter in Übersetzungen. */
export function useFormatMessage() {
return formatMessage;
+3 -3
View File
@@ -1,5 +1,5 @@
-- =============================================================
-- Canteen Menu – Initiales Schema (selbst gehostet, ohne Supabase)
-- Canteen Menu – Initiales Schema (vollständig selbst gehostet)
-- Rollen: admin (Benutzerverwaltung, Branding) / staff (Kantine)
-- Öffentliche Speisekarte: lesbar ohne Login
-- =============================================================
@@ -27,8 +27,8 @@ create table sessions (
);
create index sessions_user_id_idx on sessions (user_id);
-- WebAuthn-Credentials (ersetzt Supabase's internes, für die App bisher
-- unsichtbares Passkey-Schema).
-- WebAuthn-Credentials (Passkeys) – eigenes Schema, wird von
-- @simplewebauthn/server geprüft (lib/auth/).
create table passkey_credentials (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users (id) on delete cascade,
+11
View File
@@ -0,0 +1,11 @@
-- Umschaltbare Anzeige der Allergene/Zusatzstoffe: entweder die
-- detaillierte Auswahl je Gericht (bisheriges Verhalten) oder
-- stattdessen nur ein von Personal/Admin editierbarer Hinweistext
-- (z. B. "Bei Fragen bitte das Kantinen-Team fragen").
alter table settings
add column labeling_mode text not null default 'detailed'
check (labeling_mode in ('detailed', 'notice')),
add column labeling_notice text not null default
'Bei Fragen zu Allergenen und Zusatzstoffen wenden Sie sich bitte an das Kantinen-Team.',
add column labeling_notice_en text,
add column labeling_notice_pl text;
+12
View File
@@ -0,0 +1,12 @@
-- Kurzlebiger, einmalig verwendbarer State-Store für den OpenID-Connect-
-- Login (SSO, z. B. gegen Authentik). Analog zu webauthn_challenges:
-- der "state"-Wert selbst ist die CSRF-Absicherung für den Callback
-- (der Callback ist ein Cross-Origin-GET vom Identity Provider, der
-- normale isSameOrigin()-Check greift dort nicht).
create table oidc_states (
state text primary key,
nonce text not null,
code_verifier text not null,
expires_at timestamptz not null,
created_at timestamptz not null default now()
);
+34
View File
@@ -0,0 +1,34 @@
-- Dynamisches Sprachpaket-System für die Bedienoberfläche: Sprachen
-- sind ab jetzt zur Laufzeit im Admin-Bereich verwaltbar (aktivieren/
-- deaktivieren, neue Sprache per Datei-Import hinzufügen) statt fest
-- im Code zu stehen. Deutsch bleibt als "de"-Objekt in
-- lib/i18n/dictionaries.ts die immer vollständige Basis-Sprache – sie
-- braucht keine Zeilen in ui_translations, nur einen Eintrag in
-- languages als Referenz für den Sprachumschalter.
create table languages (
code text primary key,
label text not null,
is_base boolean not null default false,
active boolean not null default true,
-- Hash der DE-Vorlage (siehe lib/i18n/flatten.ts + lib/db/languages.ts)
-- zum Zeitpunkt des letzten Imports – weicht der aktuelle Hash davon
-- ab, sind seit dem Import neue Begriffe hinzugekommen ("veraltet").
-- Bei der Basis-Sprache immer null (nie veraltet, da Code = Vorlage).
template_version text,
updated_at timestamptz not null default now()
);
-- Eine Zeile je Sprache + Übersetzungsschlüssel (z. B.
-- "login.errorGeneric"), fehlende Schlüssel fallen zur Laufzeit
-- automatisch auf die deutsche Vorlage zurück statt leer zu bleiben.
create table ui_translations (
language_code text not null references languages(code) on delete cascade,
key text not null,
value text not null,
primary key (language_code, key)
);
insert into languages (code, label, is_base, active, template_version) values
('de', 'Deutsch', true, true, null),
('en', 'English', false, true, '2da2662b794f019f6f7dba99ef93e2035eb38d1a78bd73bdccbb6b3cab447d5d'),
('pl', 'Polski', false, true, '2da2662b794f019f6f7dba99ef93e2035eb38d1a78bd73bdccbb6b3cab447d5d');
+574
View File
@@ -0,0 +1,574 @@
-- Einmaliger Seed: bisherige fest im Code hinterlegte Englisch-/Polnisch-
-- Übersetzungen (lib/i18n/dictionaries.ts) als Sprachpakete in
-- ui_translations überführen, damit nach dem Umbau auf das dynamische
-- Sprachpaket-System (siehe 0004_languages.sql) nichts verloren geht.
insert into ui_translations (language_code, key, value) values
('en', 'common.save', 'Save'),
('en', 'common.cancel', 'Cancel'),
('en', 'common.delete', 'Delete'),
('en', 'common.edit', 'Edit'),
('en', 'common.add', 'Add'),
('en', 'common.back', 'Back'),
('en', 'common.login', 'Log in'),
('en', 'common.logout', 'Log out'),
('en', 'common.loading', 'Loading…'),
('en', 'common.you', '(you)'),
('en', 'common.name', 'Name'),
('en', 'common.email', 'Email address'),
('en', 'common.password', 'Password'),
('en', 'common.role', 'Role'),
('en', 'common.roleAdmin', 'Administrator'),
('en', 'common.roleStaff', 'Canteen staff'),
('en', 'weekdays.long.0', 'Monday'),
('en', 'weekdays.long.1', 'Tuesday'),
('en', 'weekdays.long.2', 'Wednesday'),
('en', 'weekdays.long.3', 'Thursday'),
('en', 'weekdays.long.4', 'Friday'),
('en', 'weekdays.short.0', 'Mon'),
('en', 'weekdays.short.1', 'Tue'),
('en', 'weekdays.short.2', 'Wed'),
('en', 'weekdays.short.3', 'Thu'),
('en', 'weekdays.short.4', 'Fri'),
('en', 'dateLocale', 'en-GB'),
('en', 'header.canteen', 'Canteen'),
('en', 'header.menuGroup', 'Menu'),
('en', 'header.weekPlan', 'Weekly plan'),
('en', 'header.sections', 'Meal times'),
('en', 'header.dishes', 'Dishes'),
('en', 'header.labels', 'Allergens & Additives'),
('en', 'header.adminGroup', 'Administration'),
('en', 'header.users', 'Users'),
('en', 'header.design', 'Design'),
('en', 'header.languages', 'Languages'),
('en', 'header.suggestions', 'Suggestions'),
('en', 'header.openSuggestions', '{count} open dish suggestions'),
('en', 'header.viewMenu', 'View menu'),
('en', 'header.myAccount', 'My account'),
('en', 'header.menuToggle', 'Open/close menu'),
('en', 'login.title', 'Sign in'),
('en', 'login.submit', 'Log in'),
('en', 'login.or', 'or'),
('en', 'login.passkeyButton', 'Sign in with passkey'),
('en', 'login.ssoButton', 'Sign in with Single Sign-On'),
('en', 'login.backToMenu', '← Back to menu'),
('en', 'login.errorInvalidCredentials', 'Email address or password is incorrect.'),
('en', 'login.errorPasskeyCancelled', 'Passkey sign-in was cancelled.'),
('en', 'login.errorGeneric', 'Sign-in failed: {message}'),
('en', 'login.errorPasswordDisabled', 'Password sign-in is disabled for this account. Please sign in with a passkey.'),
('en', 'login.errorOidcInvalidState', 'The SSO sign-in expired or was invalid. Please try again.'),
('en', 'login.errorOidcUnknownUser', 'No account exists for this email address. Please contact your administrator.'),
('en', 'login.errorOidcFailed', 'SSO sign-in failed. Please try again.'),
('en', 'publicMenu.prevWeek', '← Previous week'),
('en', 'publicMenu.nextWeek', 'Next week →'),
('en', 'publicMenu.prevWeekShort', '← Week'),
('en', 'publicMenu.nextWeekShort', 'Week →'),
('en', 'publicMenu.today', 'Today'),
('en', 'publicMenu.dayTab', 'Day'),
('en', 'publicMenu.weekTab', 'Week'),
('en', 'publicMenu.allergens', 'Allergens'),
('en', 'publicMenu.additives', 'Additives'),
('en', 'publicMenu.labelingNoticeTitle', 'Allergens & additives'),
('en', 'publicMenu.suggestDish', 'Suggest a dish'),
('en', 'publicMenu.noEntry', '–'),
('en', 'publicMenu.rssFeed', 'Subscribe to RSS feed'),
('en', 'weekEditor.title', 'Weekly plan'),
('en', 'weekEditor.loading', 'Loading weekly plan…'),
('en', 'weekEditor.changeDish', 'Choose a different dish'),
('en', 'weekEditor.addMeal', 'Add meal'),
('en', 'weekEditor.newDishesHint', 'Add new dishes under {link}.'),
('en', 'weekEditor.newDishesLinkLabel', 'Dishes'),
('en', 'weekEditor.loadError', 'Loading failed: {message}'),
('en', 'weekEditor.duplicateEntry', 'This dish is already assigned on this day.'),
('en', 'weekEditor.saveError', 'Saving failed: {message}'),
('en', 'weekEditor.deleteError', 'Deleting failed: {message}'),
('en', 'dishManager.title', 'Dishes'),
('en', 'dishManager.newDish', 'New dish'),
('en', 'dishManager.editDish', 'Edit dish'),
('en', 'dishManager.nameLabel', 'Name'),
('en', 'dishManager.nameEnLabel', 'Name (English)'),
('en', 'dishManager.namePlLabel', 'Name (Polish)'),
('en', 'dishManager.autoTranslate', 'Auto-translate'),
('en', 'dishManager.translating', 'Translating…'),
('en', 'dishManager.translated', 'Translated.'),
('en', 'dishManager.priceLabel', 'Price in €'),
('en', 'dishManager.pricePlaceholder', '3.50'),
('en', 'dishManager.searchPlaceholder', 'Search dishes…'),
('en', 'dishManager.selectAll', 'Select all'),
('en', 'dishManager.selectedCount', '{count} selected'),
('en', 'dishManager.deleteSelected', 'Delete selected'),
('en', 'dishManager.deleteSelectedCount', 'Delete selected ({count})'),
('en', 'dishManager.empty', 'No dishes yet.'),
('en', 'dishManager.noResults', 'No results for “{query}”.'),
('en', 'dishManager.confirmDelete', 'Really delete “{name}”? The dish will also be removed from all weekly plans.'),
('en', 'dishManager.confirmDeleteSelected', 'Really delete {count} dish(es)? They will also be removed from all weekly plans.'),
('en', 'dishManager.invalidPrice', 'Please enter a valid price, e.g. 3.50'),
('en', 'dishManager.loadError', 'Loading failed: {message}'),
('en', 'dishManager.saveError', 'Saving failed: {message}'),
('en', 'dishManager.deleteError', 'Deleting failed: {message}'),
('en', 'dishManager.exportButton', 'Export'),
('en', 'dishManager.importButton', 'Import'),
('en', 'dishManager.exportError', 'Export failed: {message}'),
('en', 'dishManager.importError', 'Import failed: {message}'),
('en', 'dishManager.importSuccess', '{created} created, {updated} updated.'),
('en', 'dishManager.importSkipped', '{count} row(s) skipped: {reasons}'),
('en', 'dishManager.importSkippedRow', 'Row {row}: {reason}'),
('en', 'dishManager.labelingDisabledHint', 'Allergens/additives are currently disabled (notice mode active, see "Allergens & additives").'),
('en', 'codeList.allergensTitle', 'Allergens'),
('en', 'codeList.additivesTitle', 'Additives'),
('en', 'codeList.allergenCodeLabel', 'Code (e.g. A)'),
('en', 'codeList.additiveCodeLabel', 'Code (e.g. 1)'),
('en', 'codeList.nameLabel', 'Name'),
('en', 'codeList.empty', 'No entries.'),
('en', 'codeList.confirmDelete', 'Really delete “{code} – {name}”?'),
('en', 'codeList.duplicateCode', 'The code “{code}” already exists.'),
('en', 'codeList.loadError', 'Loading failed: {message}'),
('en', 'codeList.saveError', 'Saving failed: {message}'),
('en', 'codeList.deleteError', 'Deleting failed: {message}'),
('en', 'codeList.exportButton', 'Export'),
('en', 'codeList.importButton', 'Import'),
('en', 'codeList.exportError', 'Export failed: {message}'),
('en', 'codeList.importError', 'Import failed: {message}'),
('en', 'codeList.importSuccess', '{created} created, {updated} updated.'),
('en', 'codeList.importSkipped', '{count} row(s) skipped: {reasons}'),
('en', 'codeList.importSkippedRow', 'Row {row}: {reason}'),
('en', 'sectionManager.title', 'Meal times'),
('en', 'sectionManager.newSection', 'New meal time'),
('en', 'sectionManager.editSection', 'Edit meal time'),
('en', 'sectionManager.nameLabel', 'Name'),
('en', 'sectionManager.nameEnLabel', 'Name (English)'),
('en', 'sectionManager.namePlLabel', 'Name (Polish)'),
('en', 'sectionManager.timeFrom', 'From'),
('en', 'sectionManager.timeTo', 'To'),
('en', 'sectionManager.moveUp', 'Move up'),
('en', 'sectionManager.moveDown', 'Move down'),
('en', 'sectionManager.empty', 'No meal times yet.'),
('en', 'sectionManager.confirmDelete', 'Really delete “{name}”? Assigned dishes will also be removed from all weekly plans.'),
('en', 'sectionManager.loadError', 'Loading failed: {message}'),
('en', 'sectionManager.saveError', 'Saving failed: {message}'),
('en', 'sectionManager.deleteError', 'Deleting failed: {message}'),
('en', 'userManager.title', 'Users'),
('en', 'userManager.newUser', 'New user'),
('en', 'userManager.editUser', 'Edit user'),
('en', 'userManager.newPasswordLabel', 'New password (optional)'),
('en', 'userManager.repeatPasswordLabel', 'Repeat password'),
('en', 'userManager.passwordMismatch', 'The passwords do not match.'),
('en', 'userManager.confirmDelete', 'Really delete user “{email}”? Access will be permanently removed.'),
('en', 'userManager.loadError', 'Loading failed: {message}'),
('en', 'designEditor.title', 'Design'),
('en', 'designEditor.logoTitle', 'Logo'),
('en', 'designEditor.logoHint', 'The logo is displayed centered above the menu.'),
('en', 'designEditor.logoNone', 'No logo set.'),
('en', 'designEditor.logoUpload', 'Upload logo'),
('en', 'designEditor.logoRemove', 'Remove logo'),
('en', 'designEditor.logoUpdated', 'Logo updated.'),
('en', 'designEditor.logoRemoved', 'Logo removed.'),
('en', 'designEditor.faviconTitle', 'Favicon'),
('en', 'designEditor.faviconHint', 'Shown as the icon in the browser tab. Without a custom favicon, the application''s default icon is used.'),
('en', 'designEditor.faviconNone', 'No custom favicon set.'),
('en', 'designEditor.faviconUpload', 'Upload favicon'),
('en', 'designEditor.faviconRemove', 'Remove favicon'),
('en', 'designEditor.faviconUpdated', 'Favicon updated.'),
('en', 'designEditor.faviconRemoved', 'Favicon removed.'),
('en', 'designEditor.invalidImage', 'Please choose an image file (PNG, JPG, SVG, WebP, ICO).'),
('en', 'designEditor.colorsTitle', 'Colors'),
('en', 'designEditor.colorsSave', 'Save colors'),
('en', 'designEditor.colorsReset', 'Reset to default'),
('en', 'designEditor.colorsSaved', 'Colors saved.'),
('en', 'designEditor.colorPrimary', 'Primary color (table header)'),
('en', 'designEditor.colorPrimaryDark', 'Primary color, dark (internal top bar)'),
('en', 'designEditor.colorAccent', 'Accent color'),
('en', 'designEditor.colorHeaderText', 'Table header text color'),
('en', 'designEditor.colorRowBg', 'Cell background'),
('en', 'designEditor.colorRowBgAlt', 'Cell background (alternating)'),
('en', 'designEditor.colorAddBtn', '“Add” button'),
('en', 'designEditor.colorDeleteBtn', '“Delete” button'),
('en', 'designEditor.loadError', 'Loading failed: {message}'),
('en', 'designEditor.saveError', 'Saving failed: {message}'),
('en', 'designEditor.uploadError', 'Upload failed: {message}'),
('en', 'designEditor.confirmRemoveLogo', 'Really remove the logo?'),
('en', 'designEditor.confirmRemoveFavicon', 'Really remove the favicon?'),
('en', 'labelingNotice.title', 'Allergen & additive display'),
('en', 'labelingNotice.hint', 'By default every guest sees a detailed allergen/additive selection per dish. Alternatively, only a notice text can be shown instead – the per-dish selection is then also hidden in the dish manager.'),
('en', 'labelingNotice.modeDetailed', 'Show detailed selection per dish'),
('en', 'labelingNotice.modeNotice', 'Show only notice text'),
('en', 'labelingNotice.noticeDeLabel', 'Notice text (German)'),
('en', 'labelingNotice.noticeEnLabel', 'Notice text (English)'),
('en', 'labelingNotice.noticePlLabel', 'Notice text (Polish)'),
('en', 'labelingNotice.save', 'Save'),
('en', 'labelingNotice.saved', 'Saved.'),
('en', 'labelingNotice.loadError', 'Loading failed: {message}'),
('en', 'labelingNotice.saveError', 'Saving failed: {message}'),
('en', 'passkeys.title', 'Passkeys'),
('en', 'passkeys.hint', 'With a passkey (fingerprint, face recognition, or a security key) you can sign in without a password in the future. Passkeys are bound to a domain – after a domain change they need to be registered again.'),
('en', 'passkeys.register', 'Register new passkey'),
('en', 'passkeys.empty', 'No passkeys registered yet.'),
('en', 'passkeys.createdOn', '– created on {date}'),
('en', 'passkeys.registered', 'Passkey registered successfully.'),
('en', 'passkeys.registerError', 'Registration failed: {message}'),
('en', 'passkeys.registerCancelled', 'Passkey registration was cancelled.'),
('en', 'passkeys.loadError', 'Could not load passkeys: {message}'),
('en', 'passkeys.deleteError', 'Deleting failed: {message}'),
('en', 'passkeys.confirmDelete', 'Really delete this passkey?'),
('en', 'account.title', 'My account'),
('en', 'account.personalTitle', 'Personal details'),
('en', 'account.saveName', 'Save name'),
('en', 'account.nameSaved', 'Name saved.'),
('en', 'account.nameRequired', 'Please enter a name.'),
('en', 'account.emailTitle', 'Email address'),
('en', 'account.changeEmail', 'Change email address'),
('en', 'account.emailChanged', 'Email address changed.'),
('en', 'account.passwordTitle', 'Change password'),
('en', 'account.currentPassword', 'Current password (for verification)'),
('en', 'account.newPassword', 'New password'),
('en', 'account.newPasswordRepeat', 'Repeat new password'),
('en', 'account.changePassword', 'Change password'),
('en', 'account.passwordChanged', 'Password changed.'),
('en', 'account.passwordMismatch', 'The passwords do not match.'),
('en', 'account.passwordTooShort', 'The new password must be at least 8 characters long.'),
('en', 'account.passwordAuthTitle', 'Password sign-in'),
('en', 'account.passwordAuthHint', 'You can disable email and password sign-in for your account and sign in exclusively with a passkey in the future. At least one passkey must be registered for this.'),
('en', 'account.passwordAuthCheckbox', 'Email and password sign-in allowed'),
('en', 'account.passwordAuthEnabled', 'Password sign-in enabled.'),
('en', 'account.passwordAuthDisabled', 'Password sign-in disabled.'),
('en', 'account.passwordAuthNeedsPasskey', 'You can''t disable password sign-in without a registered passkey – you would lock yourself out. Register a passkey below first.'),
('en', 'account.confirmDisablePasswordAuth', 'Really disable email and password sign-in? Afterwards you can only sign in with a passkey.'),
('en', 'account.wrongCurrentPassword', 'The current password is incorrect.'),
('en', 'account.emailAlreadyUsed', 'This email address is already in use.'),
('en', 'account.saveError', 'Saving failed: {message}'),
('en', 'account.changeError', 'Change failed: {message}'),
('en', 'suggestions.formTitle', 'Suggest a dish'),
('en', 'suggestions.formHint', 'Missing a dish on the menu? Suggest it here – the canteen team will review your suggestion.'),
('en', 'suggestions.nameLabel', 'Dish name'),
('en', 'suggestions.descriptionLabel', 'Description / ingredients (optional)'),
('en', 'suggestions.submit', 'Submit suggestion'),
('en', 'suggestions.submitting', 'Submitting…'),
('en', 'suggestions.success', 'Thanks! Your suggestion has been submitted.'),
('en', 'suggestions.duplicate', 'This dish already exists on the menu.'),
('en', 'suggestions.genericError', 'Submission failed: {message}'),
('en', 'suggestions.nameRequired', 'Please enter a name.'),
('en', 'suggestions.backToMenu', '← Back to menu'),
('en', 'suggestions.reviewTitle', 'Dish suggestions'),
('en', 'suggestions.reviewEmpty', 'No suggestions yet.'),
('en', 'suggestions.accept', 'Add as dish'),
('en', 'suggestions.reject', 'Reject'),
('en', 'suggestions.confirmReject', 'Really reject and delete the suggestion “{name}”?'),
('en', 'suggestions.accepted', 'Added as dish.'),
('en', 'suggestions.rejected', 'Suggestion rejected.'),
('en', 'suggestions.loadError', 'Loading failed: {message}'),
('en', 'suggestions.actionError', 'Action failed: {message}'),
('en', 'suggestions.submittedOn', 'submitted on {date}'),
('en', 'languageManager.title', 'Languages'),
('en', 'languageManager.hint', 'German is the fixed base language and always complete. Additional languages are imported as a translation file and automatically filled in with German wherever a term is missing.'),
('en', 'languageManager.codeColumn', 'Code'),
('en', 'languageManager.labelColumn', 'Display name'),
('en', 'languageManager.activeColumn', 'Active'),
('en', 'languageManager.completenessColumn', 'Completeness'),
('en', 'languageManager.baseBadge', 'Base language'),
('en', 'languageManager.complete', 'Complete'),
('en', 'languageManager.completeness', '{percent}% translated'),
('en', 'languageManager.outdated', '{count} new term(s) since last import'),
('en', 'languageManager.downloadTemplate', 'Download template'),
('en', 'languageManager.uploadTranslation', 'Upload translation'),
('en', 'languageManager.newLanguageTitle', 'Add new language'),
('en', 'languageManager.newLanguageCodeLabel', 'Language code (e.g. fr)'),
('en', 'languageManager.newLanguageLabelLabel', 'Display name (e.g. Français)'),
('en', 'languageManager.create', 'Create'),
('en', 'languageManager.delete', 'Delete'),
('en', 'languageManager.confirmDelete', 'Really delete “{label}”? All translations for this language will be lost.'),
('en', 'languageManager.cannotDeleteBase', 'The base language cannot be deleted.'),
('en', 'languageManager.cannotDeactivateLast', 'At least one language must stay active.'),
('en', 'languageManager.loadError', 'Loading failed: {message}'),
('en', 'languageManager.saveError', 'Saving failed: {message}'),
('en', 'languageManager.deleteError', 'Deleting failed: {message}'),
('en', 'languageManager.importSuccess', '{count} terms imported.'),
('en', 'languageManager.importError', 'Import failed: {message}'),
('en', 'languageManager.importInvalidFile', 'The file could not be read (invalid JSON).'),
('en', 'languageManager.importMissingPlaceholder', '“{key}”: placeholder {placeholder} missing in the translation – row skipped.'),
('en', 'languageManager.duplicateCode', 'The language code “{code}” already exists.'),
('en', 'languageManager.invalidCode', 'Please enter a language code of 2-5 lowercase letters.');
insert into ui_translations (language_code, key, value) values
('pl', 'common.save', 'Zapisz'),
('pl', 'common.cancel', 'Anuluj'),
('pl', 'common.delete', 'Usuń'),
('pl', 'common.edit', 'Edytuj'),
('pl', 'common.add', 'Dodaj'),
('pl', 'common.back', 'Wstecz'),
('pl', 'common.login', 'Zaloguj się'),
('pl', 'common.logout', 'Wyloguj się'),
('pl', 'common.loading', 'Wczytywanie…'),
('pl', 'common.you', '(ty)'),
('pl', 'common.name', 'Nazwa'),
('pl', 'common.email', 'Adres e-mail'),
('pl', 'common.password', 'Hasło'),
('pl', 'common.role', 'Rola'),
('pl', 'common.roleAdmin', 'Administrator'),
('pl', 'common.roleStaff', 'Personel stołówki'),
('pl', 'weekdays.long.0', 'Poniedziałek'),
('pl', 'weekdays.long.1', 'Wtorek'),
('pl', 'weekdays.long.2', 'Środa'),
('pl', 'weekdays.long.3', 'Czwartek'),
('pl', 'weekdays.long.4', 'Piątek'),
('pl', 'weekdays.short.0', 'Pon'),
('pl', 'weekdays.short.1', 'Wto'),
('pl', 'weekdays.short.2', 'Śro'),
('pl', 'weekdays.short.3', 'Czw'),
('pl', 'weekdays.short.4', 'Pią'),
('pl', 'dateLocale', 'pl-PL'),
('pl', 'header.canteen', 'Stołówka'),
('pl', 'header.menuGroup', 'Jadłospis'),
('pl', 'header.weekPlan', 'Plan tygodnia'),
('pl', 'header.sections', 'Pory posiłków'),
('pl', 'header.dishes', 'Dania'),
('pl', 'header.labels', 'Alergeny i dodatki'),
('pl', 'header.adminGroup', 'Administracja'),
('pl', 'header.users', 'Użytkownicy'),
('pl', 'header.design', 'Wygląd'),
('pl', 'header.languages', 'Języki'),
('pl', 'header.suggestions', 'Propozycje'),
('pl', 'header.openSuggestions', '{count} otwartych propozycji dań'),
('pl', 'header.viewMenu', 'Zobacz jadłospis'),
('pl', 'header.myAccount', 'Moje konto'),
('pl', 'header.menuToggle', 'Otwórz/zamknij menu'),
('pl', 'login.title', 'Logowanie'),
('pl', 'login.submit', 'Zaloguj się'),
('pl', 'login.or', 'lub'),
('pl', 'login.passkeyButton', 'Zaloguj się kluczem dostępu'),
('pl', 'login.ssoButton', 'Zaloguj się przez Single Sign-On'),
('pl', 'login.backToMenu', '← Powrót do jadłospisu'),
('pl', 'login.errorInvalidCredentials', 'Nieprawidłowy adres e-mail lub hasło.'),
('pl', 'login.errorPasskeyCancelled', 'Logowanie kluczem dostępu zostało anulowane.'),
('pl', 'login.errorGeneric', 'Logowanie nie powiodło się: {message}'),
('pl', 'login.errorPasswordDisabled', 'Logowanie hasłem jest wyłączone dla tego konta. Zaloguj się kluczem dostępu.'),
('pl', 'login.errorOidcInvalidState', 'Logowanie SSO wygasło lub jest nieprawidłowe. Spróbuj ponownie.'),
('pl', 'login.errorOidcUnknownUser', 'Nie istnieje konto dla tego adresu e-mail. Skontaktuj się z administracją.'),
('pl', 'login.errorOidcFailed', 'Logowanie SSO nie powiodło się. Spróbuj ponownie.'),
('pl', 'publicMenu.prevWeek', '← Poprzedni tydzień'),
('pl', 'publicMenu.nextWeek', 'Następny tydzień →'),
('pl', 'publicMenu.prevWeekShort', '← Tydzień'),
('pl', 'publicMenu.nextWeekShort', 'Tydzień →'),
('pl', 'publicMenu.today', 'Dziś'),
('pl', 'publicMenu.dayTab', 'Dzień'),
('pl', 'publicMenu.weekTab', 'Tydzień'),
('pl', 'publicMenu.allergens', 'Alergeny'),
('pl', 'publicMenu.additives', 'Dodatki'),
('pl', 'publicMenu.labelingNoticeTitle', 'Alergeny i dodatki'),
('pl', 'publicMenu.suggestDish', 'Zaproponuj danie'),
('pl', 'publicMenu.noEntry', '–'),
('pl', 'publicMenu.rssFeed', 'Subskrybuj kanał RSS'),
('pl', 'weekEditor.title', 'Plan tygodnia'),
('pl', 'weekEditor.loading', 'Wczytywanie planu tygodnia…'),
('pl', 'weekEditor.changeDish', 'Wybierz inne danie'),
('pl', 'weekEditor.addMeal', 'Dodaj posiłek'),
('pl', 'weekEditor.newDishesHint', 'Nowe dania możesz dodać w zakładce {link}.'),
('pl', 'weekEditor.newDishesLinkLabel', 'Dania'),
('pl', 'weekEditor.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'weekEditor.duplicateEntry', 'To danie jest już przypisane na ten dzień.'),
('pl', 'weekEditor.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'weekEditor.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'dishManager.title', 'Dania'),
('pl', 'dishManager.newDish', 'Nowe danie'),
('pl', 'dishManager.editDish', 'Edytuj danie'),
('pl', 'dishManager.nameLabel', 'Nazwa'),
('pl', 'dishManager.nameEnLabel', 'Nazwa (angielski)'),
('pl', 'dishManager.namePlLabel', 'Nazwa (polski)'),
('pl', 'dishManager.autoTranslate', 'Przetłumacz automatycznie'),
('pl', 'dishManager.translating', 'Tłumaczenie…'),
('pl', 'dishManager.translated', 'Przetłumaczono.'),
('pl', 'dishManager.priceLabel', 'Cena w €'),
('pl', 'dishManager.pricePlaceholder', '3,50'),
('pl', 'dishManager.searchPlaceholder', 'Szukaj dania…'),
('pl', 'dishManager.selectAll', 'Zaznacz wszystko'),
('pl', 'dishManager.selectedCount', 'Zaznaczono: {count}'),
('pl', 'dishManager.deleteSelected', 'Usuń zaznaczone'),
('pl', 'dishManager.deleteSelectedCount', 'Usuń zaznaczone ({count})'),
('pl', 'dishManager.empty', 'Nie dodano jeszcze żadnych dań.'),
('pl', 'dishManager.noResults', 'Brak wyników dla „{query}”.'),
('pl', 'dishManager.confirmDelete', 'Czy na pewno usunąć „{name}”? Danie zostanie również usunięte ze wszystkich planów tygodniowych.'),
('pl', 'dishManager.confirmDeleteSelected', 'Czy na pewno usunąć {count} danie/dania? Zostaną również usunięte ze wszystkich planów tygodniowych.'),
('pl', 'dishManager.invalidPrice', 'Podaj prawidłową cenę, np. 3,50'),
('pl', 'dishManager.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'dishManager.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'dishManager.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'dishManager.exportButton', 'Eksportuj'),
('pl', 'dishManager.importButton', 'Importuj'),
('pl', 'dishManager.exportError', 'Eksport nie powiódł się: {message}'),
('pl', 'dishManager.importError', 'Import nie powiódł się: {message}'),
('pl', 'dishManager.importSuccess', 'Utworzono: {created}, zaktualizowano: {updated}.'),
('pl', 'dishManager.importSkipped', 'Pominięto wierszy: {count}: {reasons}'),
('pl', 'dishManager.importSkippedRow', 'Wiersz {row}: {reason}'),
('pl', 'dishManager.labelingDisabledHint', 'Alergeny/dodatki są obecnie wyłączone (aktywny tryb komunikatu, patrz „Alergeny i dodatki”).'),
('pl', 'codeList.allergensTitle', 'Alergeny'),
('pl', 'codeList.additivesTitle', 'Dodatki'),
('pl', 'codeList.allergenCodeLabel', 'Kod (np. A)'),
('pl', 'codeList.additiveCodeLabel', 'Kod (np. 1)'),
('pl', 'codeList.nameLabel', 'Nazwa'),
('pl', 'codeList.empty', 'Brak wpisów.'),
('pl', 'codeList.confirmDelete', 'Czy na pewno usunąć „{code} – {name}”?'),
('pl', 'codeList.duplicateCode', 'Kod „{code}” już istnieje.'),
('pl', 'codeList.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'codeList.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'codeList.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'codeList.exportButton', 'Eksportuj'),
('pl', 'codeList.importButton', 'Importuj'),
('pl', 'codeList.exportError', 'Eksport nie powiódł się: {message}'),
('pl', 'codeList.importError', 'Import nie powiódł się: {message}'),
('pl', 'codeList.importSuccess', 'Utworzono: {created}, zaktualizowano: {updated}.'),
('pl', 'codeList.importSkipped', 'Pominięto wierszy: {count}: {reasons}'),
('pl', 'codeList.importSkippedRow', 'Wiersz {row}: {reason}'),
('pl', 'sectionManager.title', 'Pory posiłków'),
('pl', 'sectionManager.newSection', 'Nowa pora posiłku'),
('pl', 'sectionManager.editSection', 'Edytuj porę posiłku'),
('pl', 'sectionManager.nameLabel', 'Nazwa'),
('pl', 'sectionManager.nameEnLabel', 'Nazwa (angielski)'),
('pl', 'sectionManager.namePlLabel', 'Nazwa (polski)'),
('pl', 'sectionManager.timeFrom', 'Od'),
('pl', 'sectionManager.timeTo', 'Do'),
('pl', 'sectionManager.moveUp', 'Przesuń w górę'),
('pl', 'sectionManager.moveDown', 'Przesuń w dół'),
('pl', 'sectionManager.empty', 'Nie dodano jeszcze żadnych pór posiłków.'),
('pl', 'sectionManager.confirmDelete', 'Czy na pewno usunąć „{name}”? Przypisane dania zostaną również usunięte ze wszystkich planów tygodniowych.'),
('pl', 'sectionManager.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'sectionManager.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'sectionManager.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'userManager.title', 'Użytkownicy'),
('pl', 'userManager.newUser', 'Nowy użytkownik'),
('pl', 'userManager.editUser', 'Edytuj użytkownika'),
('pl', 'userManager.newPasswordLabel', 'Nowe hasło (opcjonalnie)'),
('pl', 'userManager.repeatPasswordLabel', 'Powtórz hasło'),
('pl', 'userManager.passwordMismatch', 'Hasła nie są zgodne.'),
('pl', 'userManager.confirmDelete', 'Czy na pewno usunąć użytkownika „{email}”? Dostęp zostanie trwale usunięty.'),
('pl', 'userManager.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'designEditor.title', 'Wygląd'),
('pl', 'designEditor.logoTitle', 'Logo'),
('pl', 'designEditor.logoHint', 'Logo jest wyświetlane wyśrodkowane nad jadłospisem.'),
('pl', 'designEditor.logoNone', 'Nie ustawiono logo.'),
('pl', 'designEditor.logoUpload', 'Prześlij logo'),
('pl', 'designEditor.logoRemove', 'Usuń logo'),
('pl', 'designEditor.logoUpdated', 'Logo zaktualizowane.'),
('pl', 'designEditor.logoRemoved', 'Logo usunięte.'),
('pl', 'designEditor.faviconTitle', 'Favicon'),
('pl', 'designEditor.faviconHint', 'Wyświetlane jako ikona na karcie przeglądarki. Bez własnego favicona używana jest domyślna ikona aplikacji.'),
('pl', 'designEditor.faviconNone', 'Nie ustawiono własnego favicona.'),
('pl', 'designEditor.faviconUpload', 'Prześlij favicon'),
('pl', 'designEditor.faviconRemove', 'Usuń favicon'),
('pl', 'designEditor.faviconUpdated', 'Favicon zaktualizowany.'),
('pl', 'designEditor.faviconRemoved', 'Favicon usunięty.'),
('pl', 'designEditor.invalidImage', 'Wybierz plik graficzny (PNG, JPG, SVG, WebP, ICO).'),
('pl', 'designEditor.colorsTitle', 'Kolorystyka'),
('pl', 'designEditor.colorsSave', 'Zapisz kolory'),
('pl', 'designEditor.colorsReset', 'Przywróć domyślne'),
('pl', 'designEditor.colorsSaved', 'Kolory zapisane.'),
('pl', 'designEditor.colorPrimary', 'Kolor główny (nagłówek tabeli)'),
('pl', 'designEditor.colorPrimaryDark', 'Kolor główny, ciemny (górny pasek wewnętrzny)'),
('pl', 'designEditor.colorAccent', 'Kolor akcentu'),
('pl', 'designEditor.colorHeaderText', 'Kolor tekstu w nagłówku tabeli'),
('pl', 'designEditor.colorRowBg', 'Tło komórek'),
('pl', 'designEditor.colorRowBgAlt', 'Tło komórek (naprzemienne)'),
('pl', 'designEditor.colorAddBtn', 'Przycisk „Dodaj”'),
('pl', 'designEditor.colorDeleteBtn', 'Przycisk „Usuń”'),
('pl', 'designEditor.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'designEditor.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'designEditor.uploadError', 'Przesyłanie nie powiodło się: {message}'),
('pl', 'designEditor.confirmRemoveLogo', 'Czy na pewno usunąć logo?'),
('pl', 'designEditor.confirmRemoveFavicon', 'Czy na pewno usunąć favicon?'),
('pl', 'labelingNotice.title', 'Wyświetlanie alergenów i dodatków'),
('pl', 'labelingNotice.hint', 'Domyślnie każdy gość widzi szczegółowy wybór alergenów/dodatków dla każdego dania. Alternatywnie można zamiast tego wyświetlić tylko komunikat – wybór dla poszczególnych dań zostanie wtedy również ukryty w zarządzaniu daniami.'),
('pl', 'labelingNotice.modeDetailed', 'Pokaż szczegółowy wybór dla każdego dania'),
('pl', 'labelingNotice.modeNotice', 'Pokaż tylko komunikat'),
('pl', 'labelingNotice.noticeDeLabel', 'Tekst komunikatu (niemiecki)'),
('pl', 'labelingNotice.noticeEnLabel', 'Tekst komunikatu (angielski)'),
('pl', 'labelingNotice.noticePlLabel', 'Tekst komunikatu (polski)'),
('pl', 'labelingNotice.save', 'Zapisz'),
('pl', 'labelingNotice.saved', 'Zapisano.'),
('pl', 'labelingNotice.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'labelingNotice.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'passkeys.title', 'Klucze dostępu'),
('pl', 'passkeys.hint', 'Dzięki kluczowi dostępu (odcisk palca, rozpoznawanie twarzy lub klucz bezpieczeństwa) możesz w przyszłości logować się bez hasła. Klucze dostępu są przypisane do domeny – po zmianie domeny trzeba je zarejestrować ponownie.'),
('pl', 'passkeys.register', 'Zarejestruj nowy klucz dostępu'),
('pl', 'passkeys.empty', 'Nie zarejestrowano jeszcze żadnych kluczy dostępu.'),
('pl', 'passkeys.createdOn', '– utworzono {date}'),
('pl', 'passkeys.registered', 'Klucz dostępu został pomyślnie zarejestrowany.'),
('pl', 'passkeys.registerError', 'Rejestracja nie powiodła się: {message}'),
('pl', 'passkeys.registerCancelled', 'Rejestracja klucza dostępu została anulowana.'),
('pl', 'passkeys.loadError', 'Nie udało się wczytać kluczy dostępu: {message}'),
('pl', 'passkeys.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'passkeys.confirmDelete', 'Czy na pewno usunąć ten klucz dostępu?'),
('pl', 'account.title', 'Moje konto'),
('pl', 'account.personalTitle', 'Dane osobowe'),
('pl', 'account.saveName', 'Zapisz nazwę'),
('pl', 'account.nameSaved', 'Nazwa zapisana.'),
('pl', 'account.nameRequired', 'Podaj nazwę.'),
('pl', 'account.emailTitle', 'Adres e-mail'),
('pl', 'account.changeEmail', 'Zmień adres e-mail'),
('pl', 'account.emailChanged', 'Adres e-mail zmieniony.'),
('pl', 'account.passwordTitle', 'Zmień hasło'),
('pl', 'account.currentPassword', 'Obecne hasło (do weryfikacji)'),
('pl', 'account.newPassword', 'Nowe hasło'),
('pl', 'account.newPasswordRepeat', 'Powtórz nowe hasło'),
('pl', 'account.changePassword', 'Zmień hasło'),
('pl', 'account.passwordChanged', 'Hasło zmienione.'),
('pl', 'account.passwordMismatch', 'Hasła nie są zgodne.'),
('pl', 'account.passwordTooShort', 'Nowe hasło musi mieć co najmniej 8 znaków.'),
('pl', 'account.passwordAuthTitle', 'Logowanie hasłem'),
('pl', 'account.passwordAuthHint', 'Możesz wyłączyć logowanie e-mailem i hasłem dla swojego konta i logować się w przyszłości wyłącznie kluczem dostępu. Wymaga to zarejestrowania co najmniej jednego klucza dostępu.'),
('pl', 'account.passwordAuthCheckbox', 'Logowanie e-mailem i hasłem dozwolone'),
('pl', 'account.passwordAuthEnabled', 'Logowanie hasłem włączone.'),
('pl', 'account.passwordAuthDisabled', 'Logowanie hasłem wyłączone.'),
('pl', 'account.passwordAuthNeedsPasskey', 'Bez zarejestrowanego klucza dostępu nie możesz wyłączyć logowania hasłem – stracisz dostęp do konta. Najpierw zarejestruj klucz dostępu poniżej.'),
('pl', 'account.confirmDisablePasswordAuth', 'Czy na pewno wyłączyć logowanie e-mailem i hasłem? Potem będziesz mógł/mogła zalogować się tylko kluczem dostępu.'),
('pl', 'account.wrongCurrentPassword', 'Obecne hasło jest nieprawidłowe.'),
('pl', 'account.emailAlreadyUsed', 'Ten adres e-mail jest już używany.'),
('pl', 'account.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'account.changeError', 'Zmiana nie powiodła się: {message}'),
('pl', 'suggestions.formTitle', 'Zaproponuj danie'),
('pl', 'suggestions.formHint', 'Brakuje dania w jadłospisie? Zaproponuj je tutaj – zespół stołówki sprawdzi Twoją propozycję.'),
('pl', 'suggestions.nameLabel', 'Nazwa dania'),
('pl', 'suggestions.descriptionLabel', 'Opis / składniki (opcjonalnie)'),
('pl', 'suggestions.submit', 'Wyślij propozycję'),
('pl', 'suggestions.submitting', 'Wysyłanie…'),
('pl', 'suggestions.success', 'Dziękujemy! Twoja propozycja została przesłana.'),
('pl', 'suggestions.duplicate', 'To danie już istnieje w jadłospisie.'),
('pl', 'suggestions.genericError', 'Wysyłanie nie powiodło się: {message}'),
('pl', 'suggestions.nameRequired', 'Podaj nazwę.'),
('pl', 'suggestions.backToMenu', '← Powrót do jadłospisu'),
('pl', 'suggestions.reviewTitle', 'Propozycje dań'),
('pl', 'suggestions.reviewEmpty', 'Brak propozycji.'),
('pl', 'suggestions.accept', 'Dodaj jako danie'),
('pl', 'suggestions.reject', 'Odrzuć'),
('pl', 'suggestions.confirmReject', 'Czy na pewno odrzucić i usunąć propozycję „{name}”?'),
('pl', 'suggestions.accepted', 'Dodano jako danie.'),
('pl', 'suggestions.rejected', 'Propozycja odrzucona.'),
('pl', 'suggestions.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'suggestions.actionError', 'Czynność nie powiodła się: {message}'),
('pl', 'suggestions.submittedOn', 'przesłano {date}'),
('pl', 'languageManager.title', 'Języki'),
('pl', 'languageManager.hint', 'Niemiecki jest stałym językiem bazowym i zawsze kompletny. Kolejne języki są importowane jako plik tłumaczenia i automatycznie uzupełniane niemieckim tekstem tam, gdzie brakuje tłumaczenia.'),
('pl', 'languageManager.codeColumn', 'Kod'),
('pl', 'languageManager.labelColumn', 'Nazwa wyświetlana'),
('pl', 'languageManager.activeColumn', 'Aktywny'),
('pl', 'languageManager.completenessColumn', 'Kompletność'),
('pl', 'languageManager.baseBadge', 'Język bazowy'),
('pl', 'languageManager.complete', 'Kompletny'),
('pl', 'languageManager.completeness', 'Przetłumaczono {percent}%'),
('pl', 'languageManager.outdated', 'Nowe terminy od ostatniego importu: {count}'),
('pl', 'languageManager.downloadTemplate', 'Pobierz szablon'),
('pl', 'languageManager.uploadTranslation', 'Prześlij tłumaczenie'),
('pl', 'languageManager.newLanguageTitle', 'Dodaj nowy język'),
('pl', 'languageManager.newLanguageCodeLabel', 'Kod języka (np. fr)'),
('pl', 'languageManager.newLanguageLabelLabel', 'Nazwa wyświetlana (np. Français)'),
('pl', 'languageManager.create', 'Utwórz'),
('pl', 'languageManager.delete', 'Usuń'),
('pl', 'languageManager.confirmDelete', 'Czy na pewno usunąć „{label}”? Wszystkie tłumaczenia tego języka zostaną utracone.'),
('pl', 'languageManager.cannotDeleteBase', 'Nie można usunąć języka bazowego.'),
('pl', 'languageManager.cannotDeactivateLast', 'Co najmniej jeden język musi pozostać aktywny.'),
('pl', 'languageManager.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'languageManager.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'languageManager.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'languageManager.importSuccess', 'Zaimportowano terminów: {count}.'),
('pl', 'languageManager.importError', 'Import nie powiódł się: {message}'),
('pl', 'languageManager.importInvalidFile', 'Nie można odczytać pliku (nieprawidłowy JSON).'),
('pl', 'languageManager.importMissingPlaceholder', '„{key}”: brak symbolu zastępczego {placeholder} w tłumaczeniu – wiersz pominięto.'),
('pl', 'languageManager.duplicateCode', 'Kod języka „{code}” już istnieje.'),
('pl', 'languageManager.invalidCode', 'Podaj kod języka złożony z 2–5 małych liter.');
@@ -0,0 +1,71 @@
-- Teil 2 des "mehrsprachig variabel"-Umbaus: Speiseplan-Inhalte
-- (Gerichte, Essenszeiten, Allergene, Zusatzstoffe) bekommen eine
-- Übersetzungstabelle je Ressource statt fester name_en/name_pl-
-- Spalten (Teil 1, v1.10.0, hat dasselbe schon für die
-- Bedienoberfläche gemacht). Deutsch bleibt die Basis-Spalte
-- (name/label) auf der jeweiligen Haupttabelle.
--
-- WICHTIG bei Deployments mit echten Daten: vor dem Einspielen ein
-- Backup ziehen (siehe DEPLOYMENT.md, Abschnitt "Backup") – Schritt 3
-- entfernt die alten name_en/name_pl-Spalten unwiderruflich, nachdem
-- Schritt 2 ihren Inhalt in die neuen Tabellen übernommen hat.
-- ---------- Schritt 1: neue Tabellen ----------
create table dish_translations (
dish_id uuid not null references dishes(id) on delete cascade,
language_code text not null references languages(code) on delete cascade,
name text not null,
primary key (dish_id, language_code)
);
create table section_translations (
section_key text not null references sections(key) on delete cascade,
language_code text not null references languages(code) on delete cascade,
name text not null,
primary key (section_key, language_code)
);
-- allergens.code/additives.code sind vom Admin umbenennbar (Primary
-- Key), anders als dishes.id/sections.key – "on update cascade" nötig,
-- damit eine Umbenennung des Kürzels nicht die Übersetzungszeilen
-- verwaist.
create table allergen_translations (
allergen_code text not null references allergens(code) on delete cascade on update cascade,
language_code text not null references languages(code) on delete cascade,
name text not null,
primary key (allergen_code, language_code)
);
create table additive_translations (
additive_code text not null references additives(code) on delete cascade on update cascade,
language_code text not null references languages(code) on delete cascade,
name text not null,
primary key (additive_code, language_code)
);
-- ---------- Schritt 2: bestehende Übersetzungen übernehmen ----------
insert into dish_translations (dish_id, language_code, name)
select id, 'en', name_en from dishes where name_en is not null and trim(name_en) != '';
insert into dish_translations (dish_id, language_code, name)
select id, 'pl', name_pl from dishes where name_pl is not null and trim(name_pl) != '';
insert into section_translations (section_key, language_code, name)
select key, 'en', label_en from sections where label_en is not null and trim(label_en) != '';
insert into section_translations (section_key, language_code, name)
select key, 'pl', label_pl from sections where label_pl is not null and trim(label_pl) != '';
insert into allergen_translations (allergen_code, language_code, name)
select code, 'en', name_en from allergens where name_en is not null and trim(name_en) != '';
insert into allergen_translations (allergen_code, language_code, name)
select code, 'pl', name_pl from allergens where name_pl is not null and trim(name_pl) != '';
insert into additive_translations (additive_code, language_code, name)
select code, 'en', name_en from additives where name_en is not null and trim(name_en) != '';
insert into additive_translations (additive_code, language_code, name)
select code, 'pl', name_pl from additives where name_pl is not null and trim(name_pl) != '';
-- ---------- Schritt 3: alte Spalten entfernen ----------
alter table dishes drop column name_en, drop column name_pl;
alter table sections drop column label_en, drop column label_pl;
alter table allergens drop column name_en, drop column name_pl;
alter table additives drop column name_en, drop column name_pl;
+18
View File
@@ -0,0 +1,18 @@
-- Kantine an einzelnen Tagen als geschlossen markieren (Feiertage o. Ä.),
-- mit optionalem, mehrsprachigem Grund. Ein Tag ohne Zeile hier gilt als
-- regulär geöffnet.
create table closed_days (
closed_date date primary key,
reason text,
created_at timestamptz not null default now()
);
-- Wert-Spalte heißt bewusst "name" (nicht "reason"), damit die generische
-- Zugriffsschicht in lib/db/translations.ts unverändert funktioniert –
-- dieselbe Konvention wie bei section_translations.name für sections.label.
create table closed_day_translations (
closed_date date not null references closed_days(closed_date) on delete cascade,
language_code text not null references languages(code) on delete cascade,
name text not null,
primary key (closed_date, language_code)
);
+71 -32
View File
@@ -16,42 +16,81 @@ delete from allergens;
delete from additives;
-- ---------- Essenszeiten ----------
insert into sections (key, label, label_en, label_pl, time_range, sort) values
('brotzeit', 'Brotzeit', 'Breakfast', 'Śniadanie', '09:00 - 10:00', 1),
('mittagessen', 'Mittagessen', 'Lunch', 'Obiad', '11:30 - 13:30', 2),
('mittagessen_veg', 'Mittagessen vegetarisch', 'Lunch (vegetarian)', 'Obiad (wegetariański)', '11:30 - 13:30', 3),
('suppe', 'Suppe', 'Soup', 'Zupa', '11:30 - 13:30', 4);
insert into sections (key, label, time_range, sort) values
('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);
insert into section_translations (section_key, language_code, name) values
('brotzeit', 'en', 'Breakfast'),
('brotzeit', 'pl', 'Śniadanie'),
('mittagessen', 'en', 'Lunch'),
('mittagessen', 'pl', 'Obiad'),
('mittagessen_veg', 'en', 'Lunch (vegetarian)'),
('mittagessen_veg', 'pl', 'Obiad (wegetariański)'),
('suppe', 'en', 'Soup'),
('suppe', 'pl', 'Zupa');
-- ---------- Allergene (EU-Kennzeichnung, Buchstaben) ----------
insert into allergens (code, name, name_en, name_pl, sort) values
('A', 'Glutenhaltiges Getreide', 'Cereals containing gluten', 'Zboża zawierające gluten', 1),
('B', 'Krebstiere', 'Crustaceans', 'Skorupiaki', 2),
('C', 'Eier', 'Eggs', 'Jaja', 3),
('D', 'Fisch', 'Fish', 'Ryby', 4),
('E', 'Erdnüsse', 'Peanuts', 'Orzeszki ziemne', 5),
('F', 'Sojabohnen', 'Soybeans', 'Soja', 6),
('G', 'Milch (einschließlich Laktose)', 'Milk (including lactose)', 'Mleko (w tym laktoza)', 7),
('H', 'Schalenfrüchte (Nüsse)', 'Tree nuts', 'Orzechy', 8),
('I', 'Sellerie', 'Celery', 'Seler', 9),
('J', 'Senf', 'Mustard', 'Gorczyca', 10),
('K', 'Sesamsamen', 'Sesame seeds', 'Nasiona sezamu', 11),
('L', 'Schwefeldioxid und Sulfite', 'Sulphur dioxide and sulphites', 'Dwutlenek siarki i siarczyny', 12),
('M', 'Lupinen', 'Lupin', 'Łubin', 13),
('N', 'Weichtiere', 'Molluscs', 'Mięczaki', 14);
insert into allergens (code, name, sort) values
('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);
insert into allergen_translations (allergen_code, language_code, name) values
('A', 'en', 'Cereals containing gluten'), ('A', 'pl', 'Zboża zawierające gluten'),
('B', 'en', 'Crustaceans'), ('B', 'pl', 'Skorupiaki'),
('C', 'en', 'Eggs'), ('C', 'pl', 'Jaja'),
('D', 'en', 'Fish'), ('D', 'pl', 'Ryby'),
('E', 'en', 'Peanuts'), ('E', 'pl', 'Orzeszki ziemne'),
('F', 'en', 'Soybeans'), ('F', 'pl', 'Soja'),
('G', 'en', 'Milk (including lactose)'), ('G', 'pl', 'Mleko (w tym laktoza)'),
('H', 'en', 'Tree nuts'), ('H', 'pl', 'Orzechy'),
('I', 'en', 'Celery'), ('I', 'pl', 'Seler'),
('J', 'en', 'Mustard'), ('J', 'pl', 'Gorczyca'),
('K', 'en', 'Sesame seeds'), ('K', 'pl', 'Nasiona sezamu'),
('L', 'en', 'Sulphur dioxide and sulphites'), ('L', 'pl', 'Dwutlenek siarki i siarczyny'),
('M', 'en', 'Lupin'), ('M', 'pl', 'Łubin'),
('N', 'en', 'Molluscs'), ('N', 'pl', 'Mięczaki');
-- ---------- Zusatzstoffe (Ziffern) ----------
insert into additives (code, name, name_en, name_pl, sort) values
('1', 'mit Farbstoff', 'with colouring', 'z barwnikiem', 1),
('2', 'mit Konservierungsstoff', 'with preservative', 'z konserwantem', 2),
('3', 'mit Antioxidationsmittel', 'with antioxidant', 'z przeciwutleniaczem', 3),
('4', 'mit Geschmacksverstärker', 'with flavour enhancer', 'ze wzmacniaczem smaku', 4),
('5', 'geschwefelt', 'sulphured', 'siarkowany', 5),
('6', 'geschwärzt', 'blackened', 'barwiony na czarno', 6),
('7', 'gewachst', 'waxed', 'woskowany', 7),
('8', 'mit Phosphat', 'with phosphate', 'z fosforanem', 8),
('9', 'mit Süßungsmittel(n)', 'with sweetener(s)', 'ze słodzikiem/słodzikami', 9),
('10', 'enthält eine Phenylalaninquelle', 'contains a source of phenylalanine', 'zawiera źródło fenyloalaniny', 10),
('11', 'koffeinhaltig', 'caffeinated', 'zawiera kofeinę', 11);
insert into additives (code, name, sort) values
('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);
insert into additive_translations (additive_code, language_code, name) values
('1', 'en', 'with colouring'), ('1', 'pl', 'z barwnikiem'),
('2', 'en', 'with preservative'), ('2', 'pl', 'z konserwantem'),
('3', 'en', 'with antioxidant'), ('3', 'pl', 'z przeciwutleniaczem'),
('4', 'en', 'with flavour enhancer'), ('4', 'pl', 'ze wzmacniaczem smaku'),
('5', 'en', 'sulphured'), ('5', 'pl', 'siarkowany'),
('6', 'en', 'blackened'), ('6', 'pl', 'barwiony na czarno'),
('7', 'en', 'waxed'), ('7', 'pl', 'woskowany'),
('8', 'en', 'with phosphate'), ('8', 'pl', 'z fosforanem'),
('9', 'en', 'with sweetener(s)'), ('9', 'pl', 'ze słodzikiem/słodzikami'),
('10', 'en', 'contains a source of phenylalanine'), ('10', 'pl', 'zawiera źródło fenyloalaniny'),
('11', 'en', 'caffeinated'), ('11', 'pl', 'zawiera kofeinę');
-- ---------- Testgerichte ----------
insert into dishes (name, price) values
+20 -4
View File
@@ -34,6 +34,19 @@ services:
# (Essenszeiten, Kürzel-Erklärungen) nach Englisch/Polnisch. Ohne Key
# bleibt "Automatisch übersetzen" inaktiv, manuelle Eingabe geht immer.
DEEPL_API_KEY: ${DEEPL_API_KEY:-}
# Optional: SSO-Login per OpenID Connect (z. B. Authentik). Ohne
# diese drei Werte bleibt der SSO-Button ausgeblendet. Funktioniert
# nur für bereits angelegte Benutzer (Abgleich per E-Mail), legt nie
# neue Konten an.
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
# Muss auf das "volumes:"-Ziel unten UND das im Dockerfile per
# "mkdir/chown" vorbereitete Verzeichnis zeigen – ohne diese
# Variable würde lib/branding.ts auf ${PWD}/data/branding
# zurückfallen, das weder gemountet noch für den nicht-root
# App-Nutzer beschreibbar ist (Upload schlägt mit 500 fehl).
BRANDING_DIR: /data/branding
ports:
- "0.0.0.0:3000:3000"
volumes:
@@ -50,10 +63,10 @@ services:
environment:
POSTGRES_DB: canteen_menu
POSTGRES_PASSWORD: ${LOCAL_PG_PASSWORD:-canteen_menu}
ports:
# Host-Port 5433, da 5432 meist schon von einer nativen
# PostgreSQL-Installation belegt ist.
- "5433:5432"
# Bewusst kein "ports:"-Mapping: die Datenbank ist nur innerhalb des
# Compose-Netzwerks unter "db:5432" erreichbar (für app/dev), nicht
# vom Host oder von außen. Zugriff für Debugging/Backups über
# "docker compose exec db psql -U postgres canteen_menu".
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
@@ -71,6 +84,9 @@ services:
SITE_TITLE: ${SITE_TITLE:-Canteen Menu}
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
DEEPL_API_KEY: ${DEEPL_API_KEY:-}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
ports:
- "0.0.0.0:3000:3000"
volumes:
+99
View File
@@ -0,0 +1,99 @@
import "server-only";
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import { clientId, clientSecret, issuer, redirectUri } from "./oidc-config";
export interface OidcDiscovery {
authorization_endpoint: string;
token_endpoint: string;
jwks_uri: string;
userinfo_endpoint?: string;
}
// Kein Caching des Discovery-Dokuments: Login ist bei ~250 Mitarbeitern
// kein Hot Path, ein zusätzlicher HTTPS-Request pro Anmeldeversuch
// fällt nicht ins Gewicht – vermeidet Cache-Invalidierungslogik ohne
// echten Bedarf.
export async function discover(): Promise<OidcDiscovery> {
const res = await fetch(`${issuer().replace(/\/$/, "")}/.well-known/openid-configuration`);
if (!res.ok) throw new Error(`oidc_discovery_failed_${res.status}`);
return (await res.json()) as OidcDiscovery;
}
export function buildAuthorizeUrl(
discovery: OidcDiscovery,
params: { state: string; nonce: string; codeChallenge: string }
): string {
const url = new URL(discovery.authorization_endpoint);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", clientId());
url.searchParams.set("redirect_uri", redirectUri());
url.searchParams.set("scope", "openid email profile");
url.searchParams.set("state", params.state);
url.searchParams.set("nonce", params.nonce);
url.searchParams.set("code_challenge", params.codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
return url.toString();
}
export async function exchangeCode(
discovery: OidcDiscovery,
code: string,
codeVerifier: string
): Promise<{ idToken: string; accessToken: string | null }> {
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: clientId(),
client_secret: clientSecret(),
redirect_uri: redirectUri(),
code,
code_verifier: codeVerifier,
});
const res = await fetch(discovery.token_endpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!res.ok) throw new Error(`oidc_token_exchange_failed_${res.status}`);
const data = (await res.json()) as { id_token?: string; access_token?: string };
if (!data.id_token) throw new Error("oidc_missing_id_token");
return { idToken: data.id_token, accessToken: data.access_token ?? null };
}
// Algorithmus bewusst auf RS256 festgenagelt (Authentik-Standard) statt
// dem Token zu vertrauen, welchen Algorithmus es selbst behauptet zu
// nutzen ("algorithm confusion"-Angriffsklasse bei JWTs).
export async function verifyIdToken(
discovery: OidcDiscovery,
idToken: string,
expectedNonce: string
): Promise<JWTPayload> {
const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri));
const { payload } = await jwtVerify(idToken, jwks, {
issuer: issuer(),
audience: clientId(),
algorithms: ["RS256"],
});
if (payload.nonce !== expectedNonce) {
throw new Error("oidc_nonce_mismatch");
}
if (payload.email_verified === false) {
throw new Error("oidc_email_not_verified");
}
return payload;
}
/** E-Mail bevorzugt aus dem ID-Token, sonst Fallback auf den Userinfo-Endpoint. */
export async function resolveEmail(
discovery: OidcDiscovery,
payload: JWTPayload,
accessToken: string | null
): Promise<string | null> {
if (typeof payload.email === "string" && payload.email) return payload.email;
if (!accessToken || !discovery.userinfo_endpoint) return null;
const res = await fetch(discovery.userinfo_endpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return null;
const info = (await res.json()) as { email?: string };
return typeof info.email === "string" && info.email ? info.email : null;
}
+35
View File
@@ -0,0 +1,35 @@
import "server-only";
// SSO-Konfiguration wird direkt aus Env-Vars abgeleitet – kein
// Cloud-Konfigurationsschritt, analog zu lib/auth/webauthn-config.ts.
// "Aktiviert" ergibt sich implizit aus dem Vorhandensein der drei
// Pflicht-Vars (wie bei DEEPL_API_KEY), kein separates Enable-Flag.
export function isConfigured(): boolean {
return !!(
process.env.OIDC_ISSUER &&
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET
);
}
export function issuer(): string {
return process.env.OIDC_ISSUER!;
}
export function clientId(): string {
return process.env.OIDC_CLIENT_ID!;
}
export function clientSecret(): string {
return process.env.OIDC_CLIENT_SECRET!;
}
// Bewusst aus APP_URL abgeleitet statt aus Request-Headern (wie z. B.
// proxy.ts es für den Login-Redirect tut): der Wert muss exakt mit der
// beim Identity Provider hinterlegten Redirect-URI übereinstimmen,
// darf sich also nicht je nach eingehendem Host-Header ändern.
export function redirectUri(): string {
const appUrl = new URL(process.env.APP_URL ?? "http://localhost:3000");
return `${appUrl.origin}/api/auth/oidc/callback`;
}
+31
View File
@@ -0,0 +1,31 @@
import "server-only";
import { query, queryOne } from "@/lib/db";
const STATE_TTL_MS = 10 * 60 * 1000;
export async function storeState(
state: string,
nonce: string,
codeVerifier: string
): Promise<void> {
// Kein Cron nötig: abgelaufene Zeilen bei jedem neuen State gleich
// mit aufräumen (wie bei webauthn_challenges).
await query("delete from oidc_states where expires_at < now()");
await query(
"insert into oidc_states (state, nonce, code_verifier, expires_at) values ($1, $2, $3, $4)",
[state, nonce, codeVerifier, new Date(Date.now() + STATE_TTL_MS)]
);
}
/** Einmalig verwendbar: die Zeile wird beim Abruf sofort gelöscht. */
export async function consumeState(
state: string
): Promise<{ nonce: string; codeVerifier: string } | null> {
const row = await queryOne<{ nonce: string; code_verifier: string }>(
`delete from oidc_states
where state = $1 and expires_at > now()
returning nonce, code_verifier`,
[state]
);
return row ? { nonce: row.nonce, codeVerifier: row.code_verifier } : null;
}
+2 -4
View File
@@ -1,9 +1,7 @@
import "server-only";
// RP-Konfiguration wird direkt aus APP_URL/SITE_TITLE abgeleitet – kein
// externer Konfigurationsschritt mehr nötig (ersetzt das bisherige
// scripts/configure-passkeys.mjs, das die Supabase-Management-API
// aufrief).
// RP-Konfiguration wird direkt aus APP_URL/SITE_TITLE abgeleitet –
// kein externer Konfigurationsschritt oder Cloud-API-Aufruf mehr nötig.
function appUrl(): URL {
return new URL(process.env.APP_URL ?? "http://localhost:3000");
}
+2 -2
View File
@@ -2,8 +2,8 @@ import "server-only";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
// Ersetzt den früheren Supabase-Storage-Bucket "branding" durch ein
// lokales Verzeichnis (Docker-Volume in Produktion, s. docker-compose.yml).
// Logo/Favicon liegen als Dateien in einem lokalen Verzeichnis
// (Docker-Volume in Produktion, s. docker-compose.yml).
export function brandingDir(): string {
// turbopackIgnore: process.cwd() ist hier dynamisch, aber der Pfad
// bleibt statisch unter "data/branding" – ohne den Kommentar versucht
+98
View File
@@ -0,0 +1,98 @@
import "server-only";
// Minimaler RFC-4180-artiger CSV-Parser/-Serializer für Import/Export der
// Gerichte-/Allergen-/Zusatzstofflisten. Bewusst ohne npm-Abhängigkeit
// (Projekt-Stil: kein ORM, keine Validierungs-Library, minimale Deps) –
// deckt quoted Felder mit eingebetteten Kommas/Anführungszeichen/
// Zeilenumbrüchen ab, da Gerichtsnamen Kommas enthalten können.
export function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let inQuotes = false;
let i = 0;
const len = text.length;
function pushField() {
row.push(field);
field = "";
}
function pushRow() {
pushField();
rows.push(row);
row = [];
}
while (i < len) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') {
field += '"';
i += 2;
continue;
}
inQuotes = false;
i++;
continue;
}
field += c;
i++;
continue;
}
if (c === '"') {
inQuotes = true;
i++;
continue;
}
if (c === ",") {
pushField();
i++;
continue;
}
if (c === "\r") {
i++;
continue;
}
if (c === "\n") {
pushRow();
i++;
continue;
}
field += c;
i++;
}
// Letzte Zeile ohne abschließenden Zeilenumbruch noch anhängen.
if (field !== "" || row.length > 0) pushRow();
// Leere Zeilen überspringen (z. B. durch einen abschließenden Zeilenumbruch).
return rows.filter((r) => !(r.length === 1 && r[0].trim() === ""));
}
function csvField(value: string): string {
if (/[",\r\n]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
export function toCsv(rows: string[][]): string {
return rows.map((row) => row.map(csvField).join(",")).join("\r\n") + "\r\n";
}
/** Kopfzeile in Kleinbuchstaben, damit der Import tolerant gegenüber Groß-/
* Kleinschreibung und Spaltenreihenfolge ist. */
export function parseCsvWithHeader(text: string): { header: string[]; rows: string[][] } {
const table = parseCsv(text);
if (table.length === 0) return { header: [], rows: [] };
const [header, ...rows] = table;
return { header: header.map((h) => h.trim().toLowerCase()), rows };
}
/** Codes einer Zelle wie "A;G;2" in einzelne, getrimmte Kürzel zerlegen. */
export function splitCodes(cell: string | undefined): string[] {
return (cell ?? "")
.split(";")
.map((c) => c.trim())
.filter(Boolean);
}
+50
View File
@@ -0,0 +1,50 @@
import "server-only";
import { query, queryOne } from "@/lib/db";
import { loadTranslationsFor, replaceTranslations } from "./translations";
import type { ClosedDay } from "@/lib/types";
interface ClosedDayRow {
closed_date: string;
reason: string | null;
}
/** Geschlossene Tage in einem Zeitraum (für Wochenplan/öffentliche Speisekarte/RSS). */
export async function listClosedDays(fromISO: string, toISO: string): Promise<ClosedDay[]> {
const rows = await query<ClosedDayRow>(
"select closed_date, reason from closed_days where closed_date between $1 and $2",
[fromISO, toISO]
);
if (rows.length === 0) return [];
const translations = await loadTranslationsFor(
"closed_day_translations",
rows.map((r) => r.closed_date)
);
return rows.map((r) => ({
...r,
translations: translations.get(r.closed_date) ?? {},
}));
}
/** Tag als geschlossen markieren (oder bestehenden Grund aktualisieren). */
export async function setClosedDay(
date: string,
reason: string | null,
translations?: Record<string, string | null>
): Promise<ClosedDay> {
const row = (await queryOne<ClosedDayRow>(
`insert into closed_days (closed_date, reason) values ($1, $2)
on conflict (closed_date) do update set reason = excluded.reason
returning closed_date, reason`,
[date, reason]
))!;
if (translations) {
await replaceTranslations("closed_day_translations", row.closed_date, translations);
}
const loaded = await loadTranslationsFor("closed_day_translations", [row.closed_date]);
return { ...row, translations: loaded.get(row.closed_date) ?? {} };
}
/** Tag wieder öffnen (löscht auch alle Übersetzungen des Grunds per Cascade). */
export async function removeClosedDay(date: string): Promise<void> {
await query("delete from closed_days where closed_date = $1", [date]);
}
+83 -10
View File
@@ -1,41 +1,114 @@
import "server-only";
import { query, queryOne } from "@/lib/db";
import {
loadTranslations,
loadTranslationsFor,
replaceTranslations,
type TranslationTable,
} from "./translations";
import type { CodeItem } from "@/lib/types";
// "allergens"/"additives" sind identisch aufgebaute Tabellen (Kürzel +
// Bezeichnung in DE/EN/PL). Tabellenname kommt nie aus Nutzereingaben,
// Bezeichnung in Deutsch, Übersetzungen in der jeweiligen
// *_translations-Tabelle). Tabellenname kommt nie aus Nutzereingaben,
// sondern immer aus dem literal getypten Parameter unten – sicher für
// String-Interpolation in der Query.
export type CodeTable = "allergens" | "additives";
const TRANSLATION_TABLE: Record<CodeTable, TranslationTable> = {
allergens: "allergen_translations",
additives: "additive_translations",
};
interface CodeRow {
code: string;
name: string;
sort: number;
}
export async function listCodes(table: CodeTable): Promise<CodeItem[]> {
return query<CodeItem>(`select * from ${table} order by sort`);
const rows = await query<CodeRow>(`select code, name, sort from ${table} order by sort`);
const translations = await loadTranslations(TRANSLATION_TABLE[table]);
return rows.map((r) => ({ ...r, translations: translations.get(r.code) ?? {} }));
}
export async function createCode(
table: CodeTable,
input: { code: string; name: string; name_en: string | null; name_pl: string | null }
input: { code: string; name: string; translations?: Record<string, string | null> }
): Promise<CodeItem> {
const maxRow = await queryOne<{ sort: number }>(
`select sort from ${table} order by sort desc limit 1`
);
return (await queryOne<CodeItem>(
`insert into ${table} (code, name, name_en, name_pl, sort) values ($1, $2, $3, $4, $5) returning *`,
[input.code, input.name, input.name_en, input.name_pl, (maxRow?.sort ?? 0) + 1]
const row = (await queryOne<CodeRow>(
`insert into ${table} (code, name, sort) values ($1, $2, $3) returning code, name, sort`,
[input.code, input.name, (maxRow?.sort ?? 0) + 1]
))!;
if (input.translations) {
await replaceTranslations(TRANSLATION_TABLE[table], row.code, input.translations);
}
const translations = await loadTranslationsFor(TRANSLATION_TABLE[table], [row.code]);
return { ...row, translations: translations.get(row.code) ?? {} };
}
export async function updateCode(
table: CodeTable,
originalCode: string,
input: { code: string; name: string; name_en: string | null; name_pl: string | null }
input: { code: string; name: string; translations?: Record<string, string | null> }
): Promise<CodeItem | null> {
return queryOne<CodeItem>(
`update ${table} set code = $1, name = $2, name_en = $3, name_pl = $4 where code = $5 returning *`,
[input.code, input.name, input.name_en, input.name_pl, originalCode]
const row = await queryOne<CodeRow>(
`update ${table} set code = $1, name = $2 where code = $3 returning code, name, sort`,
[input.code, input.name, originalCode]
);
if (!row) return null;
if (input.translations) {
await replaceTranslations(TRANSLATION_TABLE[table], row.code, input.translations);
}
const translations = await loadTranslationsFor(TRANSLATION_TABLE[table], [row.code]);
return { ...row, translations: translations.get(row.code) ?? {} };
}
export async function deleteCode(table: CodeTable, code: string): Promise<void> {
await query(`delete from ${table} where code = $1`, [code]);
}
export interface CodeImportRow {
code: string;
name: string;
translations: Record<string, string>;
}
/** CSV-Import: legt neue Kürzel an, bestehende (gleicher Code) werden
* aktualisiert – ein doppelter Code innerhalb derselben Datei
* aktualisiert also einfach zweimal statt einen Fehler zu werfen. */
export async function importCodes(
table: CodeTable,
rows: CodeImportRow[]
): Promise<{ created: number; updated: number }> {
const existing = await query<{ code: string }>(`select code from ${table}`);
const known = new Set(existing.map((r) => r.code));
const maxRow = await queryOne<{ sort: number }>(
`select coalesce(max(sort), 0) as sort from ${table}`
);
let nextSort = (maxRow?.sort ?? 0) + 1;
let created = 0;
let updated = 0;
for (const row of rows) {
if (known.has(row.code)) {
await query(`update ${table} set name = $1 where code = $2`, [row.name, row.code]);
updated++;
} else {
await query(`insert into ${table} (code, name, sort) values ($1, $2, $3)`, [
row.code,
row.name,
nextSort++,
]);
known.add(row.code);
created++;
}
if (Object.keys(row.translations).length > 0) {
await replaceTranslations(TRANSLATION_TABLE[table], row.code, row.translations);
}
}
return { created, updated };
}
+106
View File
@@ -0,0 +1,106 @@
import "server-only";
import { query, queryOne, withTransaction } from "@/lib/db";
export interface LanguageRow {
code: string;
label: string;
is_base: boolean;
active: boolean;
template_version: string | null;
updated_at: string;
}
export async function listLanguages(): Promise<LanguageRow[]> {
return query<LanguageRow>(
"select * from languages order by is_base desc, code"
);
}
export async function listActiveLanguageCodes(): Promise<string[]> {
const rows = await query<{ code: string }>(
"select code from languages where active = true order by is_base desc, code"
);
return rows.map((r) => r.code);
}
export async function getLanguage(code: string): Promise<LanguageRow | null> {
return queryOne<LanguageRow>("select * from languages where code = $1", [code]);
}
export async function createLanguage(code: string, label: string): Promise<LanguageRow> {
return (await queryOne<LanguageRow>(
`insert into languages (code, label, is_base, active, template_version)
values ($1, $2, false, true, null) returning *`,
[code, label]
))!;
}
export async function updateLanguageLabel(code: string, label: string): Promise<LanguageRow | null> {
return queryOne<LanguageRow>(
"update languages set label = $1, updated_at = now() where code = $2 returning *",
[label, code]
);
}
/** Wirft, wenn dadurch keine aktive Sprache mehr übrig bliebe. */
export async function setLanguageActive(code: string, active: boolean): Promise<LanguageRow> {
return withTransaction(async (client) => {
if (!active) {
const { rows } = await client.query<{ count: string }>(
"select count(*)::int as count from languages where active = true and code != $1",
[code]
);
if (Number(rows[0].count) === 0) {
throw new Error("cannot_deactivate_last");
}
}
const { rows } = await client.query<LanguageRow>(
"update languages set active = $1, updated_at = now() where code = $2 returning *",
[active, code]
);
return rows[0];
});
}
export async function deleteLanguage(code: string): Promise<void> {
const lang = await getLanguage(code);
if (lang?.is_base) throw new Error("cannot_delete_base");
await query("delete from languages where code = $1", [code]);
}
export interface TranslationEntry {
key: string;
value: string;
}
export async function listTranslations(code: string): Promise<TranslationEntry[]> {
return query<TranslationEntry>(
"select key, value from ui_translations where language_code = $1",
[code]
);
}
/** Übernimmt neu importierte Übersetzungen (Insert/Update je Schlüssel)
* und aktualisiert den gespeicherten Vorlagen-Stand. Bewusst kein
* vollständiges Ersetzen: ein erneuter Import mit nur den zuvor
* fehlenden Schlüsseln darf bereits vorhandene Übersetzungen nicht
* löschen. */
export async function importTranslations(
code: string,
entries: TranslationEntry[],
templateVersion: string
): Promise<void> {
await withTransaction(async (client) => {
for (const entry of entries) {
await client.query(
`insert into ui_translations (language_code, key, value) values ($1, $2, $3)
on conflict (language_code, key) do update set value = excluded.value`,
[code, entry.key, entry.value]
);
}
await client.query(
"update languages set template_version = $1, updated_at = now() where code = $2",
[templateVersion, code]
);
});
}
+6 -5
View File
@@ -1,5 +1,6 @@
import "server-only";
import { query } from "@/lib/db";
import { loadTranslationsFor } from "./translations";
import type { MenuEntry } from "@/lib/types";
interface MenuEntryRow {
@@ -9,8 +10,6 @@ interface MenuEntryRow {
position: number;
dish_id: string;
name: string;
name_en: string | null;
name_pl: string | null;
price: string;
allergens: string[];
additives: string[];
@@ -22,7 +21,7 @@ interface MenuEntryRow {
export async function loadMenuEntries(fromISO: string, toISO: string): Promise<MenuEntry[]> {
const rows = await query<MenuEntryRow>(
`select me.id, me.menu_date, me.section_key, me.position,
d.id as dish_id, d.name, d.name_en, d.name_pl, d.price, d.allergens, d.additives
d.id as dish_id, d.name, d.price, d.allergens, d.additives
from menu_entries me
join dishes d on d.id = me.dish_id
where me.menu_date between $1 and $2
@@ -30,6 +29,9 @@ export async function loadMenuEntries(fromISO: string, toISO: string): Promise<M
[fromISO, toISO]
);
const dishIds = [...new Set(rows.map((r) => r.dish_id))];
const translations = await loadTranslationsFor("dish_translations", dishIds);
return rows.map((r) => ({
id: r.id,
menu_date: r.menu_date,
@@ -38,8 +40,7 @@ export async function loadMenuEntries(fromISO: string, toISO: string): Promise<M
dish: {
id: r.dish_id,
name: r.name,
name_en: r.name_en,
name_pl: r.name_pl,
translations: translations.get(r.dish_id) ?? {},
price: Number(r.price),
allergens: r.allergens,
additives: r.additives,
+9 -1
View File
@@ -1,14 +1,22 @@
import "server-only";
import { queryOne } from "@/lib/db";
export type LabelingMode = "detailed" | "notice";
export interface Settings {
colors: Record<string, string>;
logo_path: string | null;
favicon_path: string | null;
labeling_mode: LabelingMode;
labeling_notice: string;
labeling_notice_en: string | null;
labeling_notice_pl: string | null;
}
export async function getSettings(): Promise<Settings | null> {
return queryOne<Settings>(
"select colors, logo_path, favicon_path from settings where id = 1"
`select colors, logo_path, favicon_path, labeling_mode,
labeling_notice, labeling_notice_en, labeling_notice_pl
from settings where id = 1`
);
}
+87
View File
@@ -0,0 +1,87 @@
import "server-only";
import { query } from "@/lib/db";
// Gemeinsame Zugriffsschicht für die vier Übersetzungstabellen aus
// db/migrations/0006_content_translations.sql. Die Wert-Spalte heißt
// in allen vieren bewusst einheitlich "name" (auch bei
// section_translations, obwohl sections.label selbst anders heißt) –
// vereinfacht diese generischen Helfer, statt zusätzlich eine Spalten-
// Namens-Zuordnung pflegen zu müssen.
export type TranslationTable =
| "dish_translations"
| "section_translations"
| "allergen_translations"
| "additive_translations"
| "closed_day_translations";
const ID_COLUMN: Record<TranslationTable, string> = {
dish_translations: "dish_id",
section_translations: "section_key",
allergen_translations: "allergen_code",
additive_translations: "additive_code",
closed_day_translations: "closed_date",
};
/** Alle Übersetzungen einer Tabelle, gruppiert nach Eltern-ID. */
export async function loadTranslations(
table: TranslationTable
): Promise<Map<string, Record<string, string>>> {
const idCol = ID_COLUMN[table];
const rows = await query<{ id: string; language_code: string; name: string }>(
`select ${idCol} as id, language_code, name from ${table}`
);
return groupByParent(rows);
}
/** Übersetzungen nur für eine Teilmenge von Eltern-IDs laden (z. B. die
* Gerichte des aktuell angezeigten Wochenplan-Zeitraums). */
export async function loadTranslationsFor(
table: TranslationTable,
ids: string[]
): Promise<Map<string, Record<string, string>>> {
if (ids.length === 0) return new Map();
const idCol = ID_COLUMN[table];
const rows = await query<{ id: string; language_code: string; name: string }>(
`select ${idCol} as id, language_code, name from ${table} where ${idCol} = any($1)`,
[ids]
);
return groupByParent(rows);
}
function groupByParent(
rows: { id: string; language_code: string; name: string }[]
): Map<string, Record<string, string>> {
const map = new Map<string, Record<string, string>>();
for (const r of rows) {
if (!map.has(r.id)) map.set(r.id, {});
map.get(r.id)![r.language_code] = r.name;
}
return map;
}
/** Übersetzungen eines Eintrags für die übergebenen Sprachen setzen –
* ein leerer/nur-Leerzeichen-Wert löscht die Zeile für diese Sprache
* wieder (z. B. wenn ein Formularfeld geleert wird), statt eine leere
* Übersetzung zu speichern. */
export async function replaceTranslations(
table: TranslationTable,
id: string,
translations: Record<string, string | null | undefined>
): Promise<void> {
const idCol = ID_COLUMN[table];
for (const [languageCode, rawValue] of Object.entries(translations)) {
const value = rawValue?.trim();
if (!value) {
await query(`delete from ${table} where ${idCol} = $1 and language_code = $2`, [
id,
languageCode,
]);
continue;
}
await query(
`insert into ${table} (${idCol}, language_code, name) values ($1, $2, $3)
on conflict (${idCol}, language_code) do update set name = excluded.name`,
[id, languageCode, value]
);
}
}
+82 -509
View File
@@ -36,21 +36,30 @@ const de = {
adminGroup: "Verwaltung",
users: "Benutzer",
design: "Design",
languages: "Sprachen",
suggestions: "Vorschläge",
openSuggestions: "{count} offene Rezeptvorschläge",
viewMenu: "Speisekarte ansehen",
myAccount: "Mein Konto",
menuToggle: "Menü öffnen/schließen",
},
login: {
title: "Anmeldung",
submit: "Anmelden",
or: "oder",
passkeyButton: "Mit Passkey anmelden",
ssoButton: "Mit Single Sign-On anmelden",
backToMenu: "← Zur Speisekarte",
errorInvalidCredentials: "E-Mail-Adresse oder Passwort ist falsch.",
errorPasskeyCancelled: "Passkey-Anmeldung wurde abgebrochen.",
errorGeneric: "Anmeldung fehlgeschlagen: {message}",
errorPasswordDisabled:
"Die Anmeldung mit Passwort ist für dieses Konto deaktiviert. Bitte mit Passkey anmelden.",
errorOidcInvalidState:
"Die SSO-Anmeldung ist abgelaufen oder ungültig. Bitte erneut versuchen.",
errorOidcUnknownUser:
"Für diese E-Mail-Adresse existiert kein Konto. Bitte an die Verwaltung wenden.",
errorOidcFailed: "SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.",
},
publicMenu: {
prevWeek: "← Vorherige Woche",
@@ -62,8 +71,11 @@ const de = {
weekTab: "Woche",
allergens: "Allergene",
additives: "Zusatzstoffe",
labelingNoticeTitle: "Allergene & Zusatzstoffe",
suggestDish: "Rezept vorschlagen",
noEntry: "–",
rssFeed: "RSS-Feed abonnieren",
closedLabel: "Geschlossen",
},
weekEditor: {
title: "Wochenplan",
@@ -76,14 +88,17 @@ const de = {
duplicateEntry: "Dieses Gericht ist an diesem Tag bereits zugewiesen.",
saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
closeDay: "Tag schließen",
reopenDay: "Wieder öffnen",
closedLabel: "Geschlossen",
closedReasonLabel: "Grund (optional)",
closedReasonPlaceholder: "z. B. Weihnachtsfeiertage",
},
dishManager: {
title: "Gerichte",
newDish: "Neues Gericht",
editDish: "Gericht bearbeiten",
nameLabel: "Bezeichnung",
nameEnLabel: "Name (Englisch)",
namePlLabel: "Name (Polnisch)",
autoTranslate: "Automatisch übersetzen",
translating: "Übersetze…",
translated: "Übersetzt.",
@@ -104,6 +119,15 @@ const de = {
loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
exportButton: "Exportieren",
importButton: "Importieren",
exportError: "Export fehlgeschlagen: {message}",
importError: "Import fehlgeschlagen: {message}",
importSuccess: "{created} neu angelegt, {updated} aktualisiert.",
importSkipped: "{count} Zeile(n) übersprungen: {reasons}",
importSkippedRow: "Zeile {row}: {reason}",
labelingDisabledHint:
"Allergene/Zusatzstoffe sind aktuell deaktiviert (Hinweismodus aktiv, siehe „Allergene & Zusatzstoffe“).",
},
codeList: {
allergensTitle: "Allergene",
@@ -117,14 +141,19 @@ const de = {
loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
exportButton: "Exportieren",
importButton: "Importieren",
exportError: "Export fehlgeschlagen: {message}",
importError: "Import fehlgeschlagen: {message}",
importSuccess: "{created} neu angelegt, {updated} aktualisiert.",
importSkipped: "{count} Zeile(n) übersprungen: {reasons}",
importSkippedRow: "Zeile {row}: {reason}",
},
sectionManager: {
title: "Essenszeiten",
newSection: "Neue Essenszeit",
editSection: "Essenszeit bearbeiten",
nameLabel: "Bezeichnung",
nameEnLabel: "Bezeichnung (Englisch)",
namePlLabel: "Bezeichnung (Polnisch)",
timeFrom: "Von",
timeTo: "Bis",
moveUp: "Nach oben",
@@ -183,6 +212,20 @@ const de = {
confirmRemoveLogo: "Logo wirklich entfernen?",
confirmRemoveFavicon: "Favicon wirklich entfernen?",
},
labelingNotice: {
title: "Anzeige von Allergenen & Zusatzstoffen",
hint:
"Standardmäßig sieht jeder Gast eine detaillierte Auswahl der Allergene/Zusatzstoffe je Gericht. Alternativ kann stattdessen nur ein Hinweistext angezeigt werden – die Auswahl je Gericht wird dann auch in der Gerichte-Verwaltung ausgeblendet.",
modeDetailed: "Detaillierte Auswahl je Gericht anzeigen",
modeNotice: "Nur Hinweistext anzeigen",
noticeDeLabel: "Hinweistext (Deutsch)",
noticeEnLabel: "Hinweistext (Englisch)",
noticePlLabel: "Hinweistext (Polnisch)",
save: "Speichern",
saved: "Gespeichert.",
loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}",
},
passkeys: {
title: "Passkeys",
hint: "Mit einem Passkey (Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel) kannst du dich künftig ohne Passwort anmelden. Passkeys gelten pro Domain – nach einem Domainwechsel müssen sie neu registriert werden.",
@@ -252,513 +295,43 @@ const de = {
actionError: "Aktion fehlgeschlagen: {message}",
submittedOn: "eingereicht am {date}",
},
languageManager: {
title: "Sprachen",
hint:
"Deutsch ist die feste Basis-Sprache und immer vollständig. Weitere Sprachen werden als Übersetzungsdatei importiert und automatisch mit Deutsch aufgefüllt, wenn einzelne Begriffe fehlen.",
codeColumn: "Code",
labelColumn: "Anzeigename",
activeColumn: "Aktiv",
completenessColumn: "Vollständigkeit",
baseBadge: "Basis-Sprache",
complete: "Vollständig",
completeness: "{percent} % übersetzt",
outdated: "{count} neue(r) Begriff(e) seit letztem Import",
downloadTemplate: "Vorlage herunterladen",
uploadTranslation: "Übersetzung hochladen",
newLanguageTitle: "Neue Sprache anlegen",
newLanguageCodeLabel: "Sprachcode (z. B. fr)",
newLanguageLabelLabel: "Anzeigename (z. B. Français)",
create: "Anlegen",
delete: "Löschen",
confirmDelete:
"„{label}“ wirklich löschen? Alle Übersetzungen dieser Sprache gehen verloren.",
cannotDeleteBase: "Die Basis-Sprache kann nicht gelöscht werden.",
cannotDeactivateLast: "Mindestens eine Sprache muss aktiv bleiben.",
loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
importSuccess: "{count} Begriffe importiert.",
importError: "Import fehlgeschlagen: {message}",
importInvalidFile: "Die Datei konnte nicht gelesen werden (ungültiges JSON).",
importMissingPlaceholder:
"„{key}“: Platzhalter {placeholder} fehlt in der Übersetzung – Zeile übersprungen.",
duplicateCode: "Der Sprachcode „{code}“ existiert bereits.",
invalidCode: "Bitte einen Sprachcode aus 2–5 Kleinbuchstaben angeben.",
},
};
type Dict = typeof de;
const en: Dict = {
common: {
save: "Save",
cancel: "Cancel",
delete: "Delete",
edit: "Edit",
add: "Add",
back: "Back",
login: "Log in",
logout: "Log out",
loading: "Loading…",
you: "(you)",
name: "Name",
email: "Email address",
password: "Password",
role: "Role",
roleAdmin: "Administrator",
roleStaff: "Canteen staff",
},
weekdays: {
long: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
short: ["Mon", "Tue", "Wed", "Thu", "Fri"],
},
dateLocale: "en-GB",
header: {
canteen: "Canteen",
menuGroup: "Menu",
weekPlan: "Weekly plan",
sections: "Meal times",
dishes: "Dishes",
labels: "Allergens & Additives",
adminGroup: "Administration",
users: "Users",
design: "Design",
suggestions: "Suggestions",
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.",
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…",
changeDish: "Choose a different dish",
addMeal: "Add meal",
newDishesHint: "Add new dishes under {link}.",
newDishesLinkLabel: "Dishes",
loadError: "Loading failed: {message}",
duplicateEntry: "This dish is already assigned on this day.",
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
},
dishManager: {
title: "Dishes",
newDish: "New dish",
editDish: "Edit dish",
nameLabel: "Name",
nameEnLabel: "Name (English)",
namePlLabel: "Name (Polish)",
autoTranslate: "Auto-translate",
translating: "Translating…",
translated: "Translated.",
priceLabel: "Price in €",
pricePlaceholder: "3.50",
searchPlaceholder: "Search dishes…",
selectAll: "Select all",
selectedCount: "{count} selected",
deleteSelected: "Delete selected",
deleteSelectedCount: "Delete selected ({count})",
empty: "No dishes yet.",
noResults: "No results for “{query}”.",
confirmDelete:
"Really delete “{name}”? The dish will also be removed from all weekly plans.",
confirmDeleteSelected:
"Really delete {count} dish(es)? They will also be removed from all weekly plans.",
invalidPrice: "Please enter a valid price, e.g. 3.50",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
},
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}",
},
sectionManager: {
title: "Meal times",
newSection: "New meal time",
editSection: "Edit meal time",
nameLabel: "Name",
nameEnLabel: "Name (English)",
namePlLabel: "Name (Polish)",
timeFrom: "From",
timeTo: "To",
moveUp: "Move up",
moveDown: "Move down",
empty: "No meal times yet.",
confirmDelete:
"Really delete “{name}”? Assigned dishes will also be removed from all weekly plans.",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
},
userManager: {
title: "Users",
newUser: "New user",
editUser: "Edit user",
newPasswordLabel: "New password (optional)",
repeatPasswordLabel: "Repeat password",
passwordMismatch: "The passwords do not match.",
confirmDelete:
"Really delete user “{email}”? Access will be permanently removed.",
loadError: "Loading failed: {message}",
},
designEditor: {
title: "Design",
logoTitle: "Logo",
logoHint: "The logo is displayed centered above the menu.",
logoNone: "No logo set.",
logoUpload: "Upload logo",
logoRemove: "Remove logo",
logoUpdated: "Logo updated.",
logoRemoved: "Logo removed.",
faviconTitle: "Favicon",
faviconHint:
"Shown as the icon in the browser tab. Without a custom favicon, the application's default icon is used.",
faviconNone: "No custom favicon set.",
faviconUpload: "Upload favicon",
faviconRemove: "Remove favicon",
faviconUpdated: "Favicon updated.",
faviconRemoved: "Favicon removed.",
invalidImage: "Please choose an image file (PNG, JPG, SVG, WebP, ICO).",
colorsTitle: "Colors",
colorsSave: "Save colors",
colorsReset: "Reset to default",
colorsSaved: "Colors saved.",
colorPrimary: "Primary color (table header)",
colorPrimaryDark: "Primary color, dark (internal top bar)",
colorAccent: "Accent color",
colorHeaderText: "Table header text color",
colorRowBg: "Cell background",
colorRowBgAlt: "Cell background (alternating)",
colorAddBtn: "“Add” button",
colorDeleteBtn: "“Delete” button",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
uploadError: "Upload failed: {message}",
confirmRemoveLogo: "Really remove the logo?",
confirmRemoveFavicon: "Really remove the favicon?",
},
passkeys: {
title: "Passkeys",
hint: "With a passkey (fingerprint, face recognition, or a security key) you can sign in without a password in the future. Passkeys are bound to a domain – after a domain change they need to be registered again.",
register: "Register new passkey",
empty: "No passkeys registered yet.",
createdOn: "– created on {date}",
registered: "Passkey registered successfully.",
registerError: "Registration failed: {message}",
registerCancelled: "Passkey registration was cancelled.",
loadError: "Could not load passkeys: {message}",
deleteError: "Deleting failed: {message}",
confirmDelete: "Really delete this passkey?",
},
account: {
title: "My account",
personalTitle: "Personal details",
saveName: "Save name",
nameSaved: "Name saved.",
nameRequired: "Please enter a name.",
emailTitle: "Email address",
changeEmail: "Change email address",
emailChanged: "Email address changed.",
passwordTitle: "Change password",
currentPassword: "Current password (for verification)",
newPassword: "New password",
newPasswordRepeat: "Repeat new password",
changePassword: "Change password",
passwordChanged: "Password changed.",
passwordMismatch: "The passwords do not match.",
passwordTooShort: "The new password must be at least 8 characters long.",
passwordAuthTitle: "Password sign-in",
passwordAuthHint:
"You can disable email and password sign-in for your account and sign in exclusively with a passkey in the future. At least one passkey must be registered for this.",
passwordAuthCheckbox: "Email and password sign-in allowed",
passwordAuthEnabled: "Password sign-in enabled.",
passwordAuthDisabled: "Password sign-in disabled.",
passwordAuthNeedsPasskey:
"You can't disable password sign-in without a registered passkey – you would lock yourself out. Register a passkey below first.",
confirmDisablePasswordAuth:
"Really disable email and password sign-in? Afterwards you can only sign in with a passkey.",
wrongCurrentPassword: "The current password is incorrect.",
emailAlreadyUsed: "This email address is already in use.",
saveError: "Saving failed: {message}",
changeError: "Change failed: {message}",
},
suggestions: {
formTitle: "Suggest a dish",
formHint:
"Missing a dish on the menu? Suggest it here – the canteen team will review your suggestion.",
nameLabel: "Dish name",
descriptionLabel: "Description / ingredients (optional)",
submit: "Submit suggestion",
submitting: "Submitting…",
success: "Thanks! Your suggestion has been submitted.",
duplicate: "This dish already exists on the menu.",
genericError: "Submission failed: {message}",
nameRequired: "Please enter a name.",
backToMenu: "← Back to menu",
reviewTitle: "Dish suggestions",
reviewEmpty: "No suggestions yet.",
accept: "Add as dish",
reject: "Reject",
confirmReject: "Really reject and delete the suggestion “{name}”?",
accepted: "Added as dish.",
rejected: "Suggestion rejected.",
loadError: "Loading failed: {message}",
actionError: "Action failed: {message}",
submittedOn: "submitted on {date}",
},
};
const pl: Dict = {
common: {
save: "Zapisz",
cancel: "Anuluj",
delete: "Usuń",
edit: "Edytuj",
add: "Dodaj",
back: "Wstecz",
login: "Zaloguj się",
logout: "Wyloguj się",
loading: "Wczytywanie…",
you: "(ty)",
name: "Nazwa",
email: "Adres e-mail",
password: "Hasło",
role: "Rola",
roleAdmin: "Administrator",
roleStaff: "Personel stołówki",
},
weekdays: {
long: ["Poniedziałek", "Wtorek", "Środa", "Czwartek", "Piątek"],
short: ["Pon", "Wto", "Śro", "Czw", "Pią"],
},
dateLocale: "pl-PL",
header: {
canteen: "Stołówka",
menuGroup: "Jadłospis",
weekPlan: "Plan tygodnia",
sections: "Pory posiłków",
dishes: "Dania",
labels: "Alergeny i dodatki",
adminGroup: "Administracja",
users: "Użytkownicy",
design: "Wygląd",
suggestions: "Propozycje",
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.",
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…",
changeDish: "Wybierz inne danie",
addMeal: "Dodaj posiłek",
newDishesHint: "Nowe dania możesz dodać w zakładce {link}.",
newDishesLinkLabel: "Dania",
loadError: "Wczytywanie nie powiodło się: {message}",
duplicateEntry: "To danie jest już przypisane na ten dzień.",
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
},
dishManager: {
title: "Dania",
newDish: "Nowe danie",
editDish: "Edytuj danie",
nameLabel: "Nazwa",
nameEnLabel: "Nazwa (angielski)",
namePlLabel: "Nazwa (polski)",
autoTranslate: "Przetłumacz automatycznie",
translating: "Tłumaczenie…",
translated: "Przetłumaczono.",
priceLabel: "Cena w €",
pricePlaceholder: "3,50",
searchPlaceholder: "Szukaj dania…",
selectAll: "Zaznacz wszystko",
selectedCount: "Zaznaczono: {count}",
deleteSelected: "Usuń zaznaczone",
deleteSelectedCount: "Usuń zaznaczone ({count})",
empty: "Nie dodano jeszcze żadnych dań.",
noResults: "Brak wyników dla „{query}”.",
confirmDelete:
"Czy na pewno usunąć „{name}”? Danie zostanie również usunięte ze wszystkich planów tygodniowych.",
confirmDeleteSelected:
"Czy na pewno usunąć {count} danie/dania? Zostaną również usunięte ze wszystkich planów tygodniowych.",
invalidPrice: "Podaj prawidłową cenę, np. 3,50",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
},
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}",
},
sectionManager: {
title: "Pory posiłków",
newSection: "Nowa pora posiłku",
editSection: "Edytuj porę posiłku",
nameLabel: "Nazwa",
nameEnLabel: "Nazwa (angielski)",
namePlLabel: "Nazwa (polski)",
timeFrom: "Od",
timeTo: "Do",
moveUp: "Przesuń w górę",
moveDown: "Przesuń w dół",
empty: "Nie dodano jeszcze żadnych pór posiłków.",
confirmDelete:
"Czy na pewno usunąć „{name}”? Przypisane dania zostaną również usunięte ze wszystkich planów tygodniowych.",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
},
userManager: {
title: "Użytkownicy",
newUser: "Nowy użytkownik",
editUser: "Edytuj użytkownika",
newPasswordLabel: "Nowe hasło (opcjonalnie)",
repeatPasswordLabel: "Powtórz hasło",
passwordMismatch: "Hasła nie są zgodne.",
confirmDelete:
"Czy na pewno usunąć użytkownika „{email}”? Dostęp zostanie trwale usunięty.",
loadError: "Wczytywanie nie powiodło się: {message}",
},
designEditor: {
title: "Wygląd",
logoTitle: "Logo",
logoHint: "Logo jest wyświetlane wyśrodkowane nad jadłospisem.",
logoNone: "Nie ustawiono logo.",
logoUpload: "Prześlij logo",
logoRemove: "Usuń logo",
logoUpdated: "Logo zaktualizowane.",
logoRemoved: "Logo usunięte.",
faviconTitle: "Favicon",
faviconHint:
"Wyświetlane jako ikona na karcie przeglądarki. Bez własnego favicona używana jest domyślna ikona aplikacji.",
faviconNone: "Nie ustawiono własnego favicona.",
faviconUpload: "Prześlij favicon",
faviconRemove: "Usuń favicon",
faviconUpdated: "Favicon zaktualizowany.",
faviconRemoved: "Favicon usunięty.",
invalidImage: "Wybierz plik graficzny (PNG, JPG, SVG, WebP, ICO).",
colorsTitle: "Kolorystyka",
colorsSave: "Zapisz kolory",
colorsReset: "Przywróć domyślne",
colorsSaved: "Kolory zapisane.",
colorPrimary: "Kolor główny (nagłówek tabeli)",
colorPrimaryDark: "Kolor główny, ciemny (górny pasek wewnętrzny)",
colorAccent: "Kolor akcentu",
colorHeaderText: "Kolor tekstu w nagłówku tabeli",
colorRowBg: "Tło komórek",
colorRowBgAlt: "Tło komórek (naprzemienne)",
colorAddBtn: "Przycisk „Dodaj”",
colorDeleteBtn: "Przycisk „Usuń”",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
uploadError: "Przesyłanie nie powiodło się: {message}",
confirmRemoveLogo: "Czy na pewno usunąć logo?",
confirmRemoveFavicon: "Czy na pewno usunąć favicon?",
},
passkeys: {
title: "Klucze dostępu",
hint: "Dzięki kluczowi dostępu (odcisk palca, rozpoznawanie twarzy lub klucz bezpieczeństwa) możesz w przyszłości logować się bez hasła. Klucze dostępu są przypisane do domeny – po zmianie domeny trzeba je zarejestrować ponownie.",
register: "Zarejestruj nowy klucz dostępu",
empty: "Nie zarejestrowano jeszcze żadnych kluczy dostępu.",
createdOn: "– utworzono {date}",
registered: "Klucz dostępu został pomyślnie zarejestrowany.",
registerError: "Rejestracja nie powiodła się: {message}",
registerCancelled: "Rejestracja klucza dostępu została anulowana.",
loadError: "Nie udało się wczytać kluczy dostępu: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
confirmDelete: "Czy na pewno usunąć ten klucz dostępu?",
},
account: {
title: "Moje konto",
personalTitle: "Dane osobowe",
saveName: "Zapisz nazwę",
nameSaved: "Nazwa zapisana.",
nameRequired: "Podaj nazwę.",
emailTitle: "Adres e-mail",
changeEmail: "Zmień adres e-mail",
emailChanged: "Adres e-mail zmieniony.",
passwordTitle: "Zmień hasło",
currentPassword: "Obecne hasło (do weryfikacji)",
newPassword: "Nowe hasło",
newPasswordRepeat: "Powtórz nowe hasło",
changePassword: "Zmień hasło",
passwordChanged: "Hasło zmienione.",
passwordMismatch: "Hasła nie są zgodne.",
passwordTooShort: "Nowe hasło musi mieć co najmniej 8 znaków.",
passwordAuthTitle: "Logowanie hasłem",
passwordAuthHint:
"Możesz wyłączyć logowanie e-mailem i hasłem dla swojego konta i logować się w przyszłości wyłącznie kluczem dostępu. Wymaga to zarejestrowania co najmniej jednego klucza dostępu.",
passwordAuthCheckbox: "Logowanie e-mailem i hasłem dozwolone",
passwordAuthEnabled: "Logowanie hasłem włączone.",
passwordAuthDisabled: "Logowanie hasłem wyłączone.",
passwordAuthNeedsPasskey:
"Bez zarejestrowanego klucza dostępu nie możesz wyłączyć logowania hasłem – stracisz dostęp do konta. Najpierw zarejestruj klucz dostępu poniżej.",
confirmDisablePasswordAuth:
"Czy na pewno wyłączyć logowanie e-mailem i hasłem? Potem będziesz mógł/mogła zalogować się tylko kluczem dostępu.",
wrongCurrentPassword: "Obecne hasło jest nieprawidłowe.",
emailAlreadyUsed: "Ten adres e-mail jest już używany.",
saveError: "Zapisywanie nie powiodło się: {message}",
changeError: "Zmiana nie powiodła się: {message}",
},
suggestions: {
formTitle: "Zaproponuj danie",
formHint:
"Brakuje dania w jadłospisie? Zaproponuj je tutaj – zespół stołówki sprawdzi Twoją propozycję.",
nameLabel: "Nazwa dania",
descriptionLabel: "Opis / składniki (opcjonalnie)",
submit: "Wyślij propozycję",
submitting: "Wysyłanie…",
success: "Dziękujemy! Twoja propozycja została przesłana.",
duplicate: "To danie już istnieje w jadłospisie.",
genericError: "Wysyłanie nie powiodło się: {message}",
nameRequired: "Podaj nazwę.",
backToMenu: "← Powrót do jadłospisu",
reviewTitle: "Propozycje dań",
reviewEmpty: "Brak propozycji.",
accept: "Dodaj jako danie",
reject: "Odrzuć",
confirmReject: "Czy na pewno odrzucić i usunąć propozycję „{name}”?",
accepted: "Dodano jako danie.",
rejected: "Propozycja odrzucona.",
loadError: "Wczytywanie nie powiodło się: {message}",
actionError: "Czynność nie powiodła się: {message}",
submittedOn: "przesłano {date}",
},
};
export type { Dict };
export const dictionaries = { de, en, pl };
export const dictionaries = { de };
+46
View File
@@ -0,0 +1,46 @@
// Reine, abhängigkeitsfreie Umwandlung zwischen dem verschachtelten
// Dict-Objekt (lib/i18n/dictionaries.ts) und flachen Schlüssel-Wert-
// Paaren ("login.errorGeneric") – Grundlage für Vorlage-Export/-Import
// von Sprachpaketen. Arrays (z. B. weekdays.long) werden über den
// numerischen Index als eigene Schlüssel geflattet ("weekdays.long.0")
// und beim Zurückbauen anhand rein numerischer Kind-Schlüssel wieder
// als Array erkannt.
export function flattenDict(value: unknown, prefix = ""): Record<string, string> {
const out: Record<string, string> = {};
function walk(node: unknown, path: string) {
if (Array.isArray(node)) {
node.forEach((item, i) => walk(item, `${path}.${i}`));
} else if (node !== null && typeof node === "object") {
for (const [key, child] of Object.entries(node)) {
walk(child, path ? `${path}.${key}` : key);
}
} else {
out[path] = String(node);
}
}
walk(value, prefix);
return out;
}
export function unflattenDict(flat: Record<string, string>): unknown {
const root: Record<string, unknown> = {};
for (const [path, value] of Object.entries(flat)) {
const parts = path.split(".");
let node: Record<string, unknown> = root;
for (let i = 0; i < parts.length - 1; i++) {
const key = parts[i];
if (node[key] === undefined) {
const nextIsIndex = /^\d+$/.test(parts[i + 1]);
node[key] = nextIsIndex ? [] : {};
}
node = node[key] as Record<string, unknown>;
}
node[parts[parts.length - 1]] = value;
}
return root;
}
+29 -5
View File
@@ -1,16 +1,40 @@
// Nur für Server-Komponenten/Routen importieren (next/headers)!
// Nur für Server-Komponenten/Routen importieren (next/headers, DB)!
// Client-Komponenten müssen stattdessen "@/lib/i18n/shared" verwenden,
// sonst landet next/headers im Client-Bundle.
// sonst landet next/headers/die DB-Anbindung im Client-Bundle.
import "server-only";
import { cookies } from "next/headers";
import { listActiveLanguageCodes, listTranslations } from "@/lib/db/languages";
import { dictionaries, type Dict } from "./dictionaries";
import { flattenDict, unflattenDict } from "./flatten";
import { DEFAULT_LOCALE, isLocale, type Locale } from "./locales";
import { LOCALE_COOKIE } from "./shared";
export * from "./shared";
/** Aktuelle Sprache aus dem Cookie lesen (Server-Komponenten). */
/** Aktuelle Sprache aus dem Cookie lesen, validiert gegen die aktiven
* Sprachen (Server-Komponenten). Ungültig oder nicht mehr aktiv →
* Basis-Sprache, sofern die noch aktiv ist – sonst die erste aktive
* Sprache (z. B. wenn Deutsch im Admin-Bereich deaktiviert wurde). */
export async function getLocale(): Promise<Locale> {
const store = await cookies();
const value = store.get("locale")?.value;
return isLocale(value) ? value : DEFAULT_LOCALE;
const value = store.get(LOCALE_COOKIE)?.value;
const active = await listActiveLanguageCodes();
if (isLocale(value, active)) return value;
if (active.includes(DEFAULT_LOCALE)) return DEFAULT_LOCALE;
return active[0] ?? DEFAULT_LOCALE;
}
/** Wörterbuch für eine Sprache. Nicht-deutsche Sprachen werden aus der
* Datenbank geladen und mit der deutschen Vorlage aufgefüllt – fehlt
* eine Übersetzung (z. B. nach einem Update mit neuen Texten), zeigt
* die App automatisch den deutschen Text statt leer zu bleiben. */
export async function getDictionary(locale: Locale): Promise<Dict> {
if (locale === DEFAULT_LOCALE) return dictionaries.de;
const rows = await listTranslations(locale);
const flat = flattenDict(dictionaries.de);
for (const row of rows) {
if (row.key in flat) flat[row.key] = row.value;
}
return unflattenDict(flat) as Dict;
}
+11 -11
View File
@@ -1,13 +1,13 @@
export const LOCALES = ["de", "en", "pl"] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "de";
// Sprachen sind seit v1.10.0 zur Laufzeit verwaltbar (Tabelle
// "languages", siehe lib/db/languages.ts) statt fest im Code zu
// stehen. "de" bleibt als einzige garantiert vollständige Basis-
// Sprache ein Sonderfall (siehe lib/i18n/dictionaries.ts).
export const DEFAULT_LOCALE = "de";
export type Locale = string;
export const LOCALE_LABELS: Record<Locale, string> = {
de: "Deutsch",
en: "English",
pl: "Polski",
};
export function isLocale(value: string | undefined | null): value is Locale {
return !!value && (LOCALES as readonly string[]).includes(value);
export function isLocale(
value: string | undefined | null,
activeCodes: readonly string[]
): value is Locale {
return !!value && activeCodes.includes(value);
}
+5 -10
View File
@@ -1,19 +1,14 @@
// Client-sicherer Teil von lib/i18n: keine Abhängigkeit auf next/headers,
// darf daher auch von "use client"-Komponenten importiert werden.
// Client-sicherer Teil von lib/i18n: keine Abhängigkeit auf next/headers
// oder die Datenbank, darf daher auch von "use client"-Komponenten
// importiert werden. getDictionary()/getLocale() (DB-gestützt) leben
// in lib/i18n/index.ts und dürfen nur serverseitig importiert werden.
import { dictionaries, type Dict } from "./dictionaries";
import type { Locale } from "./locales";
export { LOCALES, LOCALE_LABELS, DEFAULT_LOCALE, isLocale } from "./locales";
export { DEFAULT_LOCALE, isLocale } from "./locales";
export type { Locale } from "./locales";
export type { Dict } from "./dictionaries";
export const LOCALE_COOKIE = "locale";
export function getDictionary(locale: Locale): Dict {
return dictionaries[locale];
}
/** Platzhalter der Form "{name}" durch vars.name ersetzen. */
export function formatMessage(
template: string,
+83
View File
@@ -0,0 +1,83 @@
import "server-only";
import { createHash } from "node:crypto";
import { dictionaries } from "./dictionaries";
import { flattenDict } from "./flatten";
/** Aktuelle deutsche Vorlage als flache Schlüssel-Wert-Paare – Basis
* für Export (Vorlage für Übersetzer), Vollständigkeits- und
* Veraltet-Prüfung. */
export function currentTemplate(): Record<string, string> {
return flattenDict(dictionaries.de);
}
/** Hash über die (sortierte) Schlüsselliste der aktuellen Vorlage –
* ändert sich nur, wenn Schlüssel hinzukommen/wegfallen, nicht bei
* reinen Text-/Tippfehler-Korrekturen am deutschen Text. */
export function currentTemplateHash(): string {
const keys = Object.keys(currentTemplate()).sort();
return createHash("sha256").update(keys.join("\n")).digest("hex");
}
export interface LanguageCompleteness {
total: number;
translated: number;
percent: number;
missingKeys: string[];
}
export function computeCompleteness(
translated: Record<string, string>
): LanguageCompleteness {
const templateKeys = Object.keys(currentTemplate());
const missingKeys = templateKeys.filter((k) => !(k in translated));
const translatedCount = templateKeys.length - missingKeys.length;
return {
total: templateKeys.length,
translated: translatedCount,
percent:
templateKeys.length === 0
? 100
: Math.round((translatedCount / templateKeys.length) * 100),
missingKeys,
};
}
function extractPlaceholders(text: string): Set<string> {
return new Set([...text.matchAll(/\{(\w+)\}/g)].map((m) => m[1]));
}
export interface ImportValidationResult {
entries: { key: string; value: string }[];
skipped: { key: string; reason: string }[];
}
/** Prüft eine hochgeladene Übersetzung gegen die aktuelle Vorlage:
* unbekannte Schlüssel werden stillschweigend ignoriert (z. B. von
* einer älteren Vorlagen-Version), Zeilen mit fehlenden Platzhaltern
* wie {message} übersprungen statt kaputte Texte zu übernehmen. */
export function validateImport(
uploaded: Record<string, unknown>
): ImportValidationResult {
const template = currentTemplate();
const entries: { key: string; value: string }[] = [];
const skipped: { key: string; reason: string }[] = [];
for (const [key, rawValue] of Object.entries(uploaded)) {
const templateValue = template[key];
if (templateValue === undefined) continue;
if (typeof rawValue !== "string" || !rawValue.trim()) {
skipped.push({ key, reason: "empty" });
continue;
}
const expected = extractPlaceholders(templateValue);
const actual = extractPlaceholders(rawValue);
const missing = [...expected].filter((p) => !actual.has(p));
if (missing.length > 0) {
skipped.push({ key, reason: `missing_placeholder:${missing[0]}` });
continue;
}
entries.push({ key, value: rawValue });
}
return { entries, skipped };
}
+31 -8
View File
@@ -12,8 +12,9 @@ export interface Profile {
export interface Section {
key: string;
label: string;
label_en: string | null;
label_pl: string | null;
// Sprachcode (z. B. "en", "fr") -> übersetzter Name. Enthält nie "de" -
// Deutsch ist immer die "label"-Spalte selbst.
translations: Record<string, string>;
time_range: string;
sort: number;
}
@@ -21,8 +22,7 @@ export interface Section {
export interface Dish {
id: string;
name: string;
name_en: string | null;
name_pl: string | null;
translations: Record<string, string>;
price: number;
allergens: string[];
additives: string[];
@@ -39,8 +39,7 @@ export interface MenuEntry {
export interface CodeItem {
code: string;
name: string;
name_en: string | null;
name_pl: string | null;
translations: Record<string, string>;
sort: number;
}
@@ -51,12 +50,36 @@ export interface DishSuggestion {
created_at: string;
}
export interface ClosedDay {
closed_date: string;
reason: string | null;
translations: Record<string, 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 },
item: {
name?: string;
label?: string;
// Grund eines geschlossenen Tages (s. ClosedDay) – optional, anders
// als name/label bei den übrigen Inhaltstypen.
reason?: string | null;
// Neue, dynamische Form (Gerichte/Essenszeiten/Allergene/
// Zusatzstoffe, s. lib/db/translations.ts) – Sprachcode -> Text.
translations?: Record<string, string>;
// Ältere, feste Form – wird u. a. noch für den Hinweistext in den
// Design-Einstellungen genutzt (settings.labeling_notice_en/_pl),
// die kein Übersetzungstabellen-Konzept haben.
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 ?? "";
const de = item.name ?? item.label ?? item.reason ?? "";
if (locale === "de") return de;
if (item.translations) return item.translations[locale] || de;
if (locale === "en") return item.name_en || item.label_en || de;
if (locale === "pl") return item.name_pl || item.label_pl || de;
return de;
+78 -2
View File
@@ -1,15 +1,16 @@
{
"name": "canteen-menu",
"version": "1.5.0",
"version": "1.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "canteen-menu",
"version": "1.5.0",
"version": "1.10.0",
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"jose": "^6.2.3",
"next": "16.2.10",
"pg": "^8.22.0",
"react": "19.2.4",
@@ -1767,6 +1768,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -4864,6 +4931,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canteen-menu",
"version": "1.5.0",
"version": "1.12.1",
"private": true,
"scripts": {
"predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs",
@@ -15,6 +15,7 @@
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"jose": "^6.2.3",
"next": "16.2.10",
"pg": "^8.22.0",
"react": "19.2.4",
+19 -2
View File
@@ -58,19 +58,36 @@ async function main() {
[email, passwordHash]
);
// Der Admin-Datensatz steht ab hier in der Datenbank – das darf durch
// ein Problem beim Ablegen der Zugangsdaten nicht mehr verloren gehen
// (sonst gibt es einen Benutzer, dessen Passwort niemand kennt).
// appendFileSync() also bewusst nicht mehr ungefangen werfen lassen,
// sondern im Fehlerfall die Zugangsdaten direkt ins Log schreiben.
const zugangsdatenPath = join(
dirname(dirname(fileURLToPath(import.meta.url))),
"ZUGANGSDATEN.md"
);
const entry = `\n## Admin-Zugang (automatisch angelegt, ${new Date().toISOString().slice(0, 10)})\n\n- E-Mail: \`${email}\`\n- Passwort: \`${password}\`\n\nBitte nach dem ersten Login ändern.\n`;
let wroteFile = false;
try {
if (existsSync(zugangsdatenPath)) {
appendFileSync(zugangsdatenPath, entry);
wroteFile = true;
}
} catch (err) {
console.warn(`bootstrap-admin – Konnte ${zugangsdatenPath} nicht beschreiben: ${err.message}`);
}
if (wroteFile) {
console.log(`bootstrap-admin – Admin angelegt: ${email} (Passwort siehe ZUGANGSDATEN.md)`);
} else {
console.warn(
`bootstrap-admin – ${zugangsdatenPath} existiert nicht, Zugangsdaten werden nur geloggt:`
`bootstrap-admin – ${zugangsdatenPath} existiert nicht oder ist nicht beschreibbar. ` +
`Zugangsdaten NUR HIER im Log, bitte sofort notieren:`
);
console.log(`bootstrap-admin – Admin-E-Mail: ${email}`);
console.log(`bootstrap-admin – Admin-Passwort: ${password}`);
}
console.log(`bootstrap-admin – Admin angelegt: ${email} (Passwort siehe ZUGANGSDATEN.md)`);
} finally {
await client.end();
}
Executable
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Aktualisiert ein per Docker Compose betriebenes Deployment:
# Stack stoppen -> lokale docker-compose.yml-Änderungen sichern (git stash)
# -> git pull -> Änderungen zurückspielen -> Stack neu bauen und starten.
#
# Aufruf im Projektverzeichnis (dort, wo docker-compose.yml liegt):
# ./update.sh
set -euo pipefail
cd "$(dirname "$0")"
ENV_FILE=".env.local"
if [ ! -f "$ENV_FILE" ]; then
echo "Fehler: $ENV_FILE nicht gefunden. Bitte im Projektverzeichnis ausführen." >&2
exit 1
fi
echo "==> Stoppe laufenden Stack..."
docker compose --env-file "$ENV_FILE" down
STASHED=0
if ! git diff --quiet -- docker-compose.yml || ! git diff --cached --quiet -- docker-compose.yml; then
echo "==> Sichere lokale Änderungen an docker-compose.yml (git stash)..."
git stash push --message "update.sh: docker-compose.yml vor Pull gesichert" -- docker-compose.yml
STASHED=1
fi
echo "==> Hole neueste Version (git pull)..."
git pull
if [ "$STASHED" -eq 1 ]; then
echo "==> Stelle lokale Änderungen an docker-compose.yml wieder her..."
if ! git stash pop; then
echo "Fehler: git stash pop hatte einen Konflikt in docker-compose.yml." >&2
echo "Bitte den Konflikt manuell auflösen (git status), danach Stack manuell starten:" >&2
echo " docker compose --env-file $ENV_FILE up -d --build" >&2
exit 1
fi
fi
echo "==> Baue und starte Stack neu..."
docker compose --env-file "$ENV_FILE" up -d --build
echo "==> Fertig. Status:"
docker compose ps