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-
|
// einem eigenen Favicon eingebunden (Next.js kombiniert Datei-
|
||||||
// Konvention und Metadata statt sie zu ersetzen).
|
// Konvention und Metadata statt sie zu ersetzen).
|
||||||
icons: { icon: faviconUrl(settings?.favicon_path) ?? "/favicon.ico" },
|
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">
|
<div className="hidden sm:block">
|
||||||
<LanguageSwitcher variant="light" />
|
<LanguageSwitcher variant="light" />
|
||||||
</div>
|
</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
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="text-sm text-gray-500 hover:text-[var(--c-primary)]"
|
className="text-sm text-gray-500 hover:text-[var(--c-primary)]"
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import type { Role } from "@/lib/types";
|
import type { Role } from "@/lib/types";
|
||||||
|
import { formatMessage } from "@/lib/i18n/shared";
|
||||||
import { useDict } from "@/components/i18n/LocaleProvider";
|
import { useDict } from "@/components/i18n/LocaleProvider";
|
||||||
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
|
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
|
||||||
|
|
||||||
@@ -20,6 +22,14 @@ export default function AppHeader({
|
|||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const dict = useDict();
|
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 = [
|
const menuLinks = [
|
||||||
{ href: "/intern", label: dict.header.weekPlan },
|
{ href: "/intern", label: dict.header.weekPlan },
|
||||||
@@ -51,8 +61,18 @@ export default function AppHeader({
|
|||||||
{dict.header.menuGroup}
|
{dict.header.menuGroup}
|
||||||
</span>
|
</span>
|
||||||
{menuLinks.map((l) => (
|
{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.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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const de = {
|
|||||||
users: "Benutzer",
|
users: "Benutzer",
|
||||||
design: "Design",
|
design: "Design",
|
||||||
suggestions: "Vorschläge",
|
suggestions: "Vorschläge",
|
||||||
|
openSuggestions: "{count} offene Rezeptvorschläge",
|
||||||
viewMenu: "Speisekarte ansehen",
|
viewMenu: "Speisekarte ansehen",
|
||||||
myAccount: "Mein Konto",
|
myAccount: "Mein Konto",
|
||||||
},
|
},
|
||||||
@@ -64,6 +65,7 @@ const de = {
|
|||||||
additives: "Zusatzstoffe",
|
additives: "Zusatzstoffe",
|
||||||
suggestDish: "Rezept vorschlagen",
|
suggestDish: "Rezept vorschlagen",
|
||||||
noEntry: "–",
|
noEntry: "–",
|
||||||
|
rssFeed: "RSS-Feed abonnieren",
|
||||||
},
|
},
|
||||||
weekEditor: {
|
weekEditor: {
|
||||||
title: "Wochenplan",
|
title: "Wochenplan",
|
||||||
@@ -291,6 +293,7 @@ const en: Dict = {
|
|||||||
users: "Users",
|
users: "Users",
|
||||||
design: "Design",
|
design: "Design",
|
||||||
suggestions: "Suggestions",
|
suggestions: "Suggestions",
|
||||||
|
openSuggestions: "{count} open dish suggestions",
|
||||||
viewMenu: "View menu",
|
viewMenu: "View menu",
|
||||||
myAccount: "My account",
|
myAccount: "My account",
|
||||||
},
|
},
|
||||||
@@ -318,6 +321,7 @@ const en: Dict = {
|
|||||||
additives: "Additives",
|
additives: "Additives",
|
||||||
suggestDish: "Suggest a dish",
|
suggestDish: "Suggest a dish",
|
||||||
noEntry: "–",
|
noEntry: "–",
|
||||||
|
rssFeed: "Subscribe to RSS feed",
|
||||||
},
|
},
|
||||||
weekEditor: {
|
weekEditor: {
|
||||||
title: "Weekly plan",
|
title: "Weekly plan",
|
||||||
@@ -543,6 +547,7 @@ const pl: Dict = {
|
|||||||
users: "Użytkownicy",
|
users: "Użytkownicy",
|
||||||
design: "Wygląd",
|
design: "Wygląd",
|
||||||
suggestions: "Propozycje",
|
suggestions: "Propozycje",
|
||||||
|
openSuggestions: "{count} otwartych propozycji dań",
|
||||||
viewMenu: "Zobacz jadłospis",
|
viewMenu: "Zobacz jadłospis",
|
||||||
myAccount: "Moje konto",
|
myAccount: "Moje konto",
|
||||||
},
|
},
|
||||||
@@ -570,6 +575,7 @@ const pl: Dict = {
|
|||||||
additives: "Dodatki",
|
additives: "Dodatki",
|
||||||
suggestDish: "Zaproponuj danie",
|
suggestDish: "Zaproponuj danie",
|
||||||
noEntry: "–",
|
noEntry: "–",
|
||||||
|
rssFeed: "Subskrybuj kanał RSS",
|
||||||
},
|
},
|
||||||
weekEditor: {
|
weekEditor: {
|
||||||
title: "Plan tygodnia",
|
title: "Plan tygodnia",
|
||||||
|
|||||||
Reference in New Issue
Block a user