Compare commits

..
8 Commits
Author SHA1 Message Date
florian.wilde bb6093dbfe Changelog hinzufügen 2026-07-18 00:49:01 +02:00
florian.wilde 088df8a26b Version 1.2.0
Versionsnummer und README auf den aktuellen Funktionsstand gebracht.
2026-07-18 00:46:41 +02:00
florian.wilde 39181be3fb SITE_TITLE per Docker-Variable, Favicon im Admin-Bereich austauschbar
- SITE_TITLE (Default "Canteen Menu") steuert Browser-Tab-Titel und
  H1-Fallback ohne Logo
- Favicon-Upload analog zum Logo über den Design-Bereich (Storage-
  Bucket branding); generateMetadata setzt icons.icon jetzt immer
  explizit, da Next.js sonst app/favicon.ico zusätzlich einbindet –
  die Datei liegt daher als expliziter Fallback unter public/
2026-07-18 00:46:41 +02:00
florian.wilde 72459d8da5 Konto: Passwort-Anmeldung pro Nutzer deaktivierbar
Schalter im eigenen Konto, erzwingt vorher mindestens einen
registrierten Passkey (sonst Aussperrung). Durchsetzung beim Login:
nach erfolgreicher Passwort-Prüfung wird bei deaktiviertem Flag sofort
wieder abgemeldet; Passkey-Anmeldung bleibt davon unberührt.
2026-07-18 00:46:40 +02:00
florian.wilde 00745ae48b Benutzerverwaltung: Passwort-Wiederholung gegen Tippfehler
Gilt beim Anlegen (Pflicht) und beim optionalen Passwort-Reset im
Bearbeiten-Formular.
2026-07-18 00:46:39 +02:00
florian.wilde 675503e1f4 Allergene/Zusatzstoffe je Gericht auswählbar, klein angezeigt
Chip-Auswahl im Gericht-Formular (Klick zum An-/Abwählen) statt
Checkbox-Liste; Kürzel erscheinen hochgestellt neben dem Gerichtnamen
in Speisekarte, mobiler Ansicht und Wochenplan-Editor. Editor bekommt
außerdem einen Heute-Button.
2026-07-18 00:46:39 +02:00
florian.wilde 1db89a54bf Öffentliche Speisekarte: Heute-Button, mobile Tages-/Wochenansicht
- "Heute" springt direkt zur aktuellen Woche
- Unter dem sm-Breakpoint umschaltbar zwischen Tag (Standard) und
  Woche statt der Tabelle; Desktop bleibt vollständig serverseitig
  gerendert, nur der mobile Umschalter ist eine Client-Komponente
2026-07-18 00:46:39 +02:00
florian.wilde 29ca8e1227 Migration: Allergene/Zusatzstoffe je Gericht, abschaltbarer Passwort-Login, Favicon
- dishes.allergens/additives (text[]): Kennzeichnung wieder pro Gericht
  statt nur global, referenziert weiterhin die globalen Listen
- profiles.password_auth_enabled: Nutzer können die eigene
  Passwort-Anmeldung deaktivieren; Spalten-Grant + neue Policy
  "profiles_update_own" (ersetzt profiles_update_own_name)
- settings.favicon_path fürs austauschbare Favicon
- Lokale PostgreSQL-Kopie (Docker + nativ) auf den neuen Stand gebracht
2026-07-18 00:46:38 +02:00
24 changed files with 851 additions and 176 deletions
+3
View File
@@ -16,3 +16,6 @@ SUPABASE_SERVICE_ROLE_KEY=<service-role-key>
# SUPABASE_ACCESS_TOKEN=<token> node scripts/configure-passkeys.mjs
# ausführen, damit Passkeys auf der neuen Domain funktionieren.
APP_URL=http://localhost:3000
# Titel im Browser-Tab und Überschrift, solange kein Logo hochgeladen ist.
SITE_TITLE=Canteen Menu
+66
View File
@@ -0,0 +1,66 @@
# Changelog
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.2.0] – 2026-07-18
### Hinzugefügt
- „Heute"-Button in der Wochennavigation (öffentliche Speisekarte und
Wochenplan-Editor)
- Mobile Ansicht der öffentlichen Speisekarte: umschaltbar zwischen
Tag (Standard) und Woche, unabhängig von der Desktop-Tabelle
- Allergene und Zusatzstoffe wieder pro Gericht auswählbar (Chip-Auswahl
im Formular) und als kleine Kürzel neben dem Gerichtnamen sichtbar –
in Speisekarte, mobiler Ansicht und Wochenplan-Editor
- Passwort-Wiederholung beim Anlegen neuer Benutzer und beim optionalen
Passwort-Reset
- Passwort-Anmeldung pro Konto deaktivierbar (nur wenn mindestens ein
Passkey registriert ist), Durchsetzung im Login-Flow
- `SITE_TITLE`-Umgebungsvariable für Browser-Tab-Titel und Überschrift
ohne Logo
- Austauschbares Favicon über den Admin-Design-Bereich
### Geändert
- Gemeinsame Navigation für Kantinen- und Admin-Bereich statt zwei
getrennter Menüs
- Live-Suche bei den Gerichten
- Mehrfachauswahl zum Batch-Löschen von Gerichten
- App erreichbar auf `0.0.0.0` statt nur `localhost` (Docker und
nativer Betrieb)
- Projekt umbenannt von „Speisekarte“ zu „canteen-menu“ (Docker-,
npm- und Supabase-Namen; deutsche In-App-Texte unverändert)
- Öffentliche URL über `APP_URL` konfigurierbar (CSRF-Origin-Check
hinter Reverse-Proxy, Passkey-Domain)
### Behoben
- Next.js band `app/favicon.ico` zusätzlich zu einem dynamisch
gesetzten Favicon ein – Favicon wird jetzt immer explizit gesetzt
- Weiterleitungen (Logout, Zugriffsschutz) übernahmen nach der
0.0.0.0-Bindung fälschlich die Server-Adresse statt des
aufgerufenen Hosts
## [1.1.0] – 2026-07-17
### Hinzugefügt
- Versionsnummer im Fußbereich jeder Seite
## [1.0.0] – 2026-07-17
Erste vollständige Version.
### Hinzugefügt
- Öffentliche Speisekarte ohne Login: Wochenansicht Mo–Fr mit den
Bereichen Brotzeit, Mittagessen, Mittagessen vegetarisch und Suppe;
Wochen-Navigation; Logo zentriert; Allergene & Zusatzstoffe unterhalb
der Karte
- Anmeldung mit E-Mail/Passwort oder Passkey
- Kantinen-Bereich: Gerichte anlegen/bearbeiten/löschen, Wochenplan
pflegen, globale Allergen-/Zusatzstofflisten verwalten
- Admin-Bereich: Benutzerverwaltung, Farbgebung, Logo-Upload
- Eigenes Konto verwalten: Name, E-Mail, Passwort, Passkeys
- Row-Level-Security, HTTP-Security-Header, CSRF-Origin-Check
- Docker-Setup (App-Image, lokale PostgreSQL-Kopie, Dev-Profil ohne
lokales Node.js)
- Lokale PostgreSQL-Kopie mit Sync-Skript
+10 -4
View File
@@ -7,16 +7,21 @@ Admin-Bereich für Benutzerverwaltung und Branding.
**Stack:** Next.js 16 (App Router, TypeScript, Tailwind CSS) · Supabase
(PostgreSQL, Auth inkl. Passkeys, Storage, Row Level Security)
Änderungen je Version stehen in [CHANGELOG.md](CHANGELOG.md), Git-Tags
markieren die jeweiligen Versionsstände (`git tag`).
## Funktionen
| Bereich | Funktionen |
| --- | --- |
| Öffentlich (ohne Login) | Wochenplan Mo–Fr mit den Bereichen Brotzeit (09:00–10:00), Mittagessen, Mittagessen vegetarisch und Suppe (je 11:30–13:30); Navigation vorherige/nächste Woche; Logo zentriert über der Karte; Allergene & Zusatzstoffe unterhalb der Karte |
| Kantinen-Team (`/intern`) | Gerichte anlegen/bearbeiten/löschen; Gerichte Tagen/Bereichen zuweisen (mehrere pro Zelle) und wieder entfernen; globale Allergen-/Zusatzstofflisten pflegen; eigene Passkeys verwalten |
| Admin (`/admin`) | Kantinenmitarbeiter anlegen/bearbeiten/löschen (inkl. Passwort-Reset und Rollenvergabe); Farbgebung der Speisekarte anpassen; Logo hochladen/entfernen |
| Öffentlich (ohne Login) | Wochenplan Mo–Fr mit den Bereichen Brotzeit (09:00–10:00), Mittagessen, Mittagessen vegetarisch und Suppe (je 11:30–13:30); Navigation vorherige/nächste Woche/Heute; auf schmalen Bildschirmen umschaltbare Tages-/Wochenansicht (Tag ist Standard); Logo zentriert über der Karte; Allergen-/Zusatzstoff-Kürzel je Gericht, Legende unterhalb der Karte |
| Kantinen-Team (`/intern`) | Gerichte anlegen/bearbeiten/löschen (inkl. Live-Suche und Mehrfachauswahl zum Batch-Löschen) mit Allergenen/Zusatzstoffen je Gericht; Gerichte Tagen/Bereichen zuweisen (mehrere pro Zelle) und wieder entfernen; globale Allergen-/Zusatzstofflisten pflegen; eigenes Konto verwalten (Name, E-Mail, Passwort, Passkeys, Passwort-Anmeldung an/aus) |
| Admin (`/admin`) | Kantinenmitarbeiter anlegen/bearbeiten/löschen (inkl. Passwort-Reset mit Wiederholung und Rollenvergabe); Farbgebung der Speisekarte anpassen; Logo und Favicon hochladen/entfernen |
Anmeldung mit E-Mail + Passwort oder alternativ per **Passkey**
(Registrierung im Bereich „Passkeys“ nach dem ersten Login).
(Registrierung im eigenen Konto nach dem ersten Login). Die
Passwort-Anmeldung kann pro Konto deaktiviert werden, sobald mindestens
ein Passkey registriert ist.
## Projektstruktur
@@ -67,6 +72,7 @@ Beim Webserver/Hoster müssen genau diese drei Variablen hinterlegt werden
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Öffentlicher anon-Key (RLS schützt die Daten) | nein |
| `SUPABASE_SERVICE_ROLE_KEY` | Service-Role-Key für die Benutzerverwaltung | **ja – niemals im Browser/Repo!** |
| `APP_URL` | Öffentliche URL der App, z. B. `https://menu.example.de` (Origin-Check hinter Reverse-Proxy; zugleich Passkey-Domain) | nein |
| `SITE_TITLE` | Titel im Browser-Tab und Überschrift ohne Logo, Default `Canteen Menu` | nein |
Build & Start auf dem Server – **mit Docker** (empfohlen, Node.js und
PostgreSQL müssen nicht installiert sein):
+28
View File
@@ -108,3 +108,31 @@ body {
color: #4b5563;
margin-left: 0.15rem;
}
/* ---------- Chip-Auswahl (Allergene/Zusatzstoffe je Gericht) ---------- */
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 9999px;
padding: 0.2rem 0.65rem;
font-size: 0.75rem;
line-height: 1.2;
border: 1px solid #d1d5db;
background: #f9fafb;
color: #374151;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.chip:hover { border-color: var(--c-primary); }
.chip-active {
background: var(--c-primary);
border-color: var(--c-primary);
color: #ffffff;
}
.dish-codes {
font-size: 0.65rem;
color: #6b7280;
vertical-align: super;
margin-left: 0.15rem;
}
+2 -1
View File
@@ -12,7 +12,7 @@ export default async function KontoPage() {
const { data: profile } = await supabase
.from("profiles")
.select("role, full_name")
.select("role, full_name, password_auth_enabled")
.eq("id", user.id)
.single();
if (!profile || !["staff", "admin"].includes(profile.role)) redirect("/");
@@ -28,6 +28,7 @@ export default async function KontoPage() {
role={profile.role}
initialFullName={profile.full_name ?? ""}
initialEmail={user.email ?? ""}
initialPasswordAuthEnabled={profile.password_auth_enabled}
/>
</main>
</div>
+19 -6
View File
@@ -2,10 +2,10 @@ import type { Metadata } from "next";
import { Geist } from "next/font/google";
import "./globals.css";
import { createClient } from "@/lib/supabase/server";
import { COLOR_VARS, DEFAULT_COLORS } from "@/lib/types";
import { COLOR_VARS, DEFAULT_COLORS, faviconUrl, SITE_TITLE } from "@/lib/types";
import packageJson from "@/package.json";
// Farben/Logo sollen Änderungen sofort widerspiegeln.
// Farben/Logo/Favicon sollen Änderungen sofort widerspiegeln.
export const dynamic = "force-dynamic";
const geistSans = Geist({
@@ -13,10 +13,23 @@ const geistSans = Geist({
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Canteen Menu",
description: "Speisekarte der Betriebskantine",
};
export async function generateMetadata(): Promise<Metadata> {
const supabase = await createClient();
const { data: settings } = await supabase
.from("settings")
.select("favicon_path")
.eq("id", 1)
.maybeSingle();
return {
title: SITE_TITLE,
description: "Speisekarte der Betriebskantine",
// Immer explizit setzen: app/favicon.ico würde sonst zusätzlich zu
// einem eigenen Favicon eingebunden (Next.js kombiniert Datei-
// Konvention und Metadata statt sie zu ersetzen).
icons: { icon: faviconUrl(settings?.favicon_path) ?? "/favicon.ico" },
};
}
export default async function RootLayout({
children,
+16 -1
View File
@@ -46,7 +46,22 @@ export default function LoginPage() {
setBusy(false);
return;
}
await redirectByRole(data.user.id);
const { data: profile } = await supabase
.from("profiles")
.select("role, password_auth_enabled")
.eq("id", data.user.id)
.single();
if (profile?.password_auth_enabled === false) {
await supabase.auth.signOut();
setError(
"Die Anmeldung mit Passwort ist für dieses Konto deaktiviert. Bitte mit Passkey anmelden."
);
setBusy(false);
return;
}
router.replace(profile?.role === "admin" ? "/admin" : "/intern");
router.refresh();
}
async function signInWithPasskey() {
+69 -53
View File
@@ -1,15 +1,18 @@
import Link from "next/link";
import Image from "next/image";
import { createClient } from "@/lib/supabase/server";
import {
addDays,
dishCodes,
entriesFor,
formatDateLong,
formatPrice,
mondayOf,
todayISO,
WEEKDAYS,
} from "@/lib/menu";
import { logoUrl } from "@/lib/types";
import { logoUrl, SITE_TITLE } from "@/lib/types";
import type { CodeItem, MenuEntry, Section } from "@/lib/types";
import PublicMobileMenu from "@/components/PublicMobileMenu";
export default async function Home({
searchParams,
@@ -19,6 +22,7 @@ export default async function Home({
const { woche } = await searchParams;
const monday = mondayOf(woche);
const friday = addDays(monday, 4);
const currentWeek = mondayOf(todayISO());
const days = WEEKDAYS.map((weekday, i) => ({
weekday,
iso: addDays(monday, i),
@@ -30,7 +34,9 @@ export default async function Home({
supabase.from("sections").select("*").order("sort"),
supabase
.from("menu_entries")
.select("id, menu_date, section_key, position, dish:dishes(id, name, price)")
.select(
"id, menu_date, section_key, position, dish:dishes(id, name, price, allergens, additives)"
)
.gte("menu_date", monday)
.lte("menu_date", friday)
.order("position")
@@ -46,9 +52,6 @@ export default async function Home({
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);
return (
<div className="flex-1 px-4 py-6 max-w-[1600px] mx-auto w-full">
{/* Kopfbereich: Logo zentriert, Login-Link rechts */}
@@ -59,7 +62,7 @@ export default async function Home({
<img src={logo} alt="Logo" className="h-20 object-contain" />
) : (
<h1 className="text-4xl font-extrabold tracking-tight text-[var(--c-primary)]">
Speisekarte
{SITE_TITLE}
</h1>
)}
</div>
@@ -71,54 +74,67 @@ export default async function Home({
</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>
{/* Mobil: Tag (Standard) oder Woche umschaltbar */}
<PublicMobileMenu monday={monday} sections={sections} entries={entries} />
{/* Ab "sm": klassische Wochentabelle */}
<div className="hidden sm:block">
<div className="flex gap-3 mb-4">
<Link href={`/?woche=${addDays(monday, -7)}`} className="btn btn-add">
← Vorherige Woche
</Link>
<Link href={`/?woche=${currentWeek}`} className="btn btn-primary">
Heute
</Link>
<Link href={`/?woche=${addDays(monday, 7)}`} className="btn btn-add">
Nächste Woche →
</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)}
</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">
{entriesFor(entries, d.iso, s.key).map((e) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish">
<span className="font-bold">{e.dish.name}</span>
{codes && <sup className="dish-codes">{codes}</sup>}
{" – "}
{formatPrice(e.dish.price)}
</div>
);
})}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 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) */}
{/* Allergene & Zusatzstoffe: Legende zu den Kürzeln bei den Gerichten */}
<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>
+76 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import PasskeyManager from "@/components/PasskeyManager";
@@ -24,10 +24,12 @@ export default function AccountManager({
role,
initialFullName,
initialEmail,
initialPasswordAuthEnabled,
}: {
role: string;
initialFullName: string;
initialEmail: string;
initialPasswordAuthEnabled: boolean;
}) {
const supabase = useMemo(() => createClient(), []);
const router = useRouter();
@@ -52,6 +54,21 @@ export default function AccountManager({
const [pwInfo, setPwInfo] = useState<string | null>(null);
const [pwBusy, setPwBusy] = useState(false);
// Passwort-Anmeldung an/aus
const [passwordAuthEnabled, setPasswordAuthEnabled] = useState(
initialPasswordAuthEnabled
);
const [passkeyCount, setPasskeyCount] = useState<number | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [authInfo, setAuthInfo] = useState<string | null>(null);
const [authBusy, setAuthBusy] = useState(false);
useEffect(() => {
supabase.auth.passkey
.list()
.then(({ data }) => setPasskeyCount((data ?? []).length));
}, [supabase]);
async function saveName(e: React.FormEvent) {
e.preventDefault();
setNameError(null);
@@ -129,6 +146,40 @@ export default function AccountManager({
setPwBusy(false);
}
async function togglePasswordAuth() {
setAuthError(null);
setAuthInfo(null);
const next = !passwordAuthEnabled;
if (!next && (passkeyCount ?? 0) === 0) {
setAuthError(
"Ohne registrierten Passkey kannst du die Passwort-Anmeldung nicht deaktivieren – sonst wärst du ausgesperrt. Registriere zuerst einen Passkey weiter unten."
);
return;
}
if (
!next &&
!window.confirm(
"Anmeldung mit E-Mail und Passwort wirklich deaktivieren? Du kannst dich danach nur noch per Passkey anmelden."
)
)
return;
setAuthBusy(true);
const { error } = await supabase
.from("profiles")
.update({ password_auth_enabled: next })
.eq("id", (await supabase.auth.getUser()).data.user?.id ?? "");
if (error) {
setAuthError(`Speichern fehlgeschlagen: ${error.message}`);
} else {
setPasswordAuthEnabled(next);
setAuthInfo(
next ? "Passwort-Anmeldung aktiviert." : "Passwort-Anmeldung deaktiviert."
);
}
setAuthBusy(false);
}
return (
<div className="max-w-3xl space-y-6">
<div>
@@ -256,6 +307,30 @@ export default function AccountManager({
</form>
</section>
<section className="card">
<h2 className="font-semibold mb-1">Anmeldung mit Passwort</h2>
<p className="text-sm text-gray-600 mb-3">
Du kannst die Anmeldung mit E-Mail und Passwort für dein Konto
deaktivieren und dich künftig ausschließlich per Passkey anmelden.
Dafür muss mindestens ein Passkey registriert sein.
</p>
{authError && (
<p className="mb-2 text-sm text-[var(--c-del)]" role="alert">
{authError}
</p>
)}
{authInfo && <p className="mb-2 text-sm text-green-700">{authInfo}</p>}
<label className="flex items-center gap-2 cursor-pointer text-sm w-fit">
<input
type="checkbox"
checked={passwordAuthEnabled}
onChange={togglePasswordAuth}
disabled={authBusy || passkeyCount === null}
/>
Anmeldung mit E-Mail und Passwort erlaubt
</label>
</section>
<PasskeyManager />
</div>
);
+42
View File
@@ -0,0 +1,42 @@
"use client";
import type { CodeItem } from "@/lib/types";
export default function ChipPicker({
items,
selected,
onToggle,
label,
}: {
items: CodeItem[];
selected: string[];
onToggle: (code: string) => void;
label: string;
}) {
return (
<div>
<label className="label">{label}</label>
{items.length === 0 ? (
<p className="text-sm text-gray-400">Keine Einträge vorhanden.</p>
) : (
<div className="flex flex-wrap gap-1.5">
{items.map((item) => {
const active = selected.includes(item.code);
return (
<button
key={item.code}
type="button"
onClick={() => onToggle(item.code)}
className={`chip ${active ? "chip-active" : ""}`}
aria-pressed={active}
title={item.name}
>
<span className="font-semibold">{item.code}</span> {item.name}
</button>
);
})}
</div>
)}
</div>
);
}
+80 -21
View File
@@ -3,13 +3,21 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import { COLOR_LABELS, DEFAULT_COLORS, logoUrl } from "@/lib/types";
import { COLOR_LABELS, DEFAULT_COLORS, faviconUrl, logoUrl } from "@/lib/types";
type AssetKind = "logo" | "favicon";
const ASSET_COLUMN: Record<AssetKind, "logo_path" | "favicon_path"> = {
logo: "logo_path",
favicon: "favicon_path",
};
export default function DesignEditor() {
const supabase = useMemo(() => createClient(), []);
const router = useRouter();
const [colors, setColors] = useState<Record<string, string>>(DEFAULT_COLORS);
const [logoPath, setLogoPath] = useState<string | null>(null);
const [faviconPath, setFaviconPath] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -17,7 +25,7 @@ export default function DesignEditor() {
const load = useCallback(async () => {
const { data, error } = await supabase
.from("settings")
.select("colors, logo_path")
.select("colors, logo_path, favicon_path")
.eq("id", 1)
.maybeSingle();
if (error) {
@@ -26,6 +34,7 @@ export default function DesignEditor() {
}
setColors({ ...DEFAULT_COLORS, ...(data?.colors ?? {}) });
setLogoPath(data?.logo_path ?? null);
setFaviconPath(data?.favicon_path ?? null);
}, [supabase]);
useEffect(() => {
@@ -52,20 +61,21 @@ export default function DesignEditor() {
setColors(DEFAULT_COLORS);
}
async function uploadLogo(e: React.ChangeEvent<HTMLInputElement>) {
async function uploadAsset(kind: AssetKind, e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
if (!/^image\//.test(file.type)) {
setError("Bitte eine Bilddatei auswählen (PNG, JPG, SVG, WebP).");
setError("Bitte eine Bilddatei auswählen (PNG, JPG, SVG, WebP, ICO).");
return;
}
setBusy(true);
setError(null);
setInfo(null);
const oldPath = kind === "logo" ? logoPath : faviconPath;
const ext = file.name.split(".").pop()?.toLowerCase() ?? "png";
const path = `logo-${Date.now()}.${ext}`;
const path = `${kind}-${Date.now()}.${ext}`;
const { error: uploadError } = await supabase.storage
.from("branding")
.upload(path, file, { contentType: file.type });
@@ -75,46 +85,53 @@ export default function DesignEditor() {
return;
}
const oldPath = logoPath;
const { error: settingsError } = await supabase
.from("settings")
.update({ logo_path: path })
.update({ [ASSET_COLUMN[kind]]: path })
.eq("id", 1);
if (settingsError) {
setError(`Speichern fehlgeschlagen: ${settingsError.message}`);
setBusy(false);
return;
}
if (oldPath) {
await supabase.storage.from("branding").remove([oldPath]);
}
setLogoPath(path);
setInfo("Logo aktualisiert.");
if (oldPath) await supabase.storage.from("branding").remove([oldPath]);
if (kind === "logo") setLogoPath(path);
else setFaviconPath(path);
setInfo(kind === "logo" ? "Logo aktualisiert." : "Favicon aktualisiert.");
router.refresh();
setBusy(false);
}
async function removeLogo() {
if (!logoPath) return;
if (!window.confirm("Logo wirklich entfernen?")) return;
async function removeAsset(kind: AssetKind) {
const path = kind === "logo" ? logoPath : faviconPath;
if (!path) return;
if (
!window.confirm(
`${kind === "logo" ? "Logo" : "Favicon"} wirklich entfernen?`
)
)
return;
setBusy(true);
setError(null);
const { error } = await supabase
.from("settings")
.update({ logo_path: null })
.update({ [ASSET_COLUMN[kind]]: null })
.eq("id", 1);
if (error) {
setError(`Speichern fehlgeschlagen: ${error.message}`);
} else {
await supabase.storage.from("branding").remove([logoPath]);
setLogoPath(null);
setInfo("Logo entfernt.");
await supabase.storage.from("branding").remove([path]);
if (kind === "logo") setLogoPath(null);
else setFaviconPath(null);
setInfo(kind === "logo" ? "Logo entfernt." : "Favicon entfernt.");
router.refresh();
}
setBusy(false);
}
const currentLogo = logoUrl(logoPath);
const currentFavicon = faviconUrl(faviconPath);
return (
<div className="max-w-3xl space-y-6">
@@ -149,7 +166,7 @@ export default function DesignEditor() {
type="file"
accept="image/*"
className="hidden"
onChange={uploadLogo}
onChange={(e) => uploadAsset("logo", e)}
disabled={busy}
/>
</label>
@@ -157,7 +174,7 @@ export default function DesignEditor() {
<button
type="button"
className="btn btn-del"
onClick={removeLogo}
onClick={() => removeAsset("logo")}
disabled={busy}
>
Logo entfernen
@@ -166,6 +183,48 @@ export default function DesignEditor() {
</div>
</section>
<section className="card">
<h2 className="font-semibold mb-3">Favicon</h2>
<p className="text-sm text-gray-600 mb-3">
Wird als Icon im Browser-Tab angezeigt. Ohne eigenes Favicon gilt
das Standard-Icon der Anwendung.
</p>
{currentFavicon ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={currentFavicon}
alt="Aktuelles Favicon"
className="h-10 w-10 object-contain mb-3 border border-gray-200 rounded p-1"
/>
) : (
<p className="text-sm text-gray-400 mb-3">
Kein eigenes Favicon hinterlegt.
</p>
)}
<div className="flex gap-2 items-center">
<label className="btn btn-primary cursor-pointer">
Favicon hochladen
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => uploadAsset("favicon", e)}
disabled={busy}
/>
</label>
{currentFavicon && (
<button
type="button"
className="btn btn-del"
onClick={() => removeAsset("favicon")}
disabled={busy}
>
Favicon entfernen
</button>
)}
</div>
</section>
<section className="card">
<h2 className="font-semibold mb-3">Farbgebung</h2>
<div className="grid gap-3 sm:grid-cols-2">
+107 -45
View File
@@ -2,20 +2,31 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { formatPrice } from "@/lib/menu";
import type { Dish } from "@/lib/types";
import { dishCodes, formatPrice } from "@/lib/menu";
import type { CodeItem, Dish } from "@/lib/types";
import ChipPicker from "@/components/ChipPicker";
interface DishForm {
id: string | null; // null = neues Gericht
name: string;
price: string;
allergens: string[];
additives: string[];
}
const EMPTY_FORM: DishForm = { id: null, name: "", price: "" };
const EMPTY_FORM: DishForm = {
id: null,
name: "",
price: "",
allergens: [],
additives: [],
};
export default function DishManager() {
const supabase = useMemo(() => createClient(), []);
const [dishes, setDishes] = useState<Dish[]>([]);
const [allergens, setAllergens] = useState<CodeItem[]>([]);
const [additives, setAdditives] = useState<CodeItem[]>([]);
const [form, setForm] = useState<DishForm | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -30,12 +41,18 @@ export default function DishManager() {
}, [dishes, search]);
const load = useCallback(async () => {
const { data, error } = await supabase
.from("dishes")
.select("*")
.order("name");
if (error) setError(`Laden fehlgeschlagen: ${error.message}`);
else setDishes(data as Dish[]);
const [dishesRes, allergensRes, additivesRes] = await Promise.all([
supabase.from("dishes").select("*").order("name"),
supabase.from("allergens").select("*").order("sort"),
supabase.from("additives").select("*").order("sort"),
]);
const problem = dishesRes.error ?? allergensRes.error ?? additivesRes.error;
if (problem) setError(`Laden fehlgeschlagen: ${problem.message}`);
else {
setDishes(dishesRes.data as Dish[]);
setAllergens(allergensRes.data as CodeItem[]);
setAdditives(additivesRes.data as CodeItem[]);
}
setLoading(false);
}, [supabase]);
@@ -52,7 +69,12 @@ export default function DishManager() {
setError("Bitte einen gültigen Preis angeben, z. B. 3,50");
return;
}
const payload = { name: form.name.trim(), price };
const payload = {
name: form.name.trim(),
price,
allergens: form.allergens,
additives: form.additives,
};
const { error } = form.id
? await supabase.from("dishes").update(payload).eq("id", form.id)
: await supabase.from("dishes").insert(payload);
@@ -122,6 +144,26 @@ export default function DishManager() {
await load();
}
function toggleAllergen(code: string) {
if (!form) return;
setForm({
...form,
allergens: form.allergens.includes(code)
? form.allergens.filter((c) => c !== code)
: [...form.allergens, code],
});
}
function toggleAdditive(code: string) {
if (!form) return;
setForm({
...form,
additives: form.additives.includes(code)
? form.additives.filter((c) => c !== code)
: [...form.additives, code],
});
}
const allSelected =
visible.length > 0 && visible.every((d) => selected.has(d.id));
@@ -147,7 +189,7 @@ export default function DishManager() {
)}
{form && (
<form onSubmit={save} className="card mb-4 space-y-3">
<form onSubmit={save} className="card mb-4 space-y-4">
<h2 className="font-semibold">
{form.id ? "Gericht bearbeiten" : "Neues Gericht"}
</h2>
@@ -177,6 +219,18 @@ export default function DishManager() {
required
/>
</div>
<ChipPicker
label="Allergene"
items={allergens}
selected={form.allergens}
onToggle={toggleAllergen}
/>
<ChipPicker
label="Zusatzstoffe"
items={additives}
selected={form.additives}
onToggle={toggleAdditive}
/>
<div className="flex gap-2">
<button type="submit" className="btn btn-add">
Speichern
@@ -230,40 +284,48 @@ export default function DishManager() {
</div>
)}
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{visible.map((dish) => (
<li key={dish.id} className="flex items-center gap-3 px-4 py-2">
<input
type="checkbox"
checked={selected.has(dish.id)}
onChange={() => toggleOne(dish.id)}
aria-label={`${dish.name} auswählen`}
/>
<span className="mr-auto">
<span className="font-semibold">{dish.name}</span>{" "}
<span className="text-gray-500">– {formatPrice(dish.price)}</span>
</span>
<button
type="button"
className="btn btn-primary"
onClick={() =>
setForm({
id: dish.id,
name: dish.name,
price: String(dish.price).replace(".", ","),
})
}
>
Bearbeiten
</button>
<button
type="button"
className="btn btn-del"
onClick={() => remove(dish)}
>
Löschen
</button>
</li>
))}
{visible.map((dish) => {
const codes = dishCodes(dish);
return (
<li key={dish.id} className="flex items-center gap-3 px-4 py-2">
<input
type="checkbox"
checked={selected.has(dish.id)}
onChange={() => toggleOne(dish.id)}
aria-label={`${dish.name} auswählen`}
/>
<span className="mr-auto">
<span className="font-semibold">{dish.name}</span>
{codes && <sup className="dish-codes">{codes}</sup>}{" "}
<span className="text-gray-500">
– {formatPrice(dish.price)}
</span>
</span>
<button
type="button"
className="btn btn-primary"
onClick={() =>
setForm({
id: dish.id,
name: dish.name,
price: String(dish.price).replace(".", ","),
allergens: dish.allergens ?? [],
additives: dish.additives ?? [],
})
}
>
Bearbeiten
</button>
<button
type="button"
className="btn btn-del"
onClick={() => remove(dish)}
>
Löschen
</button>
</li>
);
})}
{dishes.length === 0 && (
<li className="px-4 py-3 text-gray-500">
Noch keine Gerichte angelegt.
+169
View File
@@ -0,0 +1,169 @@
"use client";
// Mobile Ansicht der öffentlichen Speisekarte: Tag (Standard) oder Woche,
// umschaltbar ohne Seiten-Reload. Ab dem "sm"-Breakpoint übernimmt die
// normale Wochentabelle (siehe app/page.tsx), diese Komponente blendet
// sich dort per CSS aus.
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import {
addDays,
dishCodes,
entriesFor,
formatDateLong,
formatPrice,
mondayOf,
todayISO,
weekdayIndex,
WEEKDAYS,
WEEKDAYS_SHORT,
} from "@/lib/menu";
import type { MenuEntry, Section } from "@/lib/types";
export default function PublicMobileMenu({
monday,
sections,
entries,
}: {
monday: string;
sections: Section[];
entries: MenuEntry[];
}) {
const router = useRouter();
const days = WEEKDAYS.map((weekday, i) => ({
weekday,
short: WEEKDAYS_SHORT[i],
iso: addDays(monday, i),
}));
const isCurrentWeek = monday === mondayOf(todayISO());
const todayIdx = isCurrentWeek ? weekdayIndex(todayISO()) : -1;
const [tab, setTab] = useState<"tag" | "woche">("tag");
const [selectedIdx, setSelectedIdx] = useState(todayIdx >= 0 ? todayIdx : 0);
function goToday() {
const currentMonday = mondayOf(todayISO());
if (currentMonday === monday) {
const idx = weekdayIndex(todayISO());
setSelectedIdx(idx >= 0 ? idx : 0);
setTab("tag");
} else {
router.push(`/?woche=${currentMonday}`);
}
}
function renderDay(day: { weekday: string; iso: string }) {
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)}
</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">{s.label}</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">–</p>
) : (
items.map((e) => {
const codes = dishCodes(e.dish);
return (
<p key={e.id} className="mb-1 last:mb-0">
<span className="font-semibold">{e.dish.name}</span>
{codes && <sup className="dish-codes">{codes}</sup>}{" "}
– {formatPrice(e.dish.price)}
</p>
);
})
)}
</div>
</div>
);
})}
</div>
</div>
);
}
return (
<div className="sm:hidden mb-6">
<div className="flex gap-2 mb-3">
<Link
href={`/?woche=${addDays(monday, -7)}`}
className="btn btn-add flex-1 justify-center"
>
← Woche
</Link>
<button
type="button"
onClick={goToday}
className="btn btn-primary flex-1"
>
Heute
</button>
<Link
href={`/?woche=${addDays(monday, 7)}`}
className="btn btn-add flex-1 justify-center"
>
Woche →
</Link>
</div>
<div className="flex gap-2 mb-3" role="tablist" aria-label="Ansicht">
<button
type="button"
role="tab"
aria-selected={tab === "tag"}
onClick={() => setTab("tag")}
className={`btn flex-1 ${tab === "tag" ? "btn-primary" : "btn-neutral"}`}
>
Tag
</button>
<button
type="button"
role="tab"
aria-selected={tab === "woche"}
onClick={() => setTab("woche")}
className={`btn flex-1 ${tab === "woche" ? "btn-primary" : "btn-neutral"}`}
>
Woche
</button>
</div>
{tab === "tag" ? (
<>
<div className="flex gap-1 mb-3">
{days.map((d, i) => (
<button
key={d.iso}
type="button"
onClick={() => setSelectedIdx(i)}
className={`flex-1 rounded-md py-1.5 text-sm font-semibold border transition-colors ${
i === selectedIdx
? "bg-[var(--c-primary)] text-white border-[var(--c-primary)]"
: "bg-white text-gray-600 border-gray-200"
} ${i === todayIdx ? "ring-2 ring-[var(--c-accent)] ring-offset-1" : ""}`}
>
{d.short}
</button>
))}
</div>
{renderDay(days[selectedIdx])}
</>
) : (
<div>{days.map((d) => renderDay(d))}</div>
)}
</div>
);
}
+26
View File
@@ -10,6 +10,7 @@ interface UserForm {
full_name: string;
role: Role;
password: string; // bei Bearbeitung optional (leer = unverändert)
passwordRepeat: string;
}
const EMPTY_FORM: UserForm = {
@@ -18,6 +19,7 @@ const EMPTY_FORM: UserForm = {
full_name: "",
role: "staff",
password: "",
passwordRepeat: "",
};
const ROLE_LABELS: Record<Role, string> = {
@@ -74,6 +76,10 @@ export default function UserManager() {
async function save(e: React.FormEvent) {
e.preventDefault();
if (!form) return;
if (form.password && form.password !== form.passwordRepeat) {
setError("Die Passwörter stimmen nicht überein.");
return;
}
const ok = form.id
? await callApi("PATCH", {
id: form.id,
@@ -186,6 +192,25 @@ export default function UserManager() {
autoComplete="new-password"
/>
</div>
{(!form.id || form.password) && (
<div className="max-w-60">
<label className="label" htmlFor="user-password-repeat">
Passwort wiederholen
</label>
<input
id="user-password-repeat"
type="password"
className="input"
value={form.passwordRepeat}
onChange={(e) =>
setForm({ ...form, passwordRepeat: e.target.value })
}
minLength={8}
required={!form.id || !!form.password}
autoComplete="new-password"
/>
</div>
)}
<div className="flex gap-2">
<button type="submit" className="btn btn-add" disabled={busy}>
Speichern
@@ -221,6 +246,7 @@ export default function UserManager() {
full_name: p.full_name,
role: p.role,
password: "",
passwordRepeat: "",
})
}
>
+25 -7
View File
@@ -3,7 +3,16 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { addDays, formatDateLong, formatPrice, WEEKDAYS } from "@/lib/menu";
import {
addDays,
dishCodes,
entriesFor,
formatDateLong,
formatPrice,
mondayOf,
todayISO,
WEEKDAYS,
} from "@/lib/menu";
import type { Dish, MenuEntry, Section } from "@/lib/types";
export default function WeekEditor({ monday }: { monday: string }) {
@@ -32,7 +41,9 @@ export default function WeekEditor({ monday }: { monday: string }) {
supabase.from("dishes").select("*").order("name"),
supabase
.from("menu_entries")
.select("id, menu_date, section_key, position, dish:dishes(id, name, price)")
.select(
"id, menu_date, section_key, position, dish:dishes(id, name, price, allergens, additives)"
)
.gte("menu_date", monday)
.lte("menu_date", friday)
.order("position")
@@ -79,9 +90,6 @@ export default function WeekEditor({ monday }: { monday: string }) {
await load();
}
const cell = (iso: string, sectionKey: string) =>
entries.filter((e) => e.menu_date === iso && e.section_key === sectionKey);
return (
<div>
<div className="flex flex-wrap items-center gap-3 mb-4">
@@ -91,6 +99,12 @@ export default function WeekEditor({ monday }: { monday: string }) {
<Link href={`/intern?woche=${addDays(monday, -7)}`} className="btn btn-add">
← Vorherige Woche
</Link>
<Link
href={`/intern?woche=${mondayOf(todayISO())}`}
className="btn btn-primary"
>
Heute
</Link>
<Link href={`/intern?woche=${addDays(monday, 7)}`} className="btn btn-add">
Nächste Woche →
</Link>
@@ -129,9 +143,12 @@ export default function WeekEditor({ monday }: { monday: string }) {
adding?.iso === d.iso && adding?.section === s.key;
return (
<td key={d.iso} className="cell">
{cell(d.iso, s.key).map((e) => (
{entriesFor(entries, d.iso, s.key).map((e) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish">
<span className="font-bold">{e.dish.name}</span>
{codes && <sup className="dish-codes">{codes}</sup>}
{" – "}
{formatPrice(e.dish.price)}
<div className="mt-1">
@@ -144,7 +161,8 @@ export default function WeekEditor({ monday }: { monday: string }) {
</button>
</div>
</div>
))}
);
})}
{isAdding ? (
<div className="mt-2 space-y-1 text-left">
<select
+3
View File
@@ -34,6 +34,8 @@ services:
# Wird für den CSRF-Origin-Check genutzt; dieselbe URL ist die
# Passkey-Domain (siehe scripts/configure-passkeys.mjs).
APP_URL: ${APP_URL:-http://localhost:3000}
# Titel im Browser-Tab und Überschrift, solange kein Logo hochgeladen ist.
SITE_TITLE: ${SITE_TITLE:-Canteen Menu}
ports:
- "0.0.0.0:3000:3000"
restart: unless-stopped
@@ -68,6 +70,7 @@ services:
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
APP_URL: ${APP_URL:-http://localhost:3000}
SITE_TITLE: ${SITE_TITLE:-Canteen Menu}
ports:
- "0.0.0.0:3000:3000"
volumes:
+28
View File
@@ -2,6 +2,8 @@
// Alle Berechnungen laufen über UTC, damit Server und Browser
// unabhängig von der Zeitzone dieselbe Woche ermitteln.
import type { MenuEntry } from "./types";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
function toDate(iso: string): Date {
@@ -31,7 +33,19 @@ export function addDays(iso: string, days: number): string {
return toISO(d);
}
/** Heutiges Datum als YYYY-MM-DD (UTC). */
export function todayISO(): string {
return toISO(new Date());
}
/** Index (0=Montag…4=Freitag) von `iso` innerhalb seiner Woche, sonst -1 (Wochenende). */
export function weekdayIndex(iso: string): number {
const day = (toDate(iso).getUTCDay() + 6) % 7;
return day <= 4 ? day : -1;
}
export const WEEKDAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"];
export const WEEKDAYS_SHORT = ["Mo", "Di", "Mi", "Do", "Fr"];
/** z. B. "13. Juli 2026" */
export function formatDateLong(iso: string): string {
@@ -47,3 +61,17 @@ export function formatDateLong(iso: string): string {
export function formatPrice(price: number | string): string {
return Number(price).toFixed(2).replace(".", ",") + "€";
}
/** Allergen-/Zusatzstoff-Kürzel eines Gerichts, z. B. "A,G,2" */
export function dishCodes(dish: { allergens: string[]; additives: string[] }): string {
return [...(dish.allergens ?? []), ...(dish.additives ?? [])].join(",");
}
/** Einträge eines Tages/Bereichs aus der geladenen Wochenliste herausfiltern. */
export function entriesFor(
entries: MenuEntry[],
iso: string,
sectionKey: string
): MenuEntry[] {
return entries.filter((e) => e.menu_date === iso && e.section_key === sectionKey);
}
+10
View File
@@ -5,6 +5,7 @@ export interface Profile {
email: string;
full_name: string;
role: Role;
password_auth_enabled: boolean;
created_at: string;
}
@@ -19,6 +20,8 @@ export interface Dish {
id: string;
name: string;
price: number;
allergens: string[];
additives: string[];
}
export interface MenuEntry {
@@ -73,3 +76,10 @@ export function logoUrl(path: string | null | undefined): string | null {
if (!path) return null;
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/branding/${path}`;
}
export function faviconUrl(path: string | null | undefined): string | null {
if (!path) return null;
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/branding/${path}`;
}
export const SITE_TITLE = process.env.SITE_TITLE?.trim() || "Canteen Menu";
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "canteen-menu",
"version": "1.1.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "canteen-menu",
"version": "1.1.0",
"version": "1.2.0",
"dependencies": {
"@supabase/ssr": "^0.12.3",
"@supabase/supabase-js": "^2.110.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canteen-menu",
"version": "1.1.0",
"version": "1.2.0",
"private": true,
"scripts": {
"dev": "next dev -H 0.0.0.0",

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+9 -6
View File
@@ -2,6 +2,7 @@
-- PostgreSQL database dump
--
-- Dumped from database version 17.6
-- Dumped by pg_dump version 17.10
@@ -21,9 +22,6 @@ SET row_security = off;
-- Name: public; Type: SCHEMA; Schema: -; Owner: -
--
-- Das "public"-Schema existiert im offiziellen postgres-Image bereits;
-- CREATE SCHEMA würde dort mit ON_ERROR_STOP=1 das komplette Init-Skript
-- abbrechen.
CREATE SCHEMA IF NOT EXISTS public;
@@ -132,7 +130,9 @@ CREATE TABLE public.dishes (
name text NOT NULL,
price numeric(6,2) DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
updated_at timestamp with time zone DEFAULT now() NOT NULL,
allergens text[] DEFAULT '{}'::text[] NOT NULL,
additives text[] DEFAULT '{}'::text[] NOT NULL
);
@@ -160,6 +160,7 @@ CREATE TABLE public.profiles (
full_name text DEFAULT ''::text NOT NULL,
role text DEFAULT 'staff'::text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
password_auth_enabled boolean DEFAULT true NOT NULL,
CONSTRAINT profiles_role_check CHECK ((role = ANY (ARRAY['admin'::text, 'staff'::text])))
);
@@ -185,6 +186,7 @@ CREATE TABLE public.settings (
colors jsonb DEFAULT '{}'::jsonb NOT NULL,
logo_path text,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
favicon_path text,
CONSTRAINT settings_id_check CHECK ((id = 1))
);
@@ -392,10 +394,10 @@ CREATE POLICY profiles_select_own_or_admin ON public.profiles FOR SELECT TO auth
--
-- Name: profiles profiles_update_own_name; Type: POLICY; Schema: public; Owner: -
-- Name: profiles profiles_update_own; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY profiles_update_own_name ON public.profiles FOR UPDATE TO authenticated USING ((id = ( SELECT auth.uid() AS uid))) WITH CHECK ((id = ( SELECT auth.uid() AS uid)));
CREATE POLICY profiles_update_own ON public.profiles FOR UPDATE TO authenticated USING ((id = ( SELECT auth.uid() AS uid))) WITH CHECK ((id = ( SELECT auth.uid() AS uid)));
--
@@ -435,3 +437,4 @@ CREATE POLICY settings_public_read ON public.settings FOR SELECT TO authenticate
-- PostgreSQL database dump complete
--
+28 -28
View File
@@ -2,7 +2,7 @@
-- PostgreSQL database dump
--
\restrict HfcGijNcMcy9jPlPpFachgl8LfchhPK39kzgfhc84Q6jsOJpHN3jqf6T368FXri
\restrict WmQEI7C1lM8AheyDeBFMpJzZHr1ZhZm5NphQHzcUeS7IAzg73qb94aso6w4qDad
-- Dumped from database version 17.6
-- Dumped by pg_dump version 17.10
@@ -76,25 +76,24 @@ ALTER TABLE public.allergens ENABLE TRIGGER ALL;
ALTER TABLE public.dishes DISABLE TRIGGER ALL;
COPY public.dishes (id, name, price, created_at, updated_at) FROM stdin;
2b136266-a80d-4c76-8520-38001c92a634 1 Stk. Rindskäsewurst mit Brötchen 2.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
bf612ae7-8cdd-4149-8b21-f651631ccc02 Hackfleischbaguette 3.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
81c58cc6-7298-4d4a-9997-71d17df5aedf Knusperhähnchen Wrap 3.70 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 Leberkäse im Brötchen 2.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
a987c96f-25a0-4e5e-b8a8-aab683a01feb Chili con Carne mit Brötchen 3.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
14ef6893-93a7-4649-afe3-d6b6bdf5903c Schnitzel Baguette 3.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
ec3c5e37-9cbc-4c92-ab05-1e3fb942c504 Penne mit Grünen Spargel und Garnelenspieße 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
dbe5ee6e-8b04-4bf7-99bb-f936f6564217 Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
c0613fe7-e5c6-489e-afe5-6da2533b2bd4 Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
c5cfa5b4-e502-4b56-b579-b9fbbf817f25 Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
f8ba1844-1f23-4b23-9e03-f59148f8b2ed Spaghetti mit fruchtiger Tomatensauce und Parmesan 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
7a6fed05-e0f5-4e97-bdd4-a46c5ef1376a Gebackener Feta mit griechischen Bauernsalat und Baguette 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
ff40ea65-63bc-4d01-b626-e0c9e5d2acb3 Gemüse-Curry mit Basmatireis 4.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
ea26f18b-ddf3-4899-95e7-5dbd0878d90c Kartoffelcremesuppe 1.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 Gulaschsuppe 2.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
3c8cf0c2-b0a9-43e3-8233-723d5718a157 Tomatensuppe mit Croutons 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
2dd85835-5425-4271-9fc0-e5215bbce476 Kürbiscremesuppe 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
06df753a-65ed-4bd0-a325-d32c78ec88fa Linseneintopf mit Würstchen 2.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
COPY public.dishes (id, name, price, created_at, updated_at, allergens, additives) FROM stdin;
bf612ae7-8cdd-4149-8b21-f651631ccc02 Hackfleischbaguette 3.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
81c58cc6-7298-4d4a-9997-71d17df5aedf Knusperhähnchen Wrap 3.70 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 Leberkäse im Brötchen 2.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
14ef6893-93a7-4649-afe3-d6b6bdf5903c Schnitzel Baguette 3.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
ec3c5e37-9cbc-4c92-ab05-1e3fb942c504 Penne mit Grünen Spargel und Garnelenspieße 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
dbe5ee6e-8b04-4bf7-99bb-f936f6564217 Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
c0613fe7-e5c6-489e-afe5-6da2533b2bd4 Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
c5cfa5b4-e502-4b56-b579-b9fbbf817f25 Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
f8ba1844-1f23-4b23-9e03-f59148f8b2ed Spaghetti mit fruchtiger Tomatensauce und Parmesan 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
7a6fed05-e0f5-4e97-bdd4-a46c5ef1376a Gebackener Feta mit griechischen Bauernsalat und Baguette 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
ff40ea65-63bc-4d01-b626-e0c9e5d2acb3 Gemüse-Curry mit Basmatireis 4.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
ea26f18b-ddf3-4899-95e7-5dbd0878d90c Kartoffelcremesuppe 1.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 Gulaschsuppe 2.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
3c8cf0c2-b0a9-43e3-8233-723d5718a157 Tomatensuppe mit Croutons 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
2dd85835-5425-4271-9fc0-e5215bbce476 Kürbiscremesuppe 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
06df753a-65ed-4bd0-a325-d32c78ec88fa Linseneintopf mit Würstchen 2.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
a987c96f-25a0-4e5e-b8a8-aab683a01feb Chili con Carne mit Brötchen 3.90 2026-07-17 15:17:02.979184+00 2026-07-17 22:21:35.276672+00 {A} {4}
\.
@@ -123,7 +122,6 @@ ALTER TABLE public.sections ENABLE TRIGGER ALL;
ALTER TABLE public.menu_entries DISABLE TRIGGER ALL;
COPY public.menu_entries (id, menu_date, section_key, dish_id, "position", created_at) FROM stdin;
5d1ea849-61b3-48ac-b1e9-48b6b2c7d8c1 2026-07-13 brotzeit 2b136266-a80d-4c76-8520-38001c92a634 0 2026-07-17 15:17:02.979184+00
70469091-417b-47ed-aab5-747ff97e4ad4 2026-07-14 brotzeit bf612ae7-8cdd-4149-8b21-f651631ccc02 0 2026-07-17 15:17:02.979184+00
1e1f9b4e-75e7-413f-a747-c2884675d9f7 2026-07-15 brotzeit 81c58cc6-7298-4d4a-9997-71d17df5aedf 0 2026-07-17 15:17:02.979184+00
7b2089f7-7c01-4d6a-9e68-ebfa594bb713 2026-07-16 brotzeit cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 0 2026-07-17 15:17:02.979184+00
@@ -140,7 +138,6 @@ e2bfe62e-12c4-4f68-a7e7-ca1ee30a6039 2026-07-14 suppe 6222d5f4-05e3-4af4-a41c-3f
662902e0-bb5e-4268-b977-cef2ac20dc35 2026-07-15 suppe 3c8cf0c2-b0a9-43e3-8233-723d5718a157 0 2026-07-17 15:17:02.979184+00
6083a3f3-a54e-40c5-be1f-c96e474eb8a0 2026-07-16 suppe 2dd85835-5425-4271-9fc0-e5215bbce476 0 2026-07-17 15:17:02.979184+00
36279cd1-b0e2-46a8-b46c-c23f9f5dbb1b 2026-07-17 suppe 06df753a-65ed-4bd0-a325-d32c78ec88fa 0 2026-07-17 15:17:02.979184+00
ae3f7ced-23b1-42a9-a5d4-2bfab6c333b7 2026-07-22 brotzeit 2b136266-a80d-4c76-8520-38001c92a634 0 2026-07-17 15:17:02.979184+00
bfa36cb8-82d6-466c-b954-bf82c6a67b95 2026-07-23 brotzeit bf612ae7-8cdd-4149-8b21-f651631ccc02 0 2026-07-17 15:17:02.979184+00
b86caf93-34eb-46a3-bbe4-3a67dcf79be0 2026-07-21 brotzeit 81c58cc6-7298-4d4a-9997-71d17df5aedf 0 2026-07-17 15:17:02.979184+00
d8a962cd-f1cf-4f87-95fb-f5af79cbd0a5 2026-07-20 brotzeit cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 0 2026-07-17 15:17:02.979184+00
@@ -158,6 +155,7 @@ a6f66639-7a0a-4129-bcea-c6ac9cbccac3 2026-07-20 suppe 6222d5f4-05e3-4af4-a41c-3f
b0dbf487-c704-4ab2-af67-082addc88039 2026-07-23 suppe 3c8cf0c2-b0a9-43e3-8233-723d5718a157 0 2026-07-17 15:17:02.979184+00
d7b5d414-85c2-495a-9945-2ffc10de32ea 2026-07-24 suppe 2dd85835-5425-4271-9fc0-e5215bbce476 0 2026-07-17 15:17:02.979184+00
7a025e66-44b7-43e7-a51b-5564ab8150b1 2026-07-22 suppe 06df753a-65ed-4bd0-a325-d32c78ec88fa 0 2026-07-17 15:17:02.979184+00
2eb81b52-3c50-4449-bef9-bb12aac9991e 2026-07-13 brotzeit 6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 0 2026-07-17 15:43:22.829239+00
\.
@@ -169,9 +167,11 @@ ALTER TABLE public.menu_entries ENABLE TRIGGER ALL;
ALTER TABLE public.profiles DISABLE TRIGGER ALL;
COPY public.profiles (id, email, full_name, role, created_at) FROM stdin;
cee4160a-ab3a-4109-b706-b9f27d515596 fw@wildeflorian.de Florian Wilde admin 2026-07-17 15:17:33.46696+00
102d405c-b010-41f6-9409-244004a3c567 kantine@example.com Kantinen-Team staff 2026-07-17 15:17:34.233848+00
COPY public.profiles (id, email, full_name, role, created_at, password_auth_enabled) FROM stdin;
cee4160a-ab3a-4109-b706-b9f27d515596 fw@wildeflorian.de Florian Wilde admin 2026-07-17 15:17:33.46696+00 t
6e3ebbd4-a00a-4b08-a799-1a445a3f9eaa test@test.de Hans admin 2026-07-17 16:56:13.331443+00 t
102d405c-b010-41f6-9409-244004a3c567 kantine@example.com Kantinen-Team staff 2026-07-17 15:17:34.233848+00 t
952b21ad-65a0-4a12-aed1-6c86709e0eb2 kantine@kantine.de Kantine staff 2026-07-17 22:02:36.854844+00 t
\.
@@ -183,8 +183,8 @@ ALTER TABLE public.profiles ENABLE TRIGGER ALL;
ALTER TABLE public.settings DISABLE TRIGGER ALL;
COPY public.settings (id, colors, logo_path, updated_at) FROM stdin;
1 {"rowBg": "#e3e4f1", "accent": "#ff6900", "addBtn": "#6da54c", "primary": "#003388", "rowBgAlt": "#f4f4fa", "deleteBtn": "#e2371d", "headerText": "#ffffff", "primaryDark": "#0d315c"} \N 2026-07-17 15:39:01.866456+00
COPY public.settings (id, colors, logo_path, updated_at, favicon_path) FROM stdin;
1 {"rowBg": "#e3e4f1", "accent": "#ff6900", "addBtn": "#6da54c", "primary": "#003388", "rowBgAlt": "#f4f4fa", "deleteBtn": "#e2371d", "headerText": "#ffffff", "primaryDark": "#0d315c"} logo-1784305413466.webp 2026-07-17 22:40:52.997491+00 \N
\.
@@ -194,5 +194,5 @@ ALTER TABLE public.settings ENABLE TRIGGER ALL;
-- PostgreSQL database dump complete
--
\unrestrict HfcGijNcMcy9jPlPpFachgl8LfchhPK39kzgfhc84Q6jsOJpHN3jqf6T368FXri
\unrestrict WmQEI7C1lM8AheyDeBFMpJzZHr1ZhZm5NphQHzcUeS7IAzg73qb94aso6w4qDad
@@ -0,0 +1,32 @@
-- =============================================================
-- Allergene/Zusatzstoffe pro Gericht, Passwort-Login abschaltbar,
-- austauschbares Favicon
-- =============================================================
-- ---------- Allergene/Zusatzstoffe je Gericht ----------
-- Referenziert Kürzel aus den globalen Listen (allergens/additives),
-- die weiterhin unterhalb der Speisekarte als Legende dienen.
alter table public.dishes
add column allergens text[] not null default '{}',
add column additives text[] not null default '{}';
-- ---------- Passwort-Login pro Benutzer abschaltbar ----------
alter table public.profiles
add column password_auth_enabled boolean not null default true;
-- Selbstverwaltung: Nutzer dürfen zusätzlich zum Namen (Migration 0003)
-- jetzt auch dieses eine Flag für ihr eigenes Konto setzen. Weiterhin
-- kein Zugriff auf "role" oder "email" über diesen Weg.
grant update (password_auth_enabled) on public.profiles to authenticated;
-- Klarerer Name für die inzwischen zwei selbstverwaltbaren Spalten.
drop policy "profiles_update_own_name" on public.profiles;
create policy "profiles_update_own"
on public.profiles for update
to authenticated
using (id = (select auth.uid()))
with check (id = (select auth.uid()));
-- ---------- Austauschbares Favicon ----------
alter table public.settings
add column favicon_path text;