Files
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

279 lines
9.8 KiB
TypeScript
Raw Permalink 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 { loadTranslations } from "@/lib/db/translations";
import { listClosedDays } from "@/lib/db/closed-days";
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 [
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<Omit<CodeItem, "translations">>("select code, name, sort from allergens order by sort"),
query<Omit<CodeItem, "translations">>("select code, name, sort from additives order by sort"),
getSettings(),
loadTranslations("section_translations"),
loadTranslations("allergen_translations"),
loadTranslations("additive_translations"),
listClosedDays(monday, friday),
]);
const closedDayFor = (iso: string) => closedDays.find((c) => c.closed_date === iso);
const sections: Section[] = sectionRows.map((s) => ({
...s,
translations: sectionTranslations.get(s.key) ?? {},
}));
const allergens: CodeItem[] = allergenRows.map((a) => ({
...a,
translations: allergenTranslations.get(a.code) ?? {},
}));
const additives: CodeItem[] = additiveRows.map((a) => ({
...a,
translations: additiveTranslations.get(a.code) ?? {},
}));
const logo = logoUrl(settings?.logo_path);
const labelingDetailed = (settings?.labeling_mode ?? "detailed") !== "notice";
return (
<div className="flex-1 px-4 py-6 max-w-[1600px] mx-auto w-full">
{/* 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}
closedDays={closedDays}
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, sIdx) => (
<tr key={s.key}>
<th className="section-head">
<div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => {
const closed = closedDayFor(d.iso);
if (closed) {
if (sIdx > 0) return null;
return (
<td key={d.iso} className="cell closed-cell" rowSpan={sections.length}>
<div className="flex flex-col items-center justify-center gap-1 py-4 text-red-700">
<span className="text-2xl" aria-hidden>
🔒
</span>
<span className="font-semibold">{dict.publicMenu.closedLabel}</span>
{localized(closed, locale) && (
<span className="text-sm px-2 text-center">
{localized(closed, locale)}
</span>
)}
</div>
</td>
);
}
return (
<td key={d.iso} className="cell">
{entriesFor(entries, d.iso, s.key).map((e) => {
const codes = dishCodes(e.dish);
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>
);
}