feat(termin): Termin-Detail unter /kalender/termin/<slug>, 302 von alt
Route /termin/[slug] -> /kalender/termin/[slug] verschoben, alte URL 302-redirect (QR-Codes/geteilte Links bleiben gueltig). Breadcrumb 3-stufig (Start > Kalender > Titel). QR-/Detail-/OG-Links auf neuen Pfad. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { getCalendarItemBySlug } from "$lib/cms";
|
||||
import { marked } from "$lib/markdown-safe";
|
||||
import { env } from "$env/dynamic/public";
|
||||
|
||||
/**
|
||||
* Eigenständige Detail-Seite pro Termin: alle Infos + Aktionen ungekürzt,
|
||||
* eigene OG-Tags (Link-Previews FB/WhatsApp/Twitter) und ein Link zum
|
||||
* Kalender (KEIN Redirect mehr — die Seite ist das Ziel des QR-/Direktlinks).
|
||||
*
|
||||
* terminZeit ist mit Offset gespeichert (z. B. 10:00+02:00). Labels müssen in
|
||||
* Europe/Berlin gerendert werden, sonst zeigt der SSR-Prozess (Server-TZ=UTC)
|
||||
* 2h zu früh.
|
||||
*/
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
export const load: PageServerLoad = async ({ params, setHeaders }) => {
|
||||
const slug = params.slug;
|
||||
const rawItem = await getCalendarItemBySlug(slug, { locale: "de" });
|
||||
if (!rawItem) throw error(404, "Termin nicht gefunden");
|
||||
const item = rawItem as typeof rawItem & {
|
||||
location?: unknown;
|
||||
description?: string;
|
||||
terminEnde?: string;
|
||||
tags?: string[];
|
||||
image?: unknown;
|
||||
attachment?: { src?: string; label?: string };
|
||||
link?: unknown;
|
||||
};
|
||||
|
||||
const siteBaseUrl = (env.PUBLIC_SITE_URL || "").replace(/\/$/, "");
|
||||
const title = (item.title ?? "Termin").trim();
|
||||
const terminZeit = item.terminZeit;
|
||||
const terminEnde = item.terminEnde ?? null;
|
||||
const hasTime = !!terminZeit && !/T00:00:00/.test(terminZeit);
|
||||
|
||||
const weekdayLabel = terminZeit
|
||||
? new Date(terminZeit).toLocaleDateString("de-DE", { weekday: "long", timeZone: TZ })
|
||||
: "";
|
||||
const dateLabel = terminZeit
|
||||
? new Date(terminZeit).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: TZ,
|
||||
})
|
||||
: "";
|
||||
const timeLabel = hasTime
|
||||
? new Date(terminZeit).toLocaleTimeString("de-DE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: TZ,
|
||||
}) + " Uhr"
|
||||
: "";
|
||||
const endDateLabel =
|
||||
terminEnde && !/T00:00:00/.test(terminEnde)
|
||||
? new Date(terminEnde).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: TZ,
|
||||
})
|
||||
: "";
|
||||
|
||||
const locObj =
|
||||
typeof item.location === "object" && item.location !== null
|
||||
? (item.location as { text?: string; lat?: number; lng?: number })
|
||||
: null;
|
||||
const locationText = locObj
|
||||
? (locObj.text ?? "").trim() || null
|
||||
: typeof item.location === "string"
|
||||
? item.location.trim() || null
|
||||
: null;
|
||||
const hasCoords =
|
||||
!!locObj && typeof locObj.lat === "number" && typeof locObj.lng === "number";
|
||||
|
||||
// Externer Link / Post-Link
|
||||
let externalLink: string | null = null;
|
||||
const link = item.link as { url?: string; _slug?: string } | string | undefined;
|
||||
if (typeof link === "string" && link) externalLink = link;
|
||||
else if (link && typeof link === "object") {
|
||||
if (link.url) externalLink = link.url;
|
||||
else if (link._slug) externalLink = `/post/${encodeURIComponent(link._slug)}`;
|
||||
}
|
||||
|
||||
const descriptionHtml = item.description
|
||||
? (marked.parse(item.description) as string)
|
||||
: "";
|
||||
const descriptionPlain = descriptionHtml
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const tags = Array.isArray(item.tags) ? item.tags.filter((t) => typeof t === "string") : [];
|
||||
|
||||
const seoDescription = [
|
||||
dateLabel + (timeLabel ? `, ${timeLabel}` : ""),
|
||||
locationText,
|
||||
descriptionPlain ? descriptionPlain.slice(0, 200) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
const socialImage = `${siteBaseUrl}/api/social/calendar/${encodeURIComponent(slug)}?format=og`;
|
||||
const calendarUrl = `/kalender/#event-${slug}`;
|
||||
const canonical = `${siteBaseUrl}/kalender/termin/${encodeURIComponent(slug)}/`;
|
||||
|
||||
// schema.org Event — Rich-Result/SEO für Termine (Datum, Ort, Veranstalter).
|
||||
const jsonLd = terminZeit
|
||||
? [
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Event",
|
||||
name: title,
|
||||
startDate: terminZeit,
|
||||
...(terminEnde ? { endDate: terminEnde } : {}),
|
||||
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
|
||||
eventStatus: "https://schema.org/EventScheduled",
|
||||
...(siteBaseUrl ? { url: canonical } : {}),
|
||||
inLanguage: "de-DE",
|
||||
...(descriptionPlain ? { description: descriptionPlain.slice(0, 500) } : {}),
|
||||
image: socialImage,
|
||||
organizer: {
|
||||
"@type": "Organization",
|
||||
name: "Windwiderstand Thüringen",
|
||||
...(siteBaseUrl ? { url: siteBaseUrl } : {}),
|
||||
},
|
||||
...(locationText
|
||||
? {
|
||||
location: {
|
||||
"@type": "Place",
|
||||
name: locationText,
|
||||
...(hasCoords
|
||||
? {
|
||||
geo: {
|
||||
"@type": "GeoCoordinates",
|
||||
latitude: locObj!.lat,
|
||||
longitude: locObj!.lng,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Start", href: "/" },
|
||||
{ label: "Kalender", href: "/kalender/" },
|
||||
{ label: title },
|
||||
];
|
||||
|
||||
setHeaders({
|
||||
"cache-control": "public, s-maxage=300, stale-while-revalidate=3600",
|
||||
});
|
||||
|
||||
return {
|
||||
seoTitle: `${title} – Windwiderstand Thüringen`,
|
||||
seoDescription,
|
||||
socialImage,
|
||||
ogType: "article" as const,
|
||||
jsonLd,
|
||||
breadcrumbItems,
|
||||
calendarUrl,
|
||||
item: {
|
||||
slug,
|
||||
title,
|
||||
terminZeit: terminZeit ?? null,
|
||||
terminEnde,
|
||||
hasTime,
|
||||
weekdayLabel,
|
||||
dateLabel,
|
||||
timeLabel,
|
||||
endDateLabel,
|
||||
locationText,
|
||||
descriptionHtml,
|
||||
tags,
|
||||
image: item.image ?? null,
|
||||
attachment:
|
||||
item.attachment && item.attachment.src
|
||||
? { src: item.attachment.src, label: item.attachment.label ?? "Anhang" }
|
||||
: null,
|
||||
externalLink,
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user