diff --git a/app/feed.xml/route.ts b/app/feed.xml/route.ts new file mode 100644 index 0000000..7e36011 --- /dev/null +++ b/app/feed.xml/route.ts @@ -0,0 +1,106 @@ +import { query } from "@/lib/db"; +import { loadMenuEntries } from "@/lib/db/menu-entries"; +import { addDays, entriesFor, formatPrice, mondayOf, todayISO } from "@/lib/menu"; +import { localized, SITE_TITLE } from "@/lib/types"; +import type { Section } from "@/lib/types"; + +// RSS-Feed mit dem Speiseplan – ein Eintrag pro Tag mit Gerichten, +// damit man ihn in einem Feedreader abonnieren kann und täglich sieht, +// was es gibt. Immer auf Deutsch: Feedreader senden kein Sprach-Cookie, +// eine feste Sprache ist hier verständlicher als ein zufälliger Locale-Default. +export const dynamic = "force-dynamic"; + +function escapeXml(text: string): string { + return text.replace(/[&<>"']/g, (c) => { + switch (c) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +function formatFeedDate(iso: string): string { + const d = new Date(`${iso}T00:00:00Z`); + return d.toLocaleDateString("de-DE", { + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", + timeZone: "UTC", + }); +} + +export async function GET() { + const appUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, ""); + const monday = mondayOf(todayISO()); + // Aktuelle + nächste Woche, wie auch die öffentliche Speisekarte per + // Wochennavigation anzeigt. + const rangeEnd = addDays(monday, 11); + + const [sections, entries] = await Promise.all([ + query
("select * from sections order by sort"), + loadMenuEntries(monday, rangeEnd), + ]); + + const days = Array.from(new Set(entries.map((e) => e.menu_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) => + `
  • ${escapeXml(localized(e.dish, "de"))} – ${formatPrice(e.dish.price)}
  • ` + ) + .join(""); + return `

    ${escapeXml(localized(s, "de"))} (${escapeXml( + s.time_range + )})

    `; + }) + .join(""); + if (!dayContent) return ""; + + const [y, m, d] = iso.split("-").map(Number); + const pubDate = new Date(Date.UTC(y, m - 1, d, 6, 0, 0)).toUTCString(); + + return ` + ${escapeXml(formatFeedDate(iso))} + ${appUrl}/?woche=${mondayOf(iso)} + ${appUrl}/feed.xml#${iso} + ${pubDate} + + `; + }) + .join("\n"); + + const xml = ` + + + ${escapeXml(SITE_TITLE)} – Speiseplan + ${appUrl} + Täglicher Speiseplan der Kantine + de + ${new Date().toUTCString()} +${items} + + +`; + + return new Response(xml, { + headers: { + "Content-Type": "application/rss+xml; charset=utf-8", + "Cache-Control": "public, max-age=1800", + }, + }); +} diff --git a/app/layout.tsx b/app/layout.tsx index 171e108..f90916a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -25,6 +25,14 @@ export async function generateMetadata(): Promise { // einem eigenen Favicon eingebunden (Next.js kombiniert Datei- // Konvention und Metadata statt sie zu ersetzen). icons: { icon: faviconUrl(settings?.favicon_path) ?? "/favicon.ico" }, + // Autodiscovery für Feedreader (Browser zeigen dafür kein eigenes + // Icon mehr an, daher zusätzlich ein sichtbares RSS-Icon im Header + // der öffentlichen Speisekarte, s. app/page.tsx). + alternates: { + types: { + "application/rss+xml": [{ url: "/feed.xml", title: `${SITE_TITLE} – Speiseplan` }], + }, + }, }; } diff --git a/app/page.tsx b/app/page.tsx index 1948bc4..afd70d9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -56,6 +56,25 @@ export default async function Home({
    + + + { + fetch("/api/dish-suggestions") + .then((res) => (res.ok ? res.json() : [])) + .then((data) => setSuggestionCount(Array.isArray(data) ? data.length : 0)) + .catch(() => {}); + }, [pathname]); const menuLinks = [ { href: "/intern", label: dict.header.weekPlan }, @@ -51,8 +61,18 @@ export default function AppHeader({ {dict.header.menuGroup} {menuLinks.map((l) => ( - + {l.label} + {l.href === "/intern/vorschlaege" && suggestionCount > 0 && ( + + {suggestionCount} + + )} ))} diff --git a/lib/i18n/dictionaries.ts b/lib/i18n/dictionaries.ts index d8c81a9..a6f0d2b 100644 --- a/lib/i18n/dictionaries.ts +++ b/lib/i18n/dictionaries.ts @@ -37,6 +37,7 @@ const de = { users: "Benutzer", design: "Design", suggestions: "Vorschläge", + openSuggestions: "{count} offene Rezeptvorschläge", viewMenu: "Speisekarte ansehen", myAccount: "Mein Konto", }, @@ -64,6 +65,7 @@ const de = { additives: "Zusatzstoffe", suggestDish: "Rezept vorschlagen", noEntry: "–", + rssFeed: "RSS-Feed abonnieren", }, weekEditor: { title: "Wochenplan", @@ -291,6 +293,7 @@ const en: Dict = { users: "Users", design: "Design", suggestions: "Suggestions", + openSuggestions: "{count} open dish suggestions", viewMenu: "View menu", myAccount: "My account", }, @@ -318,6 +321,7 @@ const en: Dict = { additives: "Additives", suggestDish: "Suggest a dish", noEntry: "–", + rssFeed: "Subscribe to RSS feed", }, weekEditor: { title: "Weekly plan", @@ -543,6 +547,7 @@ const pl: Dict = { users: "Użytkownicy", design: "Wygląd", suggestions: "Propozycje", + openSuggestions: "{count} otwartych propozycji dań", viewMenu: "Zobacz jadłospis", myAccount: "Moje konto", }, @@ -570,6 +575,7 @@ const pl: Dict = { additives: "Dodatki", suggestDish: "Zaproponuj danie", noEntry: "–", + rssFeed: "Subskrybuj kanał RSS", }, weekEditor: { title: "Plan tygodnia",