e434ee3620
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>
190 lines
5.9 KiB
TypeScript
190 lines
5.9 KiB
TypeScript
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,
|
||
},
|
||
};
|
||
};
|