Files
Speisekarte/app/page.tsx
T
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

227 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link";
import { query } from "@/lib/db";
import { loadMenuEntries } from "@/lib/db/menu-entries";
import { getSettings } from "@/lib/db/settings";
import {
addDays,
dishCodes,
entriesFor,
formatDateLong,
formatPrice,
mondayOf,
todayISO,
} from "@/lib/menu";
import { getDictionary, getLocale } from "@/lib/i18n";
import { localized, logoUrl, SITE_TITLE } from "@/lib/types";
import type { CodeItem, Section } from "@/lib/types";
import PublicMobileMenu from "@/components/PublicMobileMenu";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
export default async function Home({
searchParams,
}: {
searchParams: Promise<{ woche?: string }>;
}) {
const { woche } = await searchParams;
const monday = mondayOf(woche);
const friday = addDays(monday, 4);
const currentWeek = mondayOf(todayISO());
const locale = await getLocale();
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"),
loadMenuEntries(monday, friday),
query<CodeItem>("select * from allergens order by sort"),
query<CodeItem>("select * from additives order by sort"),
getSettings(),
]);
const logo = logoUrl(settings?.logo_path);
const labelingDetailed = (settings?.labeling_mode ?? "detailed") !== "notice";
return (
<div className="flex-1 px-4 py-6 max-w-[1600px] mx-auto w-full">
{/* Kopfbereich: Sprache + Login-Link eigene Zeile, Logo zentriert
darunter. Kein absolutes Positionieren mehr – ein breites Logo
(z. B. ein Banner-Format) konnte sonst mit dem Login-Link
kollidieren. */}
<header className="mb-6">
<div className="flex justify-end items-center gap-3 mb-2">
{/* Auf Mobilgeräten übernimmt PublicMobileMenu den
Sprachumschalter, hier würde er sonst doppelt erscheinen. */}
<div className="hidden sm:block">
<LanguageSwitcher variant="light" />
</div>
<a
href="/feed.xml"
title={dict.publicMenu.rssFeed}
aria-label={dict.publicMenu.rssFeed}
className="text-gray-400 hover:text-[#ee802f]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="18"
height="18"
fill="currentColor"
aria-hidden="true"
>
<circle cx="5.5" cy="18.5" r="2.5" />
<path d="M4 11a9 9 0 0 1 9 9h-3a6 6 0 0 0-6-6v-3z" />
<path d="M4 4a16 16 0 0 1 16 16h-3A13 13 0 0 0 4 7V4z" />
</svg>
</a>
<Link
href="/login"
className="text-sm text-gray-500 hover:text-[var(--c-primary)]"
>
{dict.common.login}
</Link>
</div>
<div className="flex justify-center">
{logo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logo}
alt="Logo"
className="h-20 max-w-full object-contain"
/>
) : (
<h1 className="text-4xl font-extrabold tracking-tight text-[var(--c-primary)]">
{SITE_TITLE}
</h1>
)}
</div>
</header>
{/* Mobil: Tag (Standard) oder Woche umschaltbar */}
<PublicMobileMenu
monday={monday}
sections={sections}
entries={entries}
showLabeling={labelingDetailed}
/>
{/* Ab "sm": klassische Wochentabelle */}
<div className="hidden sm:block">
<div className="flex flex-wrap items-center gap-3 mb-4">
<Link href={`/?woche=${addDays(monday, -7)}`} className="btn btn-add">
{dict.publicMenu.prevWeek}
</Link>
<Link href={`/?woche=${currentWeek}`} className="btn btn-primary">
{dict.publicMenu.today}
</Link>
<Link href={`/?woche=${addDays(monday, 7)}`} className="btn btn-add">
{dict.publicMenu.nextWeek}
</Link>
<Link href="/vorschlag" className="btn btn-neutral ml-auto">
{dict.publicMenu.suggestDish}
</Link>
</div>
<div className="overflow-x-auto">
<table className="menu-table">
<thead>
<tr>
<th className="section-head w-56" aria-hidden />
{days.map((d) => (
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso, dict.dateLocale)}
</th>
))}
</tr>
</thead>
<tbody>
{sections.map((s) => (
<tr key={s.key}>
<th className="section-head">
<div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => (
<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>
</table>
</div>
</div>
{/* Allergene & Zusatzstoffe: entweder Legende zu den Kürzeln bei den
Gerichten, oder – falls im Hinweismodus – nur ein Hinweistext. */}
{labelingDetailed ? (
<section className="mt-8 grid gap-6 sm:grid-cols-2 text-sm text-gray-700">
<div>
<h2 className="font-bold text-[var(--c-primary)] mb-2">
{dict.publicMenu.allergens}
</h2>
<ul className="space-y-0.5">
{allergens.map((a) => (
<li key={a.code}>
<span className="font-semibold">{a.code}</span> –{" "}
{localized(a, locale)}
</li>
))}
</ul>
</div>
<div>
<h2 className="font-bold text-[var(--c-primary)] mb-2">
{dict.publicMenu.additives}
</h2>
<ul className="space-y-0.5">
{additives.map((a) => (
<li key={a.code}>
<span className="font-semibold">{a.code}</span> –{" "}
{localized(a, locale)}
</li>
))}
</ul>
</div>
</section>
) : (
settings && (
<section className="mt-8 text-sm text-gray-700">
<h2 className="font-bold text-[var(--c-primary)] mb-2">
{dict.publicMenu.labelingNoticeTitle}
</h2>
<p>
{localized(
{
name: settings.labeling_notice,
name_en: settings.labeling_notice_en,
name_pl: settings.labeling_notice_pl,
},
locale
)}
</p>
</section>
)
)}
</div>
);
}