feat(termin): Termin-Detail unter /kalender/termin/<slug>, 302 von alt
Deploy / verify (push) Successful in 1m21s
Deploy / deploy (push) Successful in 1m39s

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:
Peter Meier
2026-06-23 10:35:15 +02:00
parent a628e0e8d1
commit e434ee3620
6 changed files with 201 additions and 190 deletions
@@ -461,13 +461,13 @@
} }
// QR-Code zum direkten Ausgeben/Scannen (Aushang, Infostand). Nutzt die // QR-Code zum direkten Ausgeben/Scannen (Aushang, Infostand). Nutzt die
// canonical /termin/<slug>-URL (hat OG-Tags + Redirect), Fallback = Hash- // canonical /kalender/termin/<slug>-URL (hat OG-Tags), Fallback = Hash-
// Anchor im Kalender wenn kein Slug. // Anchor im Kalender wenn kein Slug.
let qrModal = $state<{ value: string; label: string } | null>(null); let qrModal = $state<{ value: string; label: string } | null>(null);
function openQr(item: EventCardItem) { function openQr(item: EventCardItem) {
const url = item._slug const url = item._slug
? `${window.location.origin}/termin/${encodeURIComponent(item._slug)}/` ? `${window.location.origin}/kalender/termin/${encodeURIComponent(item._slug)}/`
: `${window.location.origin}${window.location.pathname}#${eventId(item)}`; : `${window.location.origin}${window.location.pathname}#${eventId(item)}`;
qrModal = { value: url, label: item.title || "Termin" }; qrModal = { value: url, label: item.title || "Termin" };
} }
@@ -753,7 +753,7 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#if item._slug} {#if item._slug}
<a <a
href={`/termin/${encodeURIComponent(item._slug)}/`} href={`/kalender/termin/${encodeURIComponent(item._slug)}/`}
class="btn-tertiary btn-sm" class="btn-tertiary btn-sm"
> >
Details Details
@@ -45,7 +45,7 @@
<time <time
datetime={isoDate(start)} datetime={isoDate(start)}
aria-label={ariaLabel} aria-label={ariaLabel}
class="shrink-0 select-none aspect-square size-20 rounded-sm rounded-br-4xl bg-wald-200 text-wald-500 flex justify-center items-center shadow" class="shrink-0 select-none aspect-square size-18 rounded-sm rounded-br-4xl bg-wald-200 text-wald-500 flex justify-center items-center shadow"
> >
<div class="text-center font-black leading-normal"> <div class="text-center font-black leading-normal">
<div class="text-[10px] uppercase tracking-wider"> <div class="text-[10px] uppercase tracking-wider">
@@ -245,9 +245,9 @@ export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
} }
const siteUrl = (env.PUBLIC_SITE_URL || "https://windwiderstand.de").replace(/\/$/, ""); const siteUrl = (env.PUBLIC_SITE_URL || "https://windwiderstand.de").replace(/\/$/, "");
// /termin/[slug] hat eigene OG-Meta-Tags + leitet zu /kalender/#event-<slug> // /kalender/termin/[slug] hat eigene OG-Meta-Tags. Damit greifen
// weiter. Damit greifen Link-Previews (FB/WhatsApp/Twitter). // Link-Previews (FB/WhatsApp/Twitter).
const eventUrl = `${siteUrl}/termin/${encodeURIComponent(slug)}`; const eventUrl = `${siteUrl}/kalender/termin/${encodeURIComponent(slug)}`;
// Theme-Palette: drei Varianten — Wald (full-bleed), transparent-dark // Theme-Palette: drei Varianten — Wald (full-bleed), transparent-dark
// (für helle Hintergründe, dunkler Text), transparent-light (für dunkle). // (für helle Hintergründe, dunkler Text), transparent-light (für dunkle).
const isTransparent = themeParam !== "wald"; const isTransparent = themeParam !== "wald";
@@ -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,
},
};
};
+5 -183
View File
@@ -1,188 +1,10 @@
import type { PageServerLoad } from "./$types"; import type { PageServerLoad } from "./$types";
import { error } from "@sveltejs/kit"; import { redirect } 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, * Alt-URL: Termin-Detailseiten leben jetzt unter /kalender/termin/<slug>/.
* eigene OG-Tags (Link-Previews FB/WhatsApp/Twitter) und ein Link zum * 302 erhält bestehende QR-Codes, geteilte Links und Lesezeichen.
* 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 = ({ params }) => {
redirect(302, `/kalender/termin/${encodeURIComponent(params.slug)}/`);
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}/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: "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,
},
};
}; };