Compare commits

..
14 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
55 changed files with 3219 additions and 1018 deletions
+105
View File
@@ -4,6 +4,111 @@ 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
+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).
+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 />;
}
+8 -3
View File
@@ -1,14 +1,19 @@
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 = await listCodes("additives");
const [items, activeCodes] = await Promise.all([
listCodes("additives"),
listActiveLanguageCodes(),
]);
const langCodes = activeCodes.filter((c) => c !== "de");
const rows = [
["code", "name", "name_en", "name_pl"],
...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]),
["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: {
+11 -8
View File
@@ -1,5 +1,6 @@
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";
@@ -22,8 +23,10 @@ export async function POST(request: Request) {
if (codeIdx === -1 || nameIdx === -1) {
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
}
const nameEnIdx = header.indexOf("name_en");
const namePlIdx = header.indexOf("name_pl");
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 }[] = [];
@@ -35,12 +38,12 @@ export async function POST(request: Request) {
skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" });
return;
}
rows.push({
code,
name,
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null,
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
});
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 };
@@ -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"',
},
});
}
+8 -3
View File
@@ -1,14 +1,19 @@
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 = await listCodes("allergens");
const [items, activeCodes] = await Promise.all([
listCodes("allergens"),
listActiveLanguageCodes(),
]);
const langCodes = activeCodes.filter((c) => c !== "de");
const rows = [
["code", "name", "name_en", "name_pl"],
...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]),
["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: {
+11 -8
View File
@@ -1,5 +1,6 @@
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";
@@ -22,8 +23,10 @@ export async function POST(request: Request) {
if (codeIdx === -1 || nameIdx === -1) {
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
}
const nameEnIdx = header.indexOf("name_en");
const namePlIdx = header.indexOf("name_pl");
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 }[] = [];
@@ -35,12 +38,12 @@ export async function POST(request: Request) {
skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" });
return;
}
rows.push({
code,
name,
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null,
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
});
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 };
+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);
+22 -6
View File
@@ -1,19 +1,35 @@
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";
import type { Dish } from "@/lib/types";
interface DishRow {
id: string;
name: string;
price: string;
allergens: string[];
additives: string[];
}
export async function GET() {
try {
await requireRole(["staff", "admin"]);
const dishes = await query<Dish>("select * from dishes order by name");
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", "name_en", "name_pl", "price", "allergens", "additives"],
["name", ...langCodes.map((c) => `name_${c}`), "price", "allergens", "additives"],
...dishes.map((d) => [
d.name,
d.name_en ?? "",
d.name_pl ?? "",
String(d.price),
...langCodes.map((c) => translations.get(d.id)?.[c] ?? ""),
d.price,
(d.allergens ?? []).join(";"),
(d.additives ?? []).join(";"),
]),
+27 -13
View File
@@ -1,5 +1,7 @@
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";
@@ -7,8 +9,7 @@ import { parseCsvWithHeader, splitCodes } from "@/lib/csv";
interface ImportRow {
rowNum: number;
name: string;
name_en: string | null;
name_pl: string | null;
translations: Record<string, string>;
price: number;
allergens: string[];
additives: string[];
@@ -32,8 +33,10 @@ export async function POST(request: Request) {
if (nameIdx === -1 || priceIdx === -1) {
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
}
const nameEnIdx = header.indexOf("name_en");
const namePlIdx = header.indexOf("name_pl");
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");
@@ -52,11 +55,15 @@ export async function POST(request: Request) {
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,
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null,
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
translations,
price,
allergens: allergensIdx >= 0 ? splitCodes(c[allergensIdx]) : [],
additives: additivesIdx >= 0 ? splitCodes(c[additivesIdx]) : [],
@@ -65,6 +72,7 @@ export async function POST(request: Request) {
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) {
@@ -84,21 +92,27 @@ export async function POST(request: Request) {
}
if (existing.rows.length === 1) {
await client.query(
`update dishes set name_en = $1, name_pl = $2, price = $3, allergens = $4, additives = $5
where id = $6`,
[row.name_en, row.name_pl, row.price, row.allergens, row.additives, existing.rows[0].id]
`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 {
await client.query(
`insert into dishes (name, name_en, name_pl, price, allergens, additives)
values ($1, $2, $3, $4, $5, $6)`,
[row.name, row.name_en, row.name_pl, row.price, row.allergens, row.additives]
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 });
+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 });
+31 -11
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 });
values.push(key);
const section = await queryOne<Section>(
`update sections set ${fields.join(", ")} where key = $${values.length} returning *`,
values
);
if (!section) return NextResponse.json({ error: "not_found" }, { status: 404 });
let row: SectionRow | null;
if (fields.length > 0) {
values.push(key);
row = await queryOne<SectionRow>(
`update sections set ${fields.join(", ")} where key = $${values.length}
returning key, label, time_range, sort`,
values
);
} 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);
+44 -27
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." },
+25 -16
View File
@@ -1,5 +1,6 @@
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";
@@ -45,30 +46,38 @@ export async function GET() {
// Wochennavigation anzeigt.
const rangeEnd = addDays(monday, 11);
const [sections, entries] = await Promise.all([
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))).sort();
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 dayContent = 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>`
)
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("");
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);
+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;
+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}
/>
);
}
+11 -3
View File
@@ -4,6 +4,7 @@ import "./globals.css";
import { getSettings } from "@/lib/db/settings";
import { COLOR_VARS, DEFAULT_COLORS, faviconUrl, SITE_TITLE } from "@/lib/types";
import { getDictionary, getLocale } from "@/lib/i18n";
import { listLanguages } from "@/lib/db/languages";
import { LocaleProvider } from "@/components/i18n/LocaleProvider";
import packageJson from "@/package.json";
@@ -41,8 +42,15 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const [settings, locale] = await Promise.all([getSettings(), getLocale()]);
const dict = getDictionary(locale);
const [settings, locale, allLanguages] = await Promise.all([
getSettings(),
getLocale(),
listLanguages(),
]);
const dict = await getDictionary(locale);
const languages = allLanguages
.filter((l) => l.active)
.map((l) => ({ code: l.code, label: l.label }));
const colors: Record<string, string> = {
...DEFAULT_COLORS,
@@ -55,7 +63,7 @@ export default async function RootLayout({
return (
<html lang={locale} className={`${geistSans.variable} h-full antialiased`}>
<body style={style} className="min-h-full flex flex-col">
<LocaleProvider locale={locale} dict={dict}>
<LocaleProvider locale={locale} dict={dict} languages={languages}>
{children}
</LocaleProvider>
<footer className="mt-auto px-4 py-2 text-right text-xs text-gray-400 select-none">
+74 -22
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,19 +29,47 @@ 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";
@@ -104,6 +134,7 @@ export default async function Home({
monday={monday}
sections={sections}
entries={entries}
closedDays={closedDays}
showLabeling={labelingDetailed}
/>
@@ -139,31 +170,52 @@ 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) => (
<td key={d.iso} className="cell">
{entriesFor(entries, d.iso, s.key).map((e) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish">
<span className="font-bold">
{localized(e.dish, locale)}
{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>
{labelingDetailed && codes && (
<sup className="dish-codes">{codes}</sup>
<span className="font-semibold">{dict.publicMenu.closedLabel}</span>
{localized(closed, locale) && (
<span className="text-sm px-2 text-center">
{localized(closed, locale)}
</span>
)}
{" – "}
{formatPrice(e.dish.price)}
</div>
);
})}
</td>
))}
</td>
);
}
return (
<td key={d.iso} className="cell">
{entriesFor(entries, d.iso, s.key).map((e) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish">
<span className="font-bold">
{localized(e.dish, locale)}
</span>
{labelingDetailed && codes && (
<sup className="dish-codes">{codes}</sup>
)}
{" – "}
{formatPrice(e.dish.price)}
</div>
);
})}
</td>
);
})}
</tr>
))}
</tbody>
+1
View File
@@ -53,6 +53,7 @@ export default function AppHeader({
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) =>
+28 -27
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,8 +31,7 @@ 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);
@@ -60,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
@@ -118,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) {
@@ -202,7 +203,7 @@ export default function CodeListManager({
type="button"
className="btn btn-add"
onClick={() => {
setEditing({ original: null, code: "", name: "", nameEn: "", namePl: "" });
setEditing({ original: null, code: "", name: "", translations: {} });
setTranslateState("idle");
}}
>
@@ -237,25 +238,26 @@ export default function CodeListManager({
required
/>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div>
<label className="label">{dict.dishManager.nameEnLabel}</label>
<input
className="input"
value={editing.nameEn}
onChange={(e) => setEditing({ ...editing, nameEn: e.target.value })}
/>
{languages.length > 0 && (
<div className="grid gap-2 sm:grid-cols-2">
{languages.map((lang) => (
<div key={lang.code}>
<label className="label">{lang.label}</label>
<input
className="input"
value={editing.translations[lang.code] ?? ""}
onChange={(e) =>
setEditing({
...editing,
translations: { ...editing.translations, [lang.code]: e.target.value },
})
}
/>
</div>
))}
</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"
@@ -304,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");
}}
+31 -34
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,6 +33,7 @@ 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[]>([]);
@@ -93,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}`, {
@@ -218,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) {
@@ -352,31 +352,29 @@ export default function DishManager({
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label" htmlFor="dish-name-en">
{dict.dishManager.nameEnLabel}
</label>
<input
id="dish-name-en"
className="input"
value={form.nameEn}
onChange={(e) => setForm({ ...form, nameEn: e.target.value })}
/>
{languages.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{languages.map((lang) => (
<div key={lang.code}>
<label className="label" htmlFor={`dish-name-${lang.code}`}>
{lang.label}
</label>
<input
id={`dish-name-${lang.code}`}
className="input"
value={form.translations[lang.code] ?? ""}
onChange={(e) =>
setForm({
...form,
translations: { ...form.translations, [lang.code]: e.target.value },
})
}
/>
</div>
))}
</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"
@@ -495,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 ?? [],
+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>
);
}
+47 -36
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,11 +27,13 @@ export default function PublicMobileMenu({
monday,
sections,
entries,
closedDays,
showLabeling,
}: {
monday: string;
sections: Section[];
entries: MenuEntry[];
closedDays: ClosedDay[];
showLabeling: boolean;
}) {
const router = useRouter();
@@ -60,47 +62,56 @@ 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>
<div className="space-y-3">
{sections.map((s) => {
const items = entriesFor(entries, day.iso, s.key);
return (
<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>
{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);
return (
<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">
{items.length === 0 ? (
<p className="text-gray-500">{dict.publicMenu.noEntry}</p>
) : (
items.map((e) => {
const codes = dishCodes(e.dish);
return (
<p key={e.id} className="mb-1 last:mb-0">
<span className="font-semibold">
{localized(e.dish, locale)}
</span>
{showLabeling && codes && (
<sup className="dish-codes">{codes}</sup>
)}{" "}
– {formatPrice(e.dish.price)}
</p>
);
})
)}
</div>
</div>
<div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm">
{items.length === 0 ? (
<p className="text-gray-500">{dict.publicMenu.noEntry}</p>
) : (
items.map((e) => {
const codes = dishCodes(e.dish);
return (
<p key={e.id} className="mb-1 last:mb-0">
<span className="font-semibold">
{localized(e.dish, locale)}
</span>
{showLabeling && codes && (
<sup className="dish-codes">{codes}</sup>
)}{" "}
– {formatPrice(e.dish.price)}
</p>
);
})
)}
</div>
</div>
);
})}
</div>
);
})}
</div>
)}
</div>
);
}
+27 -32
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
@@ -160,30 +158,28 @@ export default function SectionManager() {
required
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label" htmlFor="section-label-en">
{dict.sectionManager.nameEnLabel}
</label>
<input
id="section-label-en"
className="input"
value={form.labelEn}
onChange={(e) => setForm({ ...form, labelEn: e.target.value })}
/>
{languages.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{languages.map((lang) => (
<div key={lang.code}>
<label className="label" htmlFor={`section-label-${lang.code}`}>
{lang.label}
</label>
<input
id={`section-label-${lang.code}`}
className="input"
value={form.translations[lang.code] ?? ""}
onChange={(e) =>
setForm({
...form,
translations: { ...form.translations, [lang.code]: e.target.value },
})
}
/>
</div>
))}
</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 })}
/>
</div>
</div>
)}
<div className="flex gap-3">
<div className="max-w-32">
<label className="label" htmlFor="section-time-from">
@@ -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,
});
+274 -25
View File
@@ -14,12 +14,19 @@ import {
} 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,
@@ -32,6 +39,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
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
@@ -43,6 +51,19 @@ export default function WeekEditor({ monday }: { monday: string }) {
// 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 });
setSelectedDish("");
@@ -57,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]);
@@ -107,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();
@@ -114,6 +208,132 @@ 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 (
<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}
>
{closed ? "🔒" : "🔓"}
</button>
);
}
// 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 (
<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
@@ -250,6 +470,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
{error}
</p>
)}
{renderClosingForm()}
{loading ? (
<p className="text-gray-500">{dict.weekEditor.loading}</p>
) : (
@@ -272,22 +493,35 @@ export default function WeekEditor({ monday }: { monday: string }) {
</button>
))}
</div>
<h2 className="font-bold text-[var(--c-primary)] mb-2">
{selectedDay.weekday} · {formatDateLong(selectedDay.iso, dict.dateLocale)}
<h2 className="font-bold text-[var(--c-primary)] mb-1">
{selectedDay.weekday} · {formatDateLong(selectedDay.iso, dict.dateLocale)}{" "}
{renderClosureToggle(selectedDay.iso)}
</h2>
<div className="space-y-3">
{sections.map((s) => (
<div key={s.key} className="rounded-md border border-gray-200 overflow-hidden">
<div className="bg-[var(--c-primary)] text-[var(--c-header-text)] px-3 py-1.5 text-sm">
<span className="font-semibold">{localized(s, locale)}</span>{" "}
<span className="opacity-80">· {s.time_range}</span>
</div>
<div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm text-center">
{renderCell(selectedDay.iso, s.key)}
{(() => {
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>
);
})()}
</div>
{/* Ab "sm": klassische Wochentabelle */}
@@ -300,23 +534,38 @@ export default function WeekEditor({ monday }: { monday: string }) {
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso, dict.dateLocale)}
{formatDateLong(d.iso, dict.dateLocale)} {renderClosureToggle(d.iso)}
</th>
))}
</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) => (
<td key={d.iso} className="cell">
{renderCell(d.iso, s.key)}
</td>
))}
{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>
);
})}
</tr>
))}
</tbody>
+13 -11
View File
@@ -2,9 +2,7 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { LOCALES, LOCALE_LABELS } from "@/lib/i18n/shared";
import type { Locale } from "@/lib/i18n/shared";
import { useLocale } from "@/components/i18n/LocaleProvider";
import { useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
export default function LanguageSwitcher({
className = "",
@@ -14,10 +12,11 @@ export default function LanguageSwitcher({
variant?: "dark" | "light";
}) {
const locale = useLocale();
const languages = useLanguages();
const router = useRouter();
const [busy, setBusy] = useState(false);
async function change(next: Locale) {
async function change(next: string) {
if (next === locale || busy) return;
setBusy(true);
await fetch("/api/locale", {
@@ -29,6 +28,9 @@ export default function LanguageSwitcher({
setBusy(false);
}
// Bei nur einer aktiven Sprache gibt es nichts umzuschalten.
if (languages.length <= 1) return null;
const activeCls =
variant === "dark" ? "bg-white/25 text-white" : "bg-gray-200 text-gray-900";
const inactiveCls =
@@ -38,19 +40,19 @@ export default function LanguageSwitcher({
return (
<div className={`flex gap-1 text-xs ${className}`} aria-label="Sprache">
{LOCALES.map((l) => (
{languages.map((l) => (
<button
key={l}
key={l.code}
type="button"
onClick={() => change(l)}
onClick={() => change(l.code)}
disabled={busy}
aria-current={l === locale}
title={LOCALE_LABELS[l]}
aria-current={l.code === locale}
title={l.label}
className={`rounded px-1.5 py-0.5 uppercase font-semibold transition-colors ${
l === locale ? activeCls : inactiveCls
l.code === locale ? activeCls : inactiveCls
}`}
>
{l}
{l.code}
</button>
))}
</div>
+20 -4
View File
@@ -1,16 +1,23 @@
"use client";
// Stellt Sprache + Wörterbuch für Client-Komponenten bereit. Wird einmal
// im Root-Layout mit dem serverseitig aus dem Cookie gelesenen Locale
// instanziiert; Client-Komponenten lesen darüber via useDict()/useLocale().
// Stellt Sprache + Wörterbuch + Liste der aktiven Sprachen für Client-
// Komponenten bereit. Wird einmal im Root-Layout mit den serverseitig
// ermittelten Werten instanziiert; Client-Komponenten lesen darüber
// via useDict()/useLocale()/useLanguages().
import { createContext, useContext, useMemo } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import type { Dict, Locale } from "@/lib/i18n/shared";
export interface LanguageInfo {
code: string;
label: string;
}
interface LocaleContextValue {
locale: Locale;
dict: Dict;
languages: LanguageInfo[];
}
const LocaleContext = createContext<LocaleContextValue | null>(null);
@@ -18,9 +25,13 @@ const LocaleContext = createContext<LocaleContextValue | null>(null);
export function LocaleProvider({
locale,
dict,
languages,
children,
}: LocaleContextValue & { children: React.ReactNode }) {
const value = useMemo(() => ({ locale, dict }), [locale, dict]);
const value = useMemo(
() => ({ locale, dict, languages }),
[locale, dict, languages]
);
return (
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
);
@@ -40,6 +51,11 @@ export function useLocale(): Locale {
return useLocaleContext().locale;
}
/** Alle aktiven Sprachen (Code + Anzeigename), z. B. für den Sprachumschalter. */
export function useLanguages(): LanguageInfo[] {
return useLocaleContext().languages;
}
/** t("Text mit {var}", { var: "Wert" }) für Platzhalter in Übersetzungen. */
export function useFormatMessage() {
return formatMessage;
+34
View File
@@ -0,0 +1,34 @@
-- Dynamisches Sprachpaket-System für die Bedienoberfläche: Sprachen
-- sind ab jetzt zur Laufzeit im Admin-Bereich verwaltbar (aktivieren/
-- deaktivieren, neue Sprache per Datei-Import hinzufügen) statt fest
-- im Code zu stehen. Deutsch bleibt als "de"-Objekt in
-- lib/i18n/dictionaries.ts die immer vollständige Basis-Sprache – sie
-- braucht keine Zeilen in ui_translations, nur einen Eintrag in
-- languages als Referenz für den Sprachumschalter.
create table languages (
code text primary key,
label text not null,
is_base boolean not null default false,
active boolean not null default true,
-- Hash der DE-Vorlage (siehe lib/i18n/flatten.ts + lib/db/languages.ts)
-- zum Zeitpunkt des letzten Imports – weicht der aktuelle Hash davon
-- ab, sind seit dem Import neue Begriffe hinzugekommen ("veraltet").
-- Bei der Basis-Sprache immer null (nie veraltet, da Code = Vorlage).
template_version text,
updated_at timestamptz not null default now()
);
-- Eine Zeile je Sprache + Übersetzungsschlüssel (z. B.
-- "login.errorGeneric"), fehlende Schlüssel fallen zur Laufzeit
-- automatisch auf die deutsche Vorlage zurück statt leer zu bleiben.
create table ui_translations (
language_code text not null references languages(code) on delete cascade,
key text not null,
value text not null,
primary key (language_code, key)
);
insert into languages (code, label, is_base, active, template_version) values
('de', 'Deutsch', true, true, null),
('en', 'English', false, true, '2da2662b794f019f6f7dba99ef93e2035eb38d1a78bd73bdccbb6b3cab447d5d'),
('pl', 'Polski', false, true, '2da2662b794f019f6f7dba99ef93e2035eb38d1a78bd73bdccbb6b3cab447d5d');
+574
View File
@@ -0,0 +1,574 @@
-- Einmaliger Seed: bisherige fest im Code hinterlegte Englisch-/Polnisch-
-- Übersetzungen (lib/i18n/dictionaries.ts) als Sprachpakete in
-- ui_translations überführen, damit nach dem Umbau auf das dynamische
-- Sprachpaket-System (siehe 0004_languages.sql) nichts verloren geht.
insert into ui_translations (language_code, key, value) values
('en', 'common.save', 'Save'),
('en', 'common.cancel', 'Cancel'),
('en', 'common.delete', 'Delete'),
('en', 'common.edit', 'Edit'),
('en', 'common.add', 'Add'),
('en', 'common.back', 'Back'),
('en', 'common.login', 'Log in'),
('en', 'common.logout', 'Log out'),
('en', 'common.loading', 'Loading…'),
('en', 'common.you', '(you)'),
('en', 'common.name', 'Name'),
('en', 'common.email', 'Email address'),
('en', 'common.password', 'Password'),
('en', 'common.role', 'Role'),
('en', 'common.roleAdmin', 'Administrator'),
('en', 'common.roleStaff', 'Canteen staff'),
('en', 'weekdays.long.0', 'Monday'),
('en', 'weekdays.long.1', 'Tuesday'),
('en', 'weekdays.long.2', 'Wednesday'),
('en', 'weekdays.long.3', 'Thursday'),
('en', 'weekdays.long.4', 'Friday'),
('en', 'weekdays.short.0', 'Mon'),
('en', 'weekdays.short.1', 'Tue'),
('en', 'weekdays.short.2', 'Wed'),
('en', 'weekdays.short.3', 'Thu'),
('en', 'weekdays.short.4', 'Fri'),
('en', 'dateLocale', 'en-GB'),
('en', 'header.canteen', 'Canteen'),
('en', 'header.menuGroup', 'Menu'),
('en', 'header.weekPlan', 'Weekly plan'),
('en', 'header.sections', 'Meal times'),
('en', 'header.dishes', 'Dishes'),
('en', 'header.labels', 'Allergens & Additives'),
('en', 'header.adminGroup', 'Administration'),
('en', 'header.users', 'Users'),
('en', 'header.design', 'Design'),
('en', 'header.languages', 'Languages'),
('en', 'header.suggestions', 'Suggestions'),
('en', 'header.openSuggestions', '{count} open dish suggestions'),
('en', 'header.viewMenu', 'View menu'),
('en', 'header.myAccount', 'My account'),
('en', 'header.menuToggle', 'Open/close menu'),
('en', 'login.title', 'Sign in'),
('en', 'login.submit', 'Log in'),
('en', 'login.or', 'or'),
('en', 'login.passkeyButton', 'Sign in with passkey'),
('en', 'login.ssoButton', 'Sign in with Single Sign-On'),
('en', 'login.backToMenu', '← Back to menu'),
('en', 'login.errorInvalidCredentials', 'Email address or password is incorrect.'),
('en', 'login.errorPasskeyCancelled', 'Passkey sign-in was cancelled.'),
('en', 'login.errorGeneric', 'Sign-in failed: {message}'),
('en', 'login.errorPasswordDisabled', 'Password sign-in is disabled for this account. Please sign in with a passkey.'),
('en', 'login.errorOidcInvalidState', 'The SSO sign-in expired or was invalid. Please try again.'),
('en', 'login.errorOidcUnknownUser', 'No account exists for this email address. Please contact your administrator.'),
('en', 'login.errorOidcFailed', 'SSO sign-in failed. Please try again.'),
('en', 'publicMenu.prevWeek', '← Previous week'),
('en', 'publicMenu.nextWeek', 'Next week →'),
('en', 'publicMenu.prevWeekShort', '← Week'),
('en', 'publicMenu.nextWeekShort', 'Week →'),
('en', 'publicMenu.today', 'Today'),
('en', 'publicMenu.dayTab', 'Day'),
('en', 'publicMenu.weekTab', 'Week'),
('en', 'publicMenu.allergens', 'Allergens'),
('en', 'publicMenu.additives', 'Additives'),
('en', 'publicMenu.labelingNoticeTitle', 'Allergens & additives'),
('en', 'publicMenu.suggestDish', 'Suggest a dish'),
('en', 'publicMenu.noEntry', '–'),
('en', 'publicMenu.rssFeed', 'Subscribe to RSS feed'),
('en', 'weekEditor.title', 'Weekly plan'),
('en', 'weekEditor.loading', 'Loading weekly plan…'),
('en', 'weekEditor.changeDish', 'Choose a different dish'),
('en', 'weekEditor.addMeal', 'Add meal'),
('en', 'weekEditor.newDishesHint', 'Add new dishes under {link}.'),
('en', 'weekEditor.newDishesLinkLabel', 'Dishes'),
('en', 'weekEditor.loadError', 'Loading failed: {message}'),
('en', 'weekEditor.duplicateEntry', 'This dish is already assigned on this day.'),
('en', 'weekEditor.saveError', 'Saving failed: {message}'),
('en', 'weekEditor.deleteError', 'Deleting failed: {message}'),
('en', 'dishManager.title', 'Dishes'),
('en', 'dishManager.newDish', 'New dish'),
('en', 'dishManager.editDish', 'Edit dish'),
('en', 'dishManager.nameLabel', 'Name'),
('en', 'dishManager.nameEnLabel', 'Name (English)'),
('en', 'dishManager.namePlLabel', 'Name (Polish)'),
('en', 'dishManager.autoTranslate', 'Auto-translate'),
('en', 'dishManager.translating', 'Translating…'),
('en', 'dishManager.translated', 'Translated.'),
('en', 'dishManager.priceLabel', 'Price in €'),
('en', 'dishManager.pricePlaceholder', '3.50'),
('en', 'dishManager.searchPlaceholder', 'Search dishes…'),
('en', 'dishManager.selectAll', 'Select all'),
('en', 'dishManager.selectedCount', '{count} selected'),
('en', 'dishManager.deleteSelected', 'Delete selected'),
('en', 'dishManager.deleteSelectedCount', 'Delete selected ({count})'),
('en', 'dishManager.empty', 'No dishes yet.'),
('en', 'dishManager.noResults', 'No results for “{query}”.'),
('en', 'dishManager.confirmDelete', 'Really delete “{name}”? The dish will also be removed from all weekly plans.'),
('en', 'dishManager.confirmDeleteSelected', 'Really delete {count} dish(es)? They will also be removed from all weekly plans.'),
('en', 'dishManager.invalidPrice', 'Please enter a valid price, e.g. 3.50'),
('en', 'dishManager.loadError', 'Loading failed: {message}'),
('en', 'dishManager.saveError', 'Saving failed: {message}'),
('en', 'dishManager.deleteError', 'Deleting failed: {message}'),
('en', 'dishManager.exportButton', 'Export'),
('en', 'dishManager.importButton', 'Import'),
('en', 'dishManager.exportError', 'Export failed: {message}'),
('en', 'dishManager.importError', 'Import failed: {message}'),
('en', 'dishManager.importSuccess', '{created} created, {updated} updated.'),
('en', 'dishManager.importSkipped', '{count} row(s) skipped: {reasons}'),
('en', 'dishManager.importSkippedRow', 'Row {row}: {reason}'),
('en', 'dishManager.labelingDisabledHint', 'Allergens/additives are currently disabled (notice mode active, see "Allergens & additives").'),
('en', 'codeList.allergensTitle', 'Allergens'),
('en', 'codeList.additivesTitle', 'Additives'),
('en', 'codeList.allergenCodeLabel', 'Code (e.g. A)'),
('en', 'codeList.additiveCodeLabel', 'Code (e.g. 1)'),
('en', 'codeList.nameLabel', 'Name'),
('en', 'codeList.empty', 'No entries.'),
('en', 'codeList.confirmDelete', 'Really delete “{code} – {name}”?'),
('en', 'codeList.duplicateCode', 'The code “{code}” already exists.'),
('en', 'codeList.loadError', 'Loading failed: {message}'),
('en', 'codeList.saveError', 'Saving failed: {message}'),
('en', 'codeList.deleteError', 'Deleting failed: {message}'),
('en', 'codeList.exportButton', 'Export'),
('en', 'codeList.importButton', 'Import'),
('en', 'codeList.exportError', 'Export failed: {message}'),
('en', 'codeList.importError', 'Import failed: {message}'),
('en', 'codeList.importSuccess', '{created} created, {updated} updated.'),
('en', 'codeList.importSkipped', '{count} row(s) skipped: {reasons}'),
('en', 'codeList.importSkippedRow', 'Row {row}: {reason}'),
('en', 'sectionManager.title', 'Meal times'),
('en', 'sectionManager.newSection', 'New meal time'),
('en', 'sectionManager.editSection', 'Edit meal time'),
('en', 'sectionManager.nameLabel', 'Name'),
('en', 'sectionManager.nameEnLabel', 'Name (English)'),
('en', 'sectionManager.namePlLabel', 'Name (Polish)'),
('en', 'sectionManager.timeFrom', 'From'),
('en', 'sectionManager.timeTo', 'To'),
('en', 'sectionManager.moveUp', 'Move up'),
('en', 'sectionManager.moveDown', 'Move down'),
('en', 'sectionManager.empty', 'No meal times yet.'),
('en', 'sectionManager.confirmDelete', 'Really delete “{name}”? Assigned dishes will also be removed from all weekly plans.'),
('en', 'sectionManager.loadError', 'Loading failed: {message}'),
('en', 'sectionManager.saveError', 'Saving failed: {message}'),
('en', 'sectionManager.deleteError', 'Deleting failed: {message}'),
('en', 'userManager.title', 'Users'),
('en', 'userManager.newUser', 'New user'),
('en', 'userManager.editUser', 'Edit user'),
('en', 'userManager.newPasswordLabel', 'New password (optional)'),
('en', 'userManager.repeatPasswordLabel', 'Repeat password'),
('en', 'userManager.passwordMismatch', 'The passwords do not match.'),
('en', 'userManager.confirmDelete', 'Really delete user “{email}”? Access will be permanently removed.'),
('en', 'userManager.loadError', 'Loading failed: {message}'),
('en', 'designEditor.title', 'Design'),
('en', 'designEditor.logoTitle', 'Logo'),
('en', 'designEditor.logoHint', 'The logo is displayed centered above the menu.'),
('en', 'designEditor.logoNone', 'No logo set.'),
('en', 'designEditor.logoUpload', 'Upload logo'),
('en', 'designEditor.logoRemove', 'Remove logo'),
('en', 'designEditor.logoUpdated', 'Logo updated.'),
('en', 'designEditor.logoRemoved', 'Logo removed.'),
('en', 'designEditor.faviconTitle', 'Favicon'),
('en', 'designEditor.faviconHint', 'Shown as the icon in the browser tab. Without a custom favicon, the application''s default icon is used.'),
('en', 'designEditor.faviconNone', 'No custom favicon set.'),
('en', 'designEditor.faviconUpload', 'Upload favicon'),
('en', 'designEditor.faviconRemove', 'Remove favicon'),
('en', 'designEditor.faviconUpdated', 'Favicon updated.'),
('en', 'designEditor.faviconRemoved', 'Favicon removed.'),
('en', 'designEditor.invalidImage', 'Please choose an image file (PNG, JPG, SVG, WebP, ICO).'),
('en', 'designEditor.colorsTitle', 'Colors'),
('en', 'designEditor.colorsSave', 'Save colors'),
('en', 'designEditor.colorsReset', 'Reset to default'),
('en', 'designEditor.colorsSaved', 'Colors saved.'),
('en', 'designEditor.colorPrimary', 'Primary color (table header)'),
('en', 'designEditor.colorPrimaryDark', 'Primary color, dark (internal top bar)'),
('en', 'designEditor.colorAccent', 'Accent color'),
('en', 'designEditor.colorHeaderText', 'Table header text color'),
('en', 'designEditor.colorRowBg', 'Cell background'),
('en', 'designEditor.colorRowBgAlt', 'Cell background (alternating)'),
('en', 'designEditor.colorAddBtn', '“Add” button'),
('en', 'designEditor.colorDeleteBtn', '“Delete” button'),
('en', 'designEditor.loadError', 'Loading failed: {message}'),
('en', 'designEditor.saveError', 'Saving failed: {message}'),
('en', 'designEditor.uploadError', 'Upload failed: {message}'),
('en', 'designEditor.confirmRemoveLogo', 'Really remove the logo?'),
('en', 'designEditor.confirmRemoveFavicon', 'Really remove the favicon?'),
('en', 'labelingNotice.title', 'Allergen & additive display'),
('en', 'labelingNotice.hint', 'By default every guest sees a detailed allergen/additive selection per dish. Alternatively, only a notice text can be shown instead – the per-dish selection is then also hidden in the dish manager.'),
('en', 'labelingNotice.modeDetailed', 'Show detailed selection per dish'),
('en', 'labelingNotice.modeNotice', 'Show only notice text'),
('en', 'labelingNotice.noticeDeLabel', 'Notice text (German)'),
('en', 'labelingNotice.noticeEnLabel', 'Notice text (English)'),
('en', 'labelingNotice.noticePlLabel', 'Notice text (Polish)'),
('en', 'labelingNotice.save', 'Save'),
('en', 'labelingNotice.saved', 'Saved.'),
('en', 'labelingNotice.loadError', 'Loading failed: {message}'),
('en', 'labelingNotice.saveError', 'Saving failed: {message}'),
('en', 'passkeys.title', 'Passkeys'),
('en', 'passkeys.hint', 'With a passkey (fingerprint, face recognition, or a security key) you can sign in without a password in the future. Passkeys are bound to a domain – after a domain change they need to be registered again.'),
('en', 'passkeys.register', 'Register new passkey'),
('en', 'passkeys.empty', 'No passkeys registered yet.'),
('en', 'passkeys.createdOn', '– created on {date}'),
('en', 'passkeys.registered', 'Passkey registered successfully.'),
('en', 'passkeys.registerError', 'Registration failed: {message}'),
('en', 'passkeys.registerCancelled', 'Passkey registration was cancelled.'),
('en', 'passkeys.loadError', 'Could not load passkeys: {message}'),
('en', 'passkeys.deleteError', 'Deleting failed: {message}'),
('en', 'passkeys.confirmDelete', 'Really delete this passkey?'),
('en', 'account.title', 'My account'),
('en', 'account.personalTitle', 'Personal details'),
('en', 'account.saveName', 'Save name'),
('en', 'account.nameSaved', 'Name saved.'),
('en', 'account.nameRequired', 'Please enter a name.'),
('en', 'account.emailTitle', 'Email address'),
('en', 'account.changeEmail', 'Change email address'),
('en', 'account.emailChanged', 'Email address changed.'),
('en', 'account.passwordTitle', 'Change password'),
('en', 'account.currentPassword', 'Current password (for verification)'),
('en', 'account.newPassword', 'New password'),
('en', 'account.newPasswordRepeat', 'Repeat new password'),
('en', 'account.changePassword', 'Change password'),
('en', 'account.passwordChanged', 'Password changed.'),
('en', 'account.passwordMismatch', 'The passwords do not match.'),
('en', 'account.passwordTooShort', 'The new password must be at least 8 characters long.'),
('en', 'account.passwordAuthTitle', 'Password sign-in'),
('en', 'account.passwordAuthHint', 'You can disable email and password sign-in for your account and sign in exclusively with a passkey in the future. At least one passkey must be registered for this.'),
('en', 'account.passwordAuthCheckbox', 'Email and password sign-in allowed'),
('en', 'account.passwordAuthEnabled', 'Password sign-in enabled.'),
('en', 'account.passwordAuthDisabled', 'Password sign-in disabled.'),
('en', 'account.passwordAuthNeedsPasskey', 'You can''t disable password sign-in without a registered passkey – you would lock yourself out. Register a passkey below first.'),
('en', 'account.confirmDisablePasswordAuth', 'Really disable email and password sign-in? Afterwards you can only sign in with a passkey.'),
('en', 'account.wrongCurrentPassword', 'The current password is incorrect.'),
('en', 'account.emailAlreadyUsed', 'This email address is already in use.'),
('en', 'account.saveError', 'Saving failed: {message}'),
('en', 'account.changeError', 'Change failed: {message}'),
('en', 'suggestions.formTitle', 'Suggest a dish'),
('en', 'suggestions.formHint', 'Missing a dish on the menu? Suggest it here – the canteen team will review your suggestion.'),
('en', 'suggestions.nameLabel', 'Dish name'),
('en', 'suggestions.descriptionLabel', 'Description / ingredients (optional)'),
('en', 'suggestions.submit', 'Submit suggestion'),
('en', 'suggestions.submitting', 'Submitting…'),
('en', 'suggestions.success', 'Thanks! Your suggestion has been submitted.'),
('en', 'suggestions.duplicate', 'This dish already exists on the menu.'),
('en', 'suggestions.genericError', 'Submission failed: {message}'),
('en', 'suggestions.nameRequired', 'Please enter a name.'),
('en', 'suggestions.backToMenu', '← Back to menu'),
('en', 'suggestions.reviewTitle', 'Dish suggestions'),
('en', 'suggestions.reviewEmpty', 'No suggestions yet.'),
('en', 'suggestions.accept', 'Add as dish'),
('en', 'suggestions.reject', 'Reject'),
('en', 'suggestions.confirmReject', 'Really reject and delete the suggestion “{name}”?'),
('en', 'suggestions.accepted', 'Added as dish.'),
('en', 'suggestions.rejected', 'Suggestion rejected.'),
('en', 'suggestions.loadError', 'Loading failed: {message}'),
('en', 'suggestions.actionError', 'Action failed: {message}'),
('en', 'suggestions.submittedOn', 'submitted on {date}'),
('en', 'languageManager.title', 'Languages'),
('en', 'languageManager.hint', 'German is the fixed base language and always complete. Additional languages are imported as a translation file and automatically filled in with German wherever a term is missing.'),
('en', 'languageManager.codeColumn', 'Code'),
('en', 'languageManager.labelColumn', 'Display name'),
('en', 'languageManager.activeColumn', 'Active'),
('en', 'languageManager.completenessColumn', 'Completeness'),
('en', 'languageManager.baseBadge', 'Base language'),
('en', 'languageManager.complete', 'Complete'),
('en', 'languageManager.completeness', '{percent}% translated'),
('en', 'languageManager.outdated', '{count} new term(s) since last import'),
('en', 'languageManager.downloadTemplate', 'Download template'),
('en', 'languageManager.uploadTranslation', 'Upload translation'),
('en', 'languageManager.newLanguageTitle', 'Add new language'),
('en', 'languageManager.newLanguageCodeLabel', 'Language code (e.g. fr)'),
('en', 'languageManager.newLanguageLabelLabel', 'Display name (e.g. Français)'),
('en', 'languageManager.create', 'Create'),
('en', 'languageManager.delete', 'Delete'),
('en', 'languageManager.confirmDelete', 'Really delete “{label}”? All translations for this language will be lost.'),
('en', 'languageManager.cannotDeleteBase', 'The base language cannot be deleted.'),
('en', 'languageManager.cannotDeactivateLast', 'At least one language must stay active.'),
('en', 'languageManager.loadError', 'Loading failed: {message}'),
('en', 'languageManager.saveError', 'Saving failed: {message}'),
('en', 'languageManager.deleteError', 'Deleting failed: {message}'),
('en', 'languageManager.importSuccess', '{count} terms imported.'),
('en', 'languageManager.importError', 'Import failed: {message}'),
('en', 'languageManager.importInvalidFile', 'The file could not be read (invalid JSON).'),
('en', 'languageManager.importMissingPlaceholder', '“{key}”: placeholder {placeholder} missing in the translation – row skipped.'),
('en', 'languageManager.duplicateCode', 'The language code “{code}” already exists.'),
('en', 'languageManager.invalidCode', 'Please enter a language code of 2-5 lowercase letters.');
insert into ui_translations (language_code, key, value) values
('pl', 'common.save', 'Zapisz'),
('pl', 'common.cancel', 'Anuluj'),
('pl', 'common.delete', 'Usuń'),
('pl', 'common.edit', 'Edytuj'),
('pl', 'common.add', 'Dodaj'),
('pl', 'common.back', 'Wstecz'),
('pl', 'common.login', 'Zaloguj się'),
('pl', 'common.logout', 'Wyloguj się'),
('pl', 'common.loading', 'Wczytywanie…'),
('pl', 'common.you', '(ty)'),
('pl', 'common.name', 'Nazwa'),
('pl', 'common.email', 'Adres e-mail'),
('pl', 'common.password', 'Hasło'),
('pl', 'common.role', 'Rola'),
('pl', 'common.roleAdmin', 'Administrator'),
('pl', 'common.roleStaff', 'Personel stołówki'),
('pl', 'weekdays.long.0', 'Poniedziałek'),
('pl', 'weekdays.long.1', 'Wtorek'),
('pl', 'weekdays.long.2', 'Środa'),
('pl', 'weekdays.long.3', 'Czwartek'),
('pl', 'weekdays.long.4', 'Piątek'),
('pl', 'weekdays.short.0', 'Pon'),
('pl', 'weekdays.short.1', 'Wto'),
('pl', 'weekdays.short.2', 'Śro'),
('pl', 'weekdays.short.3', 'Czw'),
('pl', 'weekdays.short.4', 'Pią'),
('pl', 'dateLocale', 'pl-PL'),
('pl', 'header.canteen', 'Stołówka'),
('pl', 'header.menuGroup', 'Jadłospis'),
('pl', 'header.weekPlan', 'Plan tygodnia'),
('pl', 'header.sections', 'Pory posiłków'),
('pl', 'header.dishes', 'Dania'),
('pl', 'header.labels', 'Alergeny i dodatki'),
('pl', 'header.adminGroup', 'Administracja'),
('pl', 'header.users', 'Użytkownicy'),
('pl', 'header.design', 'Wygląd'),
('pl', 'header.languages', 'Języki'),
('pl', 'header.suggestions', 'Propozycje'),
('pl', 'header.openSuggestions', '{count} otwartych propozycji dań'),
('pl', 'header.viewMenu', 'Zobacz jadłospis'),
('pl', 'header.myAccount', 'Moje konto'),
('pl', 'header.menuToggle', 'Otwórz/zamknij menu'),
('pl', 'login.title', 'Logowanie'),
('pl', 'login.submit', 'Zaloguj się'),
('pl', 'login.or', 'lub'),
('pl', 'login.passkeyButton', 'Zaloguj się kluczem dostępu'),
('pl', 'login.ssoButton', 'Zaloguj się przez Single Sign-On'),
('pl', 'login.backToMenu', '← Powrót do jadłospisu'),
('pl', 'login.errorInvalidCredentials', 'Nieprawidłowy adres e-mail lub hasło.'),
('pl', 'login.errorPasskeyCancelled', 'Logowanie kluczem dostępu zostało anulowane.'),
('pl', 'login.errorGeneric', 'Logowanie nie powiodło się: {message}'),
('pl', 'login.errorPasswordDisabled', 'Logowanie hasłem jest wyłączone dla tego konta. Zaloguj się kluczem dostępu.'),
('pl', 'login.errorOidcInvalidState', 'Logowanie SSO wygasło lub jest nieprawidłowe. Spróbuj ponownie.'),
('pl', 'login.errorOidcUnknownUser', 'Nie istnieje konto dla tego adresu e-mail. Skontaktuj się z administracją.'),
('pl', 'login.errorOidcFailed', 'Logowanie SSO nie powiodło się. Spróbuj ponownie.'),
('pl', 'publicMenu.prevWeek', '← Poprzedni tydzień'),
('pl', 'publicMenu.nextWeek', 'Następny tydzień →'),
('pl', 'publicMenu.prevWeekShort', '← Tydzień'),
('pl', 'publicMenu.nextWeekShort', 'Tydzień →'),
('pl', 'publicMenu.today', 'Dziś'),
('pl', 'publicMenu.dayTab', 'Dzień'),
('pl', 'publicMenu.weekTab', 'Tydzień'),
('pl', 'publicMenu.allergens', 'Alergeny'),
('pl', 'publicMenu.additives', 'Dodatki'),
('pl', 'publicMenu.labelingNoticeTitle', 'Alergeny i dodatki'),
('pl', 'publicMenu.suggestDish', 'Zaproponuj danie'),
('pl', 'publicMenu.noEntry', '–'),
('pl', 'publicMenu.rssFeed', 'Subskrybuj kanał RSS'),
('pl', 'weekEditor.title', 'Plan tygodnia'),
('pl', 'weekEditor.loading', 'Wczytywanie planu tygodnia…'),
('pl', 'weekEditor.changeDish', 'Wybierz inne danie'),
('pl', 'weekEditor.addMeal', 'Dodaj posiłek'),
('pl', 'weekEditor.newDishesHint', 'Nowe dania możesz dodać w zakładce {link}.'),
('pl', 'weekEditor.newDishesLinkLabel', 'Dania'),
('pl', 'weekEditor.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'weekEditor.duplicateEntry', 'To danie jest już przypisane na ten dzień.'),
('pl', 'weekEditor.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'weekEditor.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'dishManager.title', 'Dania'),
('pl', 'dishManager.newDish', 'Nowe danie'),
('pl', 'dishManager.editDish', 'Edytuj danie'),
('pl', 'dishManager.nameLabel', 'Nazwa'),
('pl', 'dishManager.nameEnLabel', 'Nazwa (angielski)'),
('pl', 'dishManager.namePlLabel', 'Nazwa (polski)'),
('pl', 'dishManager.autoTranslate', 'Przetłumacz automatycznie'),
('pl', 'dishManager.translating', 'Tłumaczenie…'),
('pl', 'dishManager.translated', 'Przetłumaczono.'),
('pl', 'dishManager.priceLabel', 'Cena w €'),
('pl', 'dishManager.pricePlaceholder', '3,50'),
('pl', 'dishManager.searchPlaceholder', 'Szukaj dania…'),
('pl', 'dishManager.selectAll', 'Zaznacz wszystko'),
('pl', 'dishManager.selectedCount', 'Zaznaczono: {count}'),
('pl', 'dishManager.deleteSelected', 'Usuń zaznaczone'),
('pl', 'dishManager.deleteSelectedCount', 'Usuń zaznaczone ({count})'),
('pl', 'dishManager.empty', 'Nie dodano jeszcze żadnych dań.'),
('pl', 'dishManager.noResults', 'Brak wyników dla „{query}”.'),
('pl', 'dishManager.confirmDelete', 'Czy na pewno usunąć „{name}”? Danie zostanie również usunięte ze wszystkich planów tygodniowych.'),
('pl', 'dishManager.confirmDeleteSelected', 'Czy na pewno usunąć {count} danie/dania? Zostaną również usunięte ze wszystkich planów tygodniowych.'),
('pl', 'dishManager.invalidPrice', 'Podaj prawidłową cenę, np. 3,50'),
('pl', 'dishManager.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'dishManager.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'dishManager.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'dishManager.exportButton', 'Eksportuj'),
('pl', 'dishManager.importButton', 'Importuj'),
('pl', 'dishManager.exportError', 'Eksport nie powiódł się: {message}'),
('pl', 'dishManager.importError', 'Import nie powiódł się: {message}'),
('pl', 'dishManager.importSuccess', 'Utworzono: {created}, zaktualizowano: {updated}.'),
('pl', 'dishManager.importSkipped', 'Pominięto wierszy: {count}: {reasons}'),
('pl', 'dishManager.importSkippedRow', 'Wiersz {row}: {reason}'),
('pl', 'dishManager.labelingDisabledHint', 'Alergeny/dodatki są obecnie wyłączone (aktywny tryb komunikatu, patrz „Alergeny i dodatki”).'),
('pl', 'codeList.allergensTitle', 'Alergeny'),
('pl', 'codeList.additivesTitle', 'Dodatki'),
('pl', 'codeList.allergenCodeLabel', 'Kod (np. A)'),
('pl', 'codeList.additiveCodeLabel', 'Kod (np. 1)'),
('pl', 'codeList.nameLabel', 'Nazwa'),
('pl', 'codeList.empty', 'Brak wpisów.'),
('pl', 'codeList.confirmDelete', 'Czy na pewno usunąć „{code} – {name}”?'),
('pl', 'codeList.duplicateCode', 'Kod „{code}” już istnieje.'),
('pl', 'codeList.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'codeList.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'codeList.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'codeList.exportButton', 'Eksportuj'),
('pl', 'codeList.importButton', 'Importuj'),
('pl', 'codeList.exportError', 'Eksport nie powiódł się: {message}'),
('pl', 'codeList.importError', 'Import nie powiódł się: {message}'),
('pl', 'codeList.importSuccess', 'Utworzono: {created}, zaktualizowano: {updated}.'),
('pl', 'codeList.importSkipped', 'Pominięto wierszy: {count}: {reasons}'),
('pl', 'codeList.importSkippedRow', 'Wiersz {row}: {reason}'),
('pl', 'sectionManager.title', 'Pory posiłków'),
('pl', 'sectionManager.newSection', 'Nowa pora posiłku'),
('pl', 'sectionManager.editSection', 'Edytuj porę posiłku'),
('pl', 'sectionManager.nameLabel', 'Nazwa'),
('pl', 'sectionManager.nameEnLabel', 'Nazwa (angielski)'),
('pl', 'sectionManager.namePlLabel', 'Nazwa (polski)'),
('pl', 'sectionManager.timeFrom', 'Od'),
('pl', 'sectionManager.timeTo', 'Do'),
('pl', 'sectionManager.moveUp', 'Przesuń w górę'),
('pl', 'sectionManager.moveDown', 'Przesuń w dół'),
('pl', 'sectionManager.empty', 'Nie dodano jeszcze żadnych pór posiłków.'),
('pl', 'sectionManager.confirmDelete', 'Czy na pewno usunąć „{name}”? Przypisane dania zostaną również usunięte ze wszystkich planów tygodniowych.'),
('pl', 'sectionManager.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'sectionManager.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'sectionManager.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'userManager.title', 'Użytkownicy'),
('pl', 'userManager.newUser', 'Nowy użytkownik'),
('pl', 'userManager.editUser', 'Edytuj użytkownika'),
('pl', 'userManager.newPasswordLabel', 'Nowe hasło (opcjonalnie)'),
('pl', 'userManager.repeatPasswordLabel', 'Powtórz hasło'),
('pl', 'userManager.passwordMismatch', 'Hasła nie są zgodne.'),
('pl', 'userManager.confirmDelete', 'Czy na pewno usunąć użytkownika „{email}”? Dostęp zostanie trwale usunięty.'),
('pl', 'userManager.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'designEditor.title', 'Wygląd'),
('pl', 'designEditor.logoTitle', 'Logo'),
('pl', 'designEditor.logoHint', 'Logo jest wyświetlane wyśrodkowane nad jadłospisem.'),
('pl', 'designEditor.logoNone', 'Nie ustawiono logo.'),
('pl', 'designEditor.logoUpload', 'Prześlij logo'),
('pl', 'designEditor.logoRemove', 'Usuń logo'),
('pl', 'designEditor.logoUpdated', 'Logo zaktualizowane.'),
('pl', 'designEditor.logoRemoved', 'Logo usunięte.'),
('pl', 'designEditor.faviconTitle', 'Favicon'),
('pl', 'designEditor.faviconHint', 'Wyświetlane jako ikona na karcie przeglądarki. Bez własnego favicona używana jest domyślna ikona aplikacji.'),
('pl', 'designEditor.faviconNone', 'Nie ustawiono własnego favicona.'),
('pl', 'designEditor.faviconUpload', 'Prześlij favicon'),
('pl', 'designEditor.faviconRemove', 'Usuń favicon'),
('pl', 'designEditor.faviconUpdated', 'Favicon zaktualizowany.'),
('pl', 'designEditor.faviconRemoved', 'Favicon usunięty.'),
('pl', 'designEditor.invalidImage', 'Wybierz plik graficzny (PNG, JPG, SVG, WebP, ICO).'),
('pl', 'designEditor.colorsTitle', 'Kolorystyka'),
('pl', 'designEditor.colorsSave', 'Zapisz kolory'),
('pl', 'designEditor.colorsReset', 'Przywróć domyślne'),
('pl', 'designEditor.colorsSaved', 'Kolory zapisane.'),
('pl', 'designEditor.colorPrimary', 'Kolor główny (nagłówek tabeli)'),
('pl', 'designEditor.colorPrimaryDark', 'Kolor główny, ciemny (górny pasek wewnętrzny)'),
('pl', 'designEditor.colorAccent', 'Kolor akcentu'),
('pl', 'designEditor.colorHeaderText', 'Kolor tekstu w nagłówku tabeli'),
('pl', 'designEditor.colorRowBg', 'Tło komórek'),
('pl', 'designEditor.colorRowBgAlt', 'Tło komórek (naprzemienne)'),
('pl', 'designEditor.colorAddBtn', 'Przycisk „Dodaj”'),
('pl', 'designEditor.colorDeleteBtn', 'Przycisk „Usuń”'),
('pl', 'designEditor.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'designEditor.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'designEditor.uploadError', 'Przesyłanie nie powiodło się: {message}'),
('pl', 'designEditor.confirmRemoveLogo', 'Czy na pewno usunąć logo?'),
('pl', 'designEditor.confirmRemoveFavicon', 'Czy na pewno usunąć favicon?'),
('pl', 'labelingNotice.title', 'Wyświetlanie alergenów i dodatków'),
('pl', 'labelingNotice.hint', 'Domyślnie każdy gość widzi szczegółowy wybór alergenów/dodatków dla każdego dania. Alternatywnie można zamiast tego wyświetlić tylko komunikat – wybór dla poszczególnych dań zostanie wtedy również ukryty w zarządzaniu daniami.'),
('pl', 'labelingNotice.modeDetailed', 'Pokaż szczegółowy wybór dla każdego dania'),
('pl', 'labelingNotice.modeNotice', 'Pokaż tylko komunikat'),
('pl', 'labelingNotice.noticeDeLabel', 'Tekst komunikatu (niemiecki)'),
('pl', 'labelingNotice.noticeEnLabel', 'Tekst komunikatu (angielski)'),
('pl', 'labelingNotice.noticePlLabel', 'Tekst komunikatu (polski)'),
('pl', 'labelingNotice.save', 'Zapisz'),
('pl', 'labelingNotice.saved', 'Zapisano.'),
('pl', 'labelingNotice.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'labelingNotice.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'passkeys.title', 'Klucze dostępu'),
('pl', 'passkeys.hint', 'Dzięki kluczowi dostępu (odcisk palca, rozpoznawanie twarzy lub klucz bezpieczeństwa) możesz w przyszłości logować się bez hasła. Klucze dostępu są przypisane do domeny – po zmianie domeny trzeba je zarejestrować ponownie.'),
('pl', 'passkeys.register', 'Zarejestruj nowy klucz dostępu'),
('pl', 'passkeys.empty', 'Nie zarejestrowano jeszcze żadnych kluczy dostępu.'),
('pl', 'passkeys.createdOn', '– utworzono {date}'),
('pl', 'passkeys.registered', 'Klucz dostępu został pomyślnie zarejestrowany.'),
('pl', 'passkeys.registerError', 'Rejestracja nie powiodła się: {message}'),
('pl', 'passkeys.registerCancelled', 'Rejestracja klucza dostępu została anulowana.'),
('pl', 'passkeys.loadError', 'Nie udało się wczytać kluczy dostępu: {message}'),
('pl', 'passkeys.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'passkeys.confirmDelete', 'Czy na pewno usunąć ten klucz dostępu?'),
('pl', 'account.title', 'Moje konto'),
('pl', 'account.personalTitle', 'Dane osobowe'),
('pl', 'account.saveName', 'Zapisz nazwę'),
('pl', 'account.nameSaved', 'Nazwa zapisana.'),
('pl', 'account.nameRequired', 'Podaj nazwę.'),
('pl', 'account.emailTitle', 'Adres e-mail'),
('pl', 'account.changeEmail', 'Zmień adres e-mail'),
('pl', 'account.emailChanged', 'Adres e-mail zmieniony.'),
('pl', 'account.passwordTitle', 'Zmień hasło'),
('pl', 'account.currentPassword', 'Obecne hasło (do weryfikacji)'),
('pl', 'account.newPassword', 'Nowe hasło'),
('pl', 'account.newPasswordRepeat', 'Powtórz nowe hasło'),
('pl', 'account.changePassword', 'Zmień hasło'),
('pl', 'account.passwordChanged', 'Hasło zmienione.'),
('pl', 'account.passwordMismatch', 'Hasła nie są zgodne.'),
('pl', 'account.passwordTooShort', 'Nowe hasło musi mieć co najmniej 8 znaków.'),
('pl', 'account.passwordAuthTitle', 'Logowanie hasłem'),
('pl', 'account.passwordAuthHint', 'Możesz wyłączyć logowanie e-mailem i hasłem dla swojego konta i logować się w przyszłości wyłącznie kluczem dostępu. Wymaga to zarejestrowania co najmniej jednego klucza dostępu.'),
('pl', 'account.passwordAuthCheckbox', 'Logowanie e-mailem i hasłem dozwolone'),
('pl', 'account.passwordAuthEnabled', 'Logowanie hasłem włączone.'),
('pl', 'account.passwordAuthDisabled', 'Logowanie hasłem wyłączone.'),
('pl', 'account.passwordAuthNeedsPasskey', 'Bez zarejestrowanego klucza dostępu nie możesz wyłączyć logowania hasłem – stracisz dostęp do konta. Najpierw zarejestruj klucz dostępu poniżej.'),
('pl', 'account.confirmDisablePasswordAuth', 'Czy na pewno wyłączyć logowanie e-mailem i hasłem? Potem będziesz mógł/mogła zalogować się tylko kluczem dostępu.'),
('pl', 'account.wrongCurrentPassword', 'Obecne hasło jest nieprawidłowe.'),
('pl', 'account.emailAlreadyUsed', 'Ten adres e-mail jest już używany.'),
('pl', 'account.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'account.changeError', 'Zmiana nie powiodła się: {message}'),
('pl', 'suggestions.formTitle', 'Zaproponuj danie'),
('pl', 'suggestions.formHint', 'Brakuje dania w jadłospisie? Zaproponuj je tutaj – zespół stołówki sprawdzi Twoją propozycję.'),
('pl', 'suggestions.nameLabel', 'Nazwa dania'),
('pl', 'suggestions.descriptionLabel', 'Opis / składniki (opcjonalnie)'),
('pl', 'suggestions.submit', 'Wyślij propozycję'),
('pl', 'suggestions.submitting', 'Wysyłanie…'),
('pl', 'suggestions.success', 'Dziękujemy! Twoja propozycja została przesłana.'),
('pl', 'suggestions.duplicate', 'To danie już istnieje w jadłospisie.'),
('pl', 'suggestions.genericError', 'Wysyłanie nie powiodło się: {message}'),
('pl', 'suggestions.nameRequired', 'Podaj nazwę.'),
('pl', 'suggestions.backToMenu', '← Powrót do jadłospisu'),
('pl', 'suggestions.reviewTitle', 'Propozycje dań'),
('pl', 'suggestions.reviewEmpty', 'Brak propozycji.'),
('pl', 'suggestions.accept', 'Dodaj jako danie'),
('pl', 'suggestions.reject', 'Odrzuć'),
('pl', 'suggestions.confirmReject', 'Czy na pewno odrzucić i usunąć propozycję „{name}”?'),
('pl', 'suggestions.accepted', 'Dodano jako danie.'),
('pl', 'suggestions.rejected', 'Propozycja odrzucona.'),
('pl', 'suggestions.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'suggestions.actionError', 'Czynność nie powiodła się: {message}'),
('pl', 'suggestions.submittedOn', 'przesłano {date}'),
('pl', 'languageManager.title', 'Języki'),
('pl', 'languageManager.hint', 'Niemiecki jest stałym językiem bazowym i zawsze kompletny. Kolejne języki są importowane jako plik tłumaczenia i automatycznie uzupełniane niemieckim tekstem tam, gdzie brakuje tłumaczenia.'),
('pl', 'languageManager.codeColumn', 'Kod'),
('pl', 'languageManager.labelColumn', 'Nazwa wyświetlana'),
('pl', 'languageManager.activeColumn', 'Aktywny'),
('pl', 'languageManager.completenessColumn', 'Kompletność'),
('pl', 'languageManager.baseBadge', 'Język bazowy'),
('pl', 'languageManager.complete', 'Kompletny'),
('pl', 'languageManager.completeness', 'Przetłumaczono {percent}%'),
('pl', 'languageManager.outdated', 'Nowe terminy od ostatniego importu: {count}'),
('pl', 'languageManager.downloadTemplate', 'Pobierz szablon'),
('pl', 'languageManager.uploadTranslation', 'Prześlij tłumaczenie'),
('pl', 'languageManager.newLanguageTitle', 'Dodaj nowy język'),
('pl', 'languageManager.newLanguageCodeLabel', 'Kod języka (np. fr)'),
('pl', 'languageManager.newLanguageLabelLabel', 'Nazwa wyświetlana (np. Français)'),
('pl', 'languageManager.create', 'Utwórz'),
('pl', 'languageManager.delete', 'Usuń'),
('pl', 'languageManager.confirmDelete', 'Czy na pewno usunąć „{label}”? Wszystkie tłumaczenia tego języka zostaną utracone.'),
('pl', 'languageManager.cannotDeleteBase', 'Nie można usunąć języka bazowego.'),
('pl', 'languageManager.cannotDeactivateLast', 'Co najmniej jeden język musi pozostać aktywny.'),
('pl', 'languageManager.loadError', 'Wczytywanie nie powiodło się: {message}'),
('pl', 'languageManager.saveError', 'Zapisywanie nie powiodło się: {message}'),
('pl', 'languageManager.deleteError', 'Usuwanie nie powiodło się: {message}'),
('pl', 'languageManager.importSuccess', 'Zaimportowano terminów: {count}.'),
('pl', 'languageManager.importError', 'Import nie powiódł się: {message}'),
('pl', 'languageManager.importInvalidFile', 'Nie można odczytać pliku (nieprawidłowy JSON).'),
('pl', 'languageManager.importMissingPlaceholder', '„{key}”: brak symbolu zastępczego {placeholder} w tłumaczeniu – wiersz pominięto.'),
('pl', 'languageManager.duplicateCode', 'Kod języka „{code}” już istnieje.'),
('pl', 'languageManager.invalidCode', 'Podaj kod języka złożony z 2–5 małych liter.');
@@ -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
+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]);
}
+68 -39
View File
@@ -1,39 +1,70 @@
import "server-only";
import { query, queryOne, withTransaction } from "@/lib/db";
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> {
@@ -43,8 +74,7 @@ export async function deleteCode(table: CodeTable, code: string): Promise<void>
export interface CodeImportRow {
code: string;
name: string;
name_en: string | null;
name_pl: string | null;
translations: Record<string, string>;
}
/** CSV-Import: legt neue Kürzel an, bestehende (gleicher Code) werden
@@ -54,32 +84,31 @@ export async function importCodes(
table: CodeTable,
rows: CodeImportRow[]
): Promise<{ created: number; updated: number }> {
return withTransaction(async (client) => {
const existing = await client.query<{ code: string }>(`select code from ${table}`);
const known = new Set(existing.rows.map((r) => r.code));
const maxRow = await client.query<{ sort: number }>(
`select coalesce(max(sort), 0) as sort from ${table}`
);
let nextSort = (maxRow.rows[0]?.sort ?? 0) + 1;
let created = 0;
let updated = 0;
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 client.query(
`update ${table} set name = $1, name_en = $2, name_pl = $3 where code = $4`,
[row.name, row.name_en, row.name_pl, row.code]
);
updated++;
} else {
await client.query(
`insert into ${table} (code, name, name_en, name_pl, sort) values ($1, $2, $3, $4, $5)`,
[row.code, row.name, row.name_en, row.name_pl, nextSort++]
);
known.add(row.code);
created++;
}
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++;
}
return { created, updated };
});
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,
+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]
);
}
}
+42 -588
View File
@@ -36,6 +36,7 @@ const de = {
adminGroup: "Verwaltung",
users: "Benutzer",
design: "Design",
languages: "Sprachen",
suggestions: "Vorschläge",
openSuggestions: "{count} offene Rezeptvorschläge",
viewMenu: "Speisekarte ansehen",
@@ -74,6 +75,7 @@ const de = {
suggestDish: "Rezept vorschlagen",
noEntry: "–",
rssFeed: "RSS-Feed abonnieren",
closedLabel: "Geschlossen",
},
weekEditor: {
title: "Wochenplan",
@@ -86,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.",
@@ -149,8 +154,6 @@ const de = {
newSection: "Neue Essenszeit",
editSection: "Essenszeit bearbeiten",
nameLabel: "Bezeichnung",
nameEnLabel: "Bezeichnung (Englisch)",
namePlLabel: "Bezeichnung (Polnisch)",
timeFrom: "Von",
timeTo: "Bis",
moveUp: "Nach oben",
@@ -292,592 +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",
openSuggestions: "{count} open dish suggestions",
viewMenu: "View menu",
myAccount: "My account",
menuToggle: "Open/close menu",
},
login: {
title: "Sign in",
submit: "Log in",
or: "or",
passkeyButton: "Sign in with passkey",
ssoButton: "Sign in with Single Sign-On",
backToMenu: "← Back to menu",
errorInvalidCredentials: "Email address or password is incorrect.",
errorPasskeyCancelled: "Passkey sign-in was cancelled.",
errorGeneric: "Sign-in failed: {message}",
errorPasswordDisabled:
"Password sign-in is disabled for this account. Please sign in with a passkey.",
errorOidcInvalidState: "The SSO sign-in expired or was invalid. Please try again.",
errorOidcUnknownUser:
"No account exists for this email address. Please contact your administrator.",
errorOidcFailed: "SSO sign-in failed. Please try again.",
},
publicMenu: {
prevWeek: "← Previous week",
nextWeek: "Next week →",
prevWeekShort: "← Week",
nextWeekShort: "Week →",
today: "Today",
dayTab: "Day",
weekTab: "Week",
allergens: "Allergens",
additives: "Additives",
labelingNoticeTitle: "Allergens & additives",
suggestDish: "Suggest a dish",
noEntry: "–",
rssFeed: "Subscribe to RSS feed",
},
weekEditor: {
title: "Weekly plan",
loading: "Loading weekly plan…",
changeDish: "Choose a different dish",
addMeal: "Add meal",
newDishesHint: "Add new dishes under {link}.",
newDishesLinkLabel: "Dishes",
loadError: "Loading failed: {message}",
duplicateEntry: "This dish is already assigned on this day.",
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
},
dishManager: {
title: "Dishes",
newDish: "New dish",
editDish: "Edit dish",
nameLabel: "Name",
nameEnLabel: "Name (English)",
namePlLabel: "Name (Polish)",
autoTranslate: "Auto-translate",
translating: "Translating…",
translated: "Translated.",
priceLabel: "Price in €",
pricePlaceholder: "3.50",
searchPlaceholder: "Search dishes…",
selectAll: "Select all",
selectedCount: "{count} selected",
deleteSelected: "Delete selected",
deleteSelectedCount: "Delete selected ({count})",
empty: "No dishes yet.",
noResults: "No results for “{query}”.",
confirmDelete:
"Really delete “{name}”? The dish will also be removed from all weekly plans.",
confirmDeleteSelected:
"Really delete {count} dish(es)? They will also be removed from all weekly plans.",
invalidPrice: "Please enter a valid price, e.g. 3.50",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
exportButton: "Export",
importButton: "Import",
exportError: "Export failed: {message}",
importError: "Import failed: {message}",
importSuccess: "{created} created, {updated} updated.",
importSkipped: "{count} row(s) skipped: {reasons}",
importSkippedRow: "Row {row}: {reason}",
labelingDisabledHint:
"Allergens/additives are currently disabled (notice mode active, see \"Allergens & additives\").",
},
codeList: {
allergensTitle: "Allergens",
additivesTitle: "Additives",
allergenCodeLabel: "Code (e.g. A)",
additiveCodeLabel: "Code (e.g. 1)",
nameLabel: "Name",
empty: "No entries.",
confirmDelete: "Really delete “{code} – {name}”?",
duplicateCode: "The code “{code}” already exists.",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
exportButton: "Export",
importButton: "Import",
exportError: "Export failed: {message}",
importError: "Import failed: {message}",
importSuccess: "{created} created, {updated} updated.",
importSkipped: "{count} row(s) skipped: {reasons}",
importSkippedRow: "Row {row}: {reason}",
},
sectionManager: {
title: "Meal times",
newSection: "New meal time",
editSection: "Edit meal time",
nameLabel: "Name",
nameEnLabel: "Name (English)",
namePlLabel: "Name (Polish)",
timeFrom: "From",
timeTo: "To",
moveUp: "Move up",
moveDown: "Move down",
empty: "No meal times yet.",
confirmDelete:
"Really delete “{name}”? Assigned dishes will also be removed from all weekly plans.",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
},
userManager: {
title: "Users",
newUser: "New user",
editUser: "Edit user",
newPasswordLabel: "New password (optional)",
repeatPasswordLabel: "Repeat password",
passwordMismatch: "The passwords do not match.",
confirmDelete:
"Really delete user “{email}”? Access will be permanently removed.",
loadError: "Loading failed: {message}",
},
designEditor: {
title: "Design",
logoTitle: "Logo",
logoHint: "The logo is displayed centered above the menu.",
logoNone: "No logo set.",
logoUpload: "Upload logo",
logoRemove: "Remove logo",
logoUpdated: "Logo updated.",
logoRemoved: "Logo removed.",
faviconTitle: "Favicon",
faviconHint:
"Shown as the icon in the browser tab. Without a custom favicon, the application's default icon is used.",
faviconNone: "No custom favicon set.",
faviconUpload: "Upload favicon",
faviconRemove: "Remove favicon",
faviconUpdated: "Favicon updated.",
faviconRemoved: "Favicon removed.",
invalidImage: "Please choose an image file (PNG, JPG, SVG, WebP, ICO).",
colorsTitle: "Colors",
colorsSave: "Save colors",
colorsReset: "Reset to default",
colorsSaved: "Colors saved.",
colorPrimary: "Primary color (table header)",
colorPrimaryDark: "Primary color, dark (internal top bar)",
colorAccent: "Accent color",
colorHeaderText: "Table header text color",
colorRowBg: "Cell background",
colorRowBgAlt: "Cell background (alternating)",
colorAddBtn: "“Add” button",
colorDeleteBtn: "“Delete” button",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
uploadError: "Upload failed: {message}",
confirmRemoveLogo: "Really remove the logo?",
confirmRemoveFavicon: "Really remove the favicon?",
},
labelingNotice: {
title: "Allergen & additive display",
hint:
"By default every guest sees a detailed allergen/additive selection per dish. Alternatively, only a notice text can be shown instead – the per-dish selection is then also hidden in the dish manager.",
modeDetailed: "Show detailed selection per dish",
modeNotice: "Show only notice text",
noticeDeLabel: "Notice text (German)",
noticeEnLabel: "Notice text (English)",
noticePlLabel: "Notice text (Polish)",
save: "Save",
saved: "Saved.",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
},
passkeys: {
title: "Passkeys",
hint: "With a passkey (fingerprint, face recognition, or a security key) you can sign in without a password in the future. Passkeys are bound to a domain – after a domain change they need to be registered again.",
register: "Register new passkey",
empty: "No passkeys registered yet.",
createdOn: "– created on {date}",
registered: "Passkey registered successfully.",
registerError: "Registration failed: {message}",
registerCancelled: "Passkey registration was cancelled.",
loadError: "Could not load passkeys: {message}",
deleteError: "Deleting failed: {message}",
confirmDelete: "Really delete this passkey?",
},
account: {
title: "My account",
personalTitle: "Personal details",
saveName: "Save name",
nameSaved: "Name saved.",
nameRequired: "Please enter a name.",
emailTitle: "Email address",
changeEmail: "Change email address",
emailChanged: "Email address changed.",
passwordTitle: "Change password",
currentPassword: "Current password (for verification)",
newPassword: "New password",
newPasswordRepeat: "Repeat new password",
changePassword: "Change password",
passwordChanged: "Password changed.",
passwordMismatch: "The passwords do not match.",
passwordTooShort: "The new password must be at least 8 characters long.",
passwordAuthTitle: "Password sign-in",
passwordAuthHint:
"You can disable email and password sign-in for your account and sign in exclusively with a passkey in the future. At least one passkey must be registered for this.",
passwordAuthCheckbox: "Email and password sign-in allowed",
passwordAuthEnabled: "Password sign-in enabled.",
passwordAuthDisabled: "Password sign-in disabled.",
passwordAuthNeedsPasskey:
"You can't disable password sign-in without a registered passkey – you would lock yourself out. Register a passkey below first.",
confirmDisablePasswordAuth:
"Really disable email and password sign-in? Afterwards you can only sign in with a passkey.",
wrongCurrentPassword: "The current password is incorrect.",
emailAlreadyUsed: "This email address is already in use.",
saveError: "Saving failed: {message}",
changeError: "Change failed: {message}",
},
suggestions: {
formTitle: "Suggest a dish",
formHint:
"Missing a dish on the menu? Suggest it here – the canteen team will review your suggestion.",
nameLabel: "Dish name",
descriptionLabel: "Description / ingredients (optional)",
submit: "Submit suggestion",
submitting: "Submitting…",
success: "Thanks! Your suggestion has been submitted.",
duplicate: "This dish already exists on the menu.",
genericError: "Submission failed: {message}",
nameRequired: "Please enter a name.",
backToMenu: "← Back to menu",
reviewTitle: "Dish suggestions",
reviewEmpty: "No suggestions yet.",
accept: "Add as dish",
reject: "Reject",
confirmReject: "Really reject and delete the suggestion “{name}”?",
accepted: "Added as dish.",
rejected: "Suggestion rejected.",
loadError: "Loading failed: {message}",
actionError: "Action failed: {message}",
submittedOn: "submitted on {date}",
},
};
const pl: Dict = {
common: {
save: "Zapisz",
cancel: "Anuluj",
delete: "Usuń",
edit: "Edytuj",
add: "Dodaj",
back: "Wstecz",
login: "Zaloguj się",
logout: "Wyloguj się",
loading: "Wczytywanie…",
you: "(ty)",
name: "Nazwa",
email: "Adres e-mail",
password: "Hasło",
role: "Rola",
roleAdmin: "Administrator",
roleStaff: "Personel stołówki",
},
weekdays: {
long: ["Poniedziałek", "Wtorek", "Środa", "Czwartek", "Piątek"],
short: ["Pon", "Wto", "Śro", "Czw", "Pią"],
},
dateLocale: "pl-PL",
header: {
canteen: "Stołówka",
menuGroup: "Jadłospis",
weekPlan: "Plan tygodnia",
sections: "Pory posiłków",
dishes: "Dania",
labels: "Alergeny i dodatki",
adminGroup: "Administracja",
users: "Użytkownicy",
design: "Wygląd",
suggestions: "Propozycje",
openSuggestions: "{count} otwartych propozycji dań",
viewMenu: "Zobacz jadłospis",
myAccount: "Moje konto",
menuToggle: "Otwórz/zamknij menu",
},
login: {
title: "Logowanie",
submit: "Zaloguj się",
or: "lub",
passkeyButton: "Zaloguj się kluczem dostępu",
ssoButton: "Zaloguj się przez Single Sign-On",
backToMenu: "← Powrót do jadłospisu",
errorInvalidCredentials: "Nieprawidłowy adres e-mail lub hasło.",
errorPasskeyCancelled: "Logowanie kluczem dostępu zostało anulowane.",
errorGeneric: "Logowanie nie powiodło się: {message}",
errorPasswordDisabled:
"Logowanie hasłem jest wyłączone dla tego konta. Zaloguj się kluczem dostępu.",
errorOidcInvalidState:
"Logowanie SSO wygasło lub jest nieprawidłowe. Spróbuj ponownie.",
errorOidcUnknownUser:
"Nie istnieje konto dla tego adresu e-mail. Skontaktuj się z administracją.",
errorOidcFailed: "Logowanie SSO nie powiodło się. Spróbuj ponownie.",
},
publicMenu: {
prevWeek: "← Poprzedni tydzień",
nextWeek: "Następny tydzień →",
prevWeekShort: "← Tydzień",
nextWeekShort: "Tydzień →",
today: "Dziś",
dayTab: "Dzień",
weekTab: "Tydzień",
allergens: "Alergeny",
additives: "Dodatki",
labelingNoticeTitle: "Alergeny i dodatki",
suggestDish: "Zaproponuj danie",
noEntry: "–",
rssFeed: "Subskrybuj kanał RSS",
},
weekEditor: {
title: "Plan tygodnia",
loading: "Wczytywanie planu tygodnia…",
changeDish: "Wybierz inne danie",
addMeal: "Dodaj posiłek",
newDishesHint: "Nowe dania możesz dodać w zakładce {link}.",
newDishesLinkLabel: "Dania",
loadError: "Wczytywanie nie powiodło się: {message}",
duplicateEntry: "To danie jest już przypisane na ten dzień.",
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
},
dishManager: {
title: "Dania",
newDish: "Nowe danie",
editDish: "Edytuj danie",
nameLabel: "Nazwa",
nameEnLabel: "Nazwa (angielski)",
namePlLabel: "Nazwa (polski)",
autoTranslate: "Przetłumacz automatycznie",
translating: "Tłumaczenie…",
translated: "Przetłumaczono.",
priceLabel: "Cena w €",
pricePlaceholder: "3,50",
searchPlaceholder: "Szukaj dania…",
selectAll: "Zaznacz wszystko",
selectedCount: "Zaznaczono: {count}",
deleteSelected: "Usuń zaznaczone",
deleteSelectedCount: "Usuń zaznaczone ({count})",
empty: "Nie dodano jeszcze żadnych dań.",
noResults: "Brak wyników dla „{query}”.",
confirmDelete:
"Czy na pewno usunąć „{name}”? Danie zostanie również usunięte ze wszystkich planów tygodniowych.",
confirmDeleteSelected:
"Czy na pewno usunąć {count} danie/dania? Zostaną również usunięte ze wszystkich planów tygodniowych.",
invalidPrice: "Podaj prawidłową cenę, np. 3,50",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
exportButton: "Eksportuj",
importButton: "Importuj",
exportError: "Eksport nie powiódł się: {message}",
importError: "Import nie powiódł się: {message}",
importSuccess: "Utworzono: {created}, zaktualizowano: {updated}.",
importSkipped: "Pominięto wierszy: {count}: {reasons}",
importSkippedRow: "Wiersz {row}: {reason}",
labelingDisabledHint:
"Alergeny/dodatki są obecnie wyłączone (aktywny tryb komunikatu, patrz „Alergeny i dodatki”).",
},
codeList: {
allergensTitle: "Alergeny",
additivesTitle: "Dodatki",
allergenCodeLabel: "Kod (np. A)",
additiveCodeLabel: "Kod (np. 1)",
nameLabel: "Nazwa",
empty: "Brak wpisów.",
confirmDelete: "Czy na pewno usunąć „{code} – {name}”?",
duplicateCode: "Kod „{code}” już istnieje.",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
exportButton: "Eksportuj",
importButton: "Importuj",
exportError: "Eksport nie powiódł się: {message}",
importError: "Import nie powiódł się: {message}",
importSuccess: "Utworzono: {created}, zaktualizowano: {updated}.",
importSkipped: "Pominięto wierszy: {count}: {reasons}",
importSkippedRow: "Wiersz {row}: {reason}",
},
sectionManager: {
title: "Pory posiłków",
newSection: "Nowa pora posiłku",
editSection: "Edytuj porę posiłku",
nameLabel: "Nazwa",
nameEnLabel: "Nazwa (angielski)",
namePlLabel: "Nazwa (polski)",
timeFrom: "Od",
timeTo: "Do",
moveUp: "Przesuń w górę",
moveDown: "Przesuń w dół",
empty: "Nie dodano jeszcze żadnych pór posiłków.",
confirmDelete:
"Czy na pewno usunąć „{name}”? Przypisane dania zostaną również usunięte ze wszystkich planów tygodniowych.",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
},
userManager: {
title: "Użytkownicy",
newUser: "Nowy użytkownik",
editUser: "Edytuj użytkownika",
newPasswordLabel: "Nowe hasło (opcjonalnie)",
repeatPasswordLabel: "Powtórz hasło",
passwordMismatch: "Hasła nie są zgodne.",
confirmDelete:
"Czy na pewno usunąć użytkownika „{email}”? Dostęp zostanie trwale usunięty.",
loadError: "Wczytywanie nie powiodło się: {message}",
},
designEditor: {
title: "Wygląd",
logoTitle: "Logo",
logoHint: "Logo jest wyświetlane wyśrodkowane nad jadłospisem.",
logoNone: "Nie ustawiono logo.",
logoUpload: "Prześlij logo",
logoRemove: "Usuń logo",
logoUpdated: "Logo zaktualizowane.",
logoRemoved: "Logo usunięte.",
faviconTitle: "Favicon",
faviconHint:
"Wyświetlane jako ikona na karcie przeglądarki. Bez własnego favicona używana jest domyślna ikona aplikacji.",
faviconNone: "Nie ustawiono własnego favicona.",
faviconUpload: "Prześlij favicon",
faviconRemove: "Usuń favicon",
faviconUpdated: "Favicon zaktualizowany.",
faviconRemoved: "Favicon usunięty.",
invalidImage: "Wybierz plik graficzny (PNG, JPG, SVG, WebP, ICO).",
colorsTitle: "Kolorystyka",
colorsSave: "Zapisz kolory",
colorsReset: "Przywróć domyślne",
colorsSaved: "Kolory zapisane.",
colorPrimary: "Kolor główny (nagłówek tabeli)",
colorPrimaryDark: "Kolor główny, ciemny (górny pasek wewnętrzny)",
colorAccent: "Kolor akcentu",
colorHeaderText: "Kolor tekstu w nagłówku tabeli",
colorRowBg: "Tło komórek",
colorRowBgAlt: "Tło komórek (naprzemienne)",
colorAddBtn: "Przycisk „Dodaj”",
colorDeleteBtn: "Przycisk „Usuń”",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
uploadError: "Przesyłanie nie powiodło się: {message}",
confirmRemoveLogo: "Czy na pewno usunąć logo?",
confirmRemoveFavicon: "Czy na pewno usunąć favicon?",
},
labelingNotice: {
title: "Wyświetlanie alergenów i dodatków",
hint:
"Domyślnie każdy gość widzi szczegółowy wybór alergenów/dodatków dla każdego dania. Alternatywnie można zamiast tego wyświetlić tylko komunikat – wybór dla poszczególnych dań zostanie wtedy również ukryty w zarządzaniu daniami.",
modeDetailed: "Pokaż szczegółowy wybór dla każdego dania",
modeNotice: "Pokaż tylko komunikat",
noticeDeLabel: "Tekst komunikatu (niemiecki)",
noticeEnLabel: "Tekst komunikatu (angielski)",
noticePlLabel: "Tekst komunikatu (polski)",
save: "Zapisz",
saved: "Zapisano.",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
},
passkeys: {
title: "Klucze dostępu",
hint: "Dzięki kluczowi dostępu (odcisk palca, rozpoznawanie twarzy lub klucz bezpieczeństwa) możesz w przyszłości logować się bez hasła. Klucze dostępu są przypisane do domeny – po zmianie domeny trzeba je zarejestrować ponownie.",
register: "Zarejestruj nowy klucz dostępu",
empty: "Nie zarejestrowano jeszcze żadnych kluczy dostępu.",
createdOn: "– utworzono {date}",
registered: "Klucz dostępu został pomyślnie zarejestrowany.",
registerError: "Rejestracja nie powiodła się: {message}",
registerCancelled: "Rejestracja klucza dostępu została anulowana.",
loadError: "Nie udało się wczytać kluczy dostępu: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
confirmDelete: "Czy na pewno usunąć ten klucz dostępu?",
},
account: {
title: "Moje konto",
personalTitle: "Dane osobowe",
saveName: "Zapisz nazwę",
nameSaved: "Nazwa zapisana.",
nameRequired: "Podaj nazwę.",
emailTitle: "Adres e-mail",
changeEmail: "Zmień adres e-mail",
emailChanged: "Adres e-mail zmieniony.",
passwordTitle: "Zmień hasło",
currentPassword: "Obecne hasło (do weryfikacji)",
newPassword: "Nowe hasło",
newPasswordRepeat: "Powtórz nowe hasło",
changePassword: "Zmień hasło",
passwordChanged: "Hasło zmienione.",
passwordMismatch: "Hasła nie są zgodne.",
passwordTooShort: "Nowe hasło musi mieć co najmniej 8 znaków.",
passwordAuthTitle: "Logowanie hasłem",
passwordAuthHint:
"Możesz wyłączyć logowanie e-mailem i hasłem dla swojego konta i logować się w przyszłości wyłącznie kluczem dostępu. Wymaga to zarejestrowania co najmniej jednego klucza dostępu.",
passwordAuthCheckbox: "Logowanie e-mailem i hasłem dozwolone",
passwordAuthEnabled: "Logowanie hasłem włączone.",
passwordAuthDisabled: "Logowanie hasłem wyłączone.",
passwordAuthNeedsPasskey:
"Bez zarejestrowanego klucza dostępu nie możesz wyłączyć logowania hasłem – stracisz dostęp do konta. Najpierw zarejestruj klucz dostępu poniżej.",
confirmDisablePasswordAuth:
"Czy na pewno wyłączyć logowanie e-mailem i hasłem? Potem będziesz mógł/mogła zalogować się tylko kluczem dostępu.",
wrongCurrentPassword: "Obecne hasło jest nieprawidłowe.",
emailAlreadyUsed: "Ten adres e-mail jest już używany.",
saveError: "Zapisywanie nie powiodło się: {message}",
changeError: "Zmiana nie powiodła się: {message}",
},
suggestions: {
formTitle: "Zaproponuj danie",
formHint:
"Brakuje dania w jadłospisie? Zaproponuj je tutaj – zespół stołówki sprawdzi Twoją propozycję.",
nameLabel: "Nazwa dania",
descriptionLabel: "Opis / składniki (opcjonalnie)",
submit: "Wyślij propozycję",
submitting: "Wysyłanie…",
success: "Dziękujemy! Twoja propozycja została przesłana.",
duplicate: "To danie już istnieje w jadłospisie.",
genericError: "Wysyłanie nie powiodło się: {message}",
nameRequired: "Podaj nazwę.",
backToMenu: "← Powrót do jadłospisu",
reviewTitle: "Propozycje dań",
reviewEmpty: "Brak propozycji.",
accept: "Dodaj jako danie",
reject: "Odrzuć",
confirmReject: "Czy na pewno odrzucić i usunąć propozycję „{name}”?",
accepted: "Dodano jako danie.",
rejected: "Propozycja odrzucona.",
loadError: "Wczytywanie nie powiodło się: {message}",
actionError: "Czynność nie powiodła się: {message}",
submittedOn: "przesłano {date}",
},
};
export type { Dict };
export const dictionaries = { de, en, pl };
export const dictionaries = { de };
+46
View File
@@ -0,0 +1,46 @@
// Reine, abhängigkeitsfreie Umwandlung zwischen dem verschachtelten
// Dict-Objekt (lib/i18n/dictionaries.ts) und flachen Schlüssel-Wert-
// Paaren ("login.errorGeneric") – Grundlage für Vorlage-Export/-Import
// von Sprachpaketen. Arrays (z. B. weekdays.long) werden über den
// numerischen Index als eigene Schlüssel geflattet ("weekdays.long.0")
// und beim Zurückbauen anhand rein numerischer Kind-Schlüssel wieder
// als Array erkannt.
export function flattenDict(value: unknown, prefix = ""): Record<string, string> {
const out: Record<string, string> = {};
function walk(node: unknown, path: string) {
if (Array.isArray(node)) {
node.forEach((item, i) => walk(item, `${path}.${i}`));
} else if (node !== null && typeof node === "object") {
for (const [key, child] of Object.entries(node)) {
walk(child, path ? `${path}.${key}` : key);
}
} else {
out[path] = String(node);
}
}
walk(value, prefix);
return out;
}
export function unflattenDict(flat: Record<string, string>): unknown {
const root: Record<string, unknown> = {};
for (const [path, value] of Object.entries(flat)) {
const parts = path.split(".");
let node: Record<string, unknown> = root;
for (let i = 0; i < parts.length - 1; i++) {
const key = parts[i];
if (node[key] === undefined) {
const nextIsIndex = /^\d+$/.test(parts[i + 1]);
node[key] = nextIsIndex ? [] : {};
}
node = node[key] as Record<string, unknown>;
}
node[parts[parts.length - 1]] = value;
}
return root;
}
+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;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "canteen-menu",
"version": "1.9.5",
"version": "1.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "canteen-menu",
"version": "1.9.5",
"version": "1.10.0",
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canteen-menu",
"version": "1.9.5",
"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",