Files
windwiderstand/src/routes/kalender/termin/[slug]/+page.server.ts
T
Peter Meier e434ee3620
Deploy / verify (push) Successful in 1m21s
Deploy / deploy (push) Successful in 1m39s
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>
2026-06-23 10:35:15 +02:00

190 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
},
};
};