v1.6: RSS-Feed für den Speiseplan + Badge für offene Rezeptvorschläge
app/feed.xml: RSS-2.0-Feed mit einem Eintrag pro Tag (aktuelle + nächste Woche), Gerichte je Essenszeit im CDATA-Beschreibungstext. Immer auf Deutsch, da Feedreader kein Sprach-Cookie mitschicken. Autodiscovery-Link im <head> (app/layout.tsx) sowie sichtbares RSS-Icon im Header der öffentlichen Speisekarte (neben Sprachumschalter/ Anmelden – die einzige Stelle, die auf allen Bildschirmgrößen sichtbar ist). AppHeader: "Vorschläge"-Navigationspunkt zeigt jetzt die Anzahl offener Rezeptvorschläge als Zahl im roten Kreis (GET /api/dish-suggestions, bereits staff/admin-beschränkt), aktualisiert sich bei Navigation innerhalb des internen Bereichs.
This commit is contained in:
@@ -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<Section>("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) =>
|
||||
`<li>${escapeXml(localized(e.dish, "de"))} – ${formatPrice(e.dish.price)}</li>`
|
||||
)
|
||||
.join("");
|
||||
return `<p><strong>${escapeXml(localized(s, "de"))} (${escapeXml(
|
||||
s.time_range
|
||||
)})</strong></p><ul>${list}</ul>`;
|
||||
})
|
||||
.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 ` <item>
|
||||
<title>${escapeXml(formatFeedDate(iso))}</title>
|
||||
<link>${appUrl}/?woche=${mondayOf(iso)}</link>
|
||||
<guid isPermaLink="false">${appUrl}/feed.xml#${iso}</guid>
|
||||
<pubDate>${pubDate}</pubDate>
|
||||
<description><![CDATA[${dayContent}]]></description>
|
||||
</item>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>${escapeXml(SITE_TITLE)} – Speiseplan</title>
|
||||
<link>${appUrl}</link>
|
||||
<description>Täglicher Speiseplan der Kantine</description>
|
||||
<language>de</language>
|
||||
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
|
||||
${items}
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
"Content-Type": "application/rss+xml; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=1800",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,14 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
// 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` }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,25 @@ export default async function Home({
|
||||
<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)]"
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Role } from "@/lib/types";
|
||||
import { formatMessage } from "@/lib/i18n/shared";
|
||||
import { useDict } from "@/components/i18n/LocaleProvider";
|
||||
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
|
||||
|
||||
@@ -20,6 +22,14 @@ export default function AppHeader({
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const dict = useDict();
|
||||
const [suggestionCount, setSuggestionCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
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}
|
||||
</span>
|
||||
{menuLinks.map((l) => (
|
||||
<Link key={l.href} href={l.href} className={linkClass(l.href)}>
|
||||
<Link key={l.href} href={l.href} className={`relative ${linkClass(l.href)}`}>
|
||||
{l.label}
|
||||
{l.href === "/intern/vorschlaege" && suggestionCount > 0 && (
|
||||
<span
|
||||
className="absolute -top-1.5 -right-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--c-del)] px-1 text-[10px] font-bold leading-none text-white"
|
||||
aria-label={formatMessage(dict.header.openSuggestions, {
|
||||
count: suggestionCount,
|
||||
})}
|
||||
>
|
||||
{suggestionCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user