Öffentliche Speisekarte: Wochenansicht, konfigurierbare Farben, Allergen-Legende

This commit is contained in:
2026-07-17 18:18:27 +02:00
parent 74ce663813
commit 7e31dce237
3 changed files with 271 additions and 90 deletions
+103 -19
View File
@@ -1,26 +1,110 @@
@import "tailwindcss";
/* Farb-Variablen: Defaults entsprechen r-hofmann.de; werden zur Laufzeit
aus den Einstellungen (Supabase settings.colors) überschrieben. */
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--c-primary: #003388;
--c-primary-dark: #0d315c;
--c-accent: #ff6900;
--c-header-text: #ffffff;
--c-row-bg: #e3e4f1;
--c-row-bg-alt: #f4f4fa;
--c-add: #6da54c;
--c-del: #e2371d;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background: #ffffff;
color: #1a1a1a;
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
padding: 0.35rem 1rem;
font-size: 0.8rem;
font-weight: 600;
color: #ffffff;
cursor: pointer;
border: 1px solid rgb(0 0 0 / 0.15);
transition: filter 0.15s ease;
text-decoration: none;
white-space: nowrap;
}
.btn:hover { filter: brightness(1.08); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-add { background: var(--c-add); }
.btn-del { background: var(--c-del); }
.btn-primary { background: var(--c-primary); }
.btn-neutral { background: #6b7280; }
/* ---------- Formulare ---------- */
.input {
width: 100%;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
background: #ffffff;
color: #1a1a1a;
}
.input:focus { outline: 2px solid var(--c-primary); outline-offset: 0; }
.label {
display: block;
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 0.15rem;
color: #374151;
}
.card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
}
/* ---------- Speisekarten-Tabelle ---------- */
.menu-table {
width: 100%;
border-collapse: separate;
border-spacing: 2px;
table-layout: fixed;
}
.menu-table th.day-head {
background: var(--c-primary);
color: var(--c-header-text);
padding: 0.5rem 0.5rem;
font-size: 0.9rem;
line-height: 1.3;
}
.menu-table th.section-head {
background: var(--c-primary);
color: var(--c-header-text);
text-align: left;
vertical-align: middle;
padding: 0.6rem 0.8rem;
font-size: 0.95rem;
}
.menu-table th.section-head .time {
font-weight: 400;
font-size: 0.8rem;
}
.menu-table td.cell {
background: var(--c-row-bg);
vertical-align: top;
text-align: center;
padding: 0.5rem;
font-size: 0.85rem;
}
.menu-table tr:nth-child(even) td.cell { background: var(--c-row-bg-alt); }
.menu-table td.cell .dish { margin-bottom: 0.35rem; }
.menu-table td.cell .dish sup {
font-size: 0.6rem;
color: #4b5563;
margin-left: 0.15rem;
}
+28 -14
View File
@@ -1,33 +1,47 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Geist } from "next/font/google";
import "./globals.css";
import { createClient } from "@/lib/supabase/server";
import { COLOR_VARS, DEFAULT_COLORS } from "@/lib/types";
// Farben/Logo sollen Änderungen sofort widerspiegeln.
export const dynamic = "force-dynamic";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Speisekarte",
description: "Speisekarte der Betriebskantine",
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const supabase = await createClient();
const { data: settings } = await supabase
.from("settings")
.select("colors")
.eq("id", 1)
.maybeSingle();
const colors: Record<string, string> = {
...DEFAULT_COLORS,
...((settings?.colors as Record<string, string>) ?? {}),
};
const style = Object.fromEntries(
Object.entries(COLOR_VARS).map(([key, cssVar]) => [cssVar, colors[key]])
) as React.CSSProperties;
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="de" className={`${geistSans.variable} h-full antialiased`}>
<body style={style} className="min-h-full flex flex-col">
{children}
</body>
</html>
);
}
+138 -55
View File
@@ -1,65 +1,148 @@
import Link from "next/link";
import Image from "next/image";
import { createClient } from "@/lib/supabase/server";
import {
addDays,
formatDateLong,
formatPrice,
mondayOf,
WEEKDAYS,
} from "@/lib/menu";
import { logoUrl } from "@/lib/types";
import type { CodeItem, MenuEntry, Section } from "@/lib/types";
export default async function Home({
searchParams,
}: {
searchParams: Promise<{ woche?: string }>;
}) {
const { woche } = await searchParams;
const monday = mondayOf(woche);
const friday = addDays(monday, 4);
const days = WEEKDAYS.map((weekday, i) => ({
weekday,
iso: addDays(monday, i),
}));
const supabase = await createClient();
const [sectionsRes, entriesRes, allergensRes, additivesRes, settingsRes] =
await Promise.all([
supabase.from("sections").select("*").order("sort"),
supabase
.from("menu_entries")
.select("id, menu_date, section_key, position, dish:dishes(id, name, price)")
.gte("menu_date", monday)
.lte("menu_date", friday)
.order("position")
.order("created_at"),
supabase.from("allergens").select("*").order("sort"),
supabase.from("additives").select("*").order("sort"),
supabase.from("settings").select("logo_path").eq("id", 1).maybeSingle(),
]);
const sections = (sectionsRes.data ?? []) as Section[];
const entries = (entriesRes.data ?? []) as unknown as MenuEntry[];
const allergens = (allergensRes.data ?? []) as CodeItem[];
const additives = (additivesRes.data ?? []) as CodeItem[];
const logo = logoUrl(settingsRes.data?.logo_path);
const cell = (iso: string, sectionKey: string) =>
entries.filter((e) => e.menu_date === iso && e.section_key === sectionKey);
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
<div className="flex-1 px-4 py-6 max-w-[1600px] mx-auto w-full">
{/* Kopfbereich: Logo zentriert, Login-Link rechts */}
<header className="relative mb-6">
<div className="flex justify-center">
{logo ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logo} alt="Logo" className="h-20 object-contain" />
) : (
<h1 className="text-4xl font-extrabold tracking-tight text-[var(--c-primary)]">
Speisekarte
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
)}
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<Link
href="/login"
className="absolute right-0 top-0 text-sm text-gray-500 hover:text-[var(--c-primary)]"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
Anmelden
</Link>
</header>
{/* Wochen-Navigation */}
<div className="flex gap-3 mb-4">
<Link href={`/?woche=${addDays(monday, -7)}`} className="btn btn-add">
← Vorherige Woche
</Link>
<Link href={`/?woche=${addDays(monday, 7)}`} className="btn btn-add">
Nächste Woche →
</Link>
</div>
</main>
{/* Speisekarte */}
<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)}
</th>
))}
</tr>
</thead>
<tbody>
{sections.map((s) => (
<tr key={s.key}>
<th className="section-head">
<div>{s.label}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => (
<td key={d.iso} className="cell">
{cell(d.iso, s.key).map((e) => (
<div key={e.id} className="dish">
<span className="font-bold">{e.dish.name}</span>
{" – "}
{formatPrice(e.dish.price)}
</div>
))}
</td>
))}
</tr>
))}
</tbody>
</table>
{/* Allergene & Zusatzstoffe (gelten für die komplette Speisekarte) */}
<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">Allergene</h2>
<ul className="space-y-0.5">
{allergens.map((a) => (
<li key={a.code}>
<span className="font-semibold">{a.code}</span> – {a.name}
</li>
))}
</ul>
</div>
<div>
<h2 className="font-bold text-[var(--c-primary)] mb-2">
Zusatzstoffe
</h2>
<ul className="space-y-0.5">
{additives.map((a) => (
<li key={a.code}>
<span className="font-semibold">{a.code}</span> – {a.name}
</li>
))}
</ul>
</div>
</section>
</div>
);
}