feat(termin): vollwertige Detailseite + QR/Detail-Link im Kalender
Deploy / verify (push) Successful in 1m15s
Deploy / deploy (push) Successful in 1m25s

/termin/<slug> ist jetzt echte Seite statt Redirect:
- alle Infos ungekürzt (Datum/Zeit in Europe/Berlin, Ort+Maps, Tags,
  Bild+Lightbox, volle Beschreibung, Anhang, externer Link)
- alle Aktionen: ICS, Google, Link kopieren, Teilen, QR, Social-Bild
- Link zum Kalender drunter (kein Auto-Redirect mehr)
- Breite via Layout-.container-custom (gleich wie andere Seiten)
- SEO: OG/Twitter/canonical zentral aus Layout (kein Doppel-Tag),
  Event-JSON-LD + Breadcrumb für Rich Results / Share-Preview

CalendarBlock: QR-Button + "Details →"-Link je Termin (→ /termin/<slug>/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-06-19 13:50:09 +02:00
parent fdc18ab5b8
commit 6925ffee45
3 changed files with 413 additions and 56 deletions
+46 -1
View File
@@ -17,6 +17,7 @@
import Icon from "@iconify/svelte";
import ImageModal from "$lib/components/ImageModal.svelte";
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
import QrModal from "$lib/components/QrModal.svelte";
type EventCardItem = CalendarItemData & {
start: Date;
@@ -458,6 +459,18 @@
socialModal = null;
}
// QR-Code zum direkten Ausgeben/Scannen (Aushang, Infostand). Nutzt die
// canonical /termin/<slug>-URL (hat OG-Tags + Redirect), Fallback = Hash-
// Anchor im Kalender wenn kein Slug.
let qrModal = $state<{ value: string; label: string } | null>(null);
function openQr(item: EventCardItem) {
const url = item._slug
? `${window.location.origin}/termin/${encodeURIComponent(item._slug)}/`
: `${window.location.origin}${window.location.pathname}#${eventId(item)}`;
qrModal = { value: url, label: item.title || "Termin" };
}
function eventId(item: EventCardItem): string {
if (item._slug) return `event-${item._slug}`;
const titlePart = (item.title || "event")
@@ -781,8 +794,21 @@
<!-- Aktionen: Primary-Reihe (Info/Karte/Anhang) + kompakte Action-Toolbar -->
<div class="pt-2 space-y-2">
{#if eventHref || locText || item.attachment?.src}
{#if item._slug || eventHref || locText || item.attachment?.src}
<div class="flex flex-wrap gap-2">
{#if item._slug}
<a
href={`/termin/${encodeURIComponent(item._slug)}/`}
class="btn-tertiary btn-sm"
>
Details
<Icon
icon="mdi:arrow-right"
class="size-3.5"
aria-hidden="true"
/>
</a>
{/if}
{#if eventHref}
<a
href={eventHref}
@@ -902,6 +928,16 @@
Bild
</button>
{/if}
<button
type="button"
onclick={() => openQr(item)}
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-700 hover:bg-white"
title="QR-Code anzeigen & herunterladen"
aria-label="QR-Code anzeigen & herunterladen"
>
<Icon icon="mdi:qrcode" class="size-3.5" />
QR
</button>
</div>
{/if}
@@ -1325,6 +1361,15 @@
/>
{/if}
{#if qrModal}
<QrModal
value={qrModal.value}
label={qrModal.label}
sublabel="Termin-Link"
onclose={() => (qrModal = null)}
/>
{/if}
<style>
/* Heute-Markierung im Kalendergrid: dezenter Rand + leichtes Glow.
Ergänzt das Event-Badge, damit auch leere Tage als "Heute"
+117 -19
View File
@@ -5,14 +5,16 @@ import { marked } from "$lib/markdown-safe";
import { env } from "$env/dynamic/public";
/**
* SEO-Detail-Route pro Termin. Hat eigene OG-Tags (für Link-Previews bei
* Facebook/WhatsApp/Twitter), leitet sonst weiter an /kalender/#event-<slug>
* (das war die ursprüngliche Hash-basierte Ziel-URL und bleibt damit
* funktional).
* 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).
*
* Crawler bekommen die Meta-Tags zu sehen, normale Browser werden via
* Client-Side-Redirect (im +page.svelte) sofort zum Kalender geschickt.
* 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" });
@@ -20,41 +22,77 @@ export const load: PageServerLoad = async ({ params, setHeaders }) => {
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", {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
timeZone: TZ,
})
: "";
const timeLabel =
terminZeit && !/T00:00:00/.test(terminZeit)
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 locationText =
const locObj =
typeof item.location === "object" && item.location !== null
? (((item.location as { text?: string }).text ?? "").trim() || 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";
// Plain-Text-Snippet aus Description (Markdown → Text, ~200 Zeichen)
let descriptionPlain = "";
if (item.description) {
const html = marked.parse(item.description) as string;
descriptionPlain = html
// 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}` : ""),
@@ -66,8 +104,54 @@ export const load: PageServerLoad = async ({ params, setHeaders }) => {
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 },
];
// CDN/Browser dürfen die SEO-Antwort cachen, da Crawler sie selten neu holen.
setHeaders({
"cache-control": "public, s-maxage=300, stale-while-revalidate=3600",
});
@@ -77,14 +161,28 @@ export const load: PageServerLoad = async ({ params, setHeaders }) => {
seoDescription,
socialImage,
ogType: "article" as const,
jsonLd,
breadcrumbItems,
calendarUrl,
item: {
slug,
title,
terminZeit: terminZeit ?? null,
terminEnde,
hasTime,
weekdayLabel,
dateLabel,
timeLabel,
endDateLabel,
locationText,
descriptionPlain: descriptionPlain.slice(0, 400),
descriptionHtml,
tags,
image: item.image ?? null,
attachment:
item.attachment && item.attachment.src
? { src: item.attachment.src, label: item.attachment.label ?? "Anhang" }
: null,
externalLink,
},
};
};
+240 -26
View File
@@ -1,42 +1,256 @@
<script lang="ts">
import { onMount } from "svelte";
import type { PageData } from "./$types";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { useTranslate, T } from "$lib/translations";
import {
downloadICS,
googleCalUrl,
mapsUrl,
type CalendarItemICS,
} from "$lib/calendar-ics";
import { getCmsImageUrl, extractCmsImageField } from "$lib/rusty-image";
import QrModal from "$lib/components/QrModal.svelte";
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
import ImageModal from "$lib/components/ImageModal.svelte";
let { data }: { data: PageData } = $props();
const t = useTranslate();
const item = $derived(data.item);
onMount(() => {
// JS-Redirect zum Kalender-Hash-Anchor (für normale Nutzer).
// Crawler ignorieren das und nutzen die OG-Tags aus dem Server-HTML.
window.location.replace(data.calendarUrl);
// Kanonische URL dieser Seite (für ICS-Link, Teilen, QR).
let pageUrl = $state("");
$effect(() => {
pageUrl = typeof window !== "undefined" ? window.location.href : "";
});
function toICS(): CalendarItemICS {
const start = item.terminZeit ? new Date(item.terminZeit) : new Date();
const descPlain = item.descriptionHtml
? item.descriptionHtml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
: "";
return {
title: item.title,
start,
end: item.terminEnde ? new Date(item.terminEnde) : null,
description: descPlain || undefined,
location: item.locationText || undefined,
url: item.externalLink || pageUrl || undefined,
};
}
function fileSlug(s: string): string {
return (
s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "termin"
);
}
let qrOpen = $state(false);
let socialOpen = $state(false);
let modalImage = $state<{ src: string; alt: string } | null>(null);
let copied = $state(false);
let shared = $state(false);
async function copyLink() {
await navigator.clipboard.writeText(pageUrl).catch(() => {});
copied = true;
setTimeout(() => (copied = false), 2000);
}
async function share() {
const text = [
item.title,
`${item.weekdayLabel}, ${item.dateLabel}${item.timeLabel ? ` · ${item.timeLabel}` : ""}`,
item.locationText,
]
.filter(Boolean)
.join("\n");
if (typeof navigator.share === "function") {
try {
await navigator.share({ title: item.title, text, url: pageUrl });
return;
} catch (e) {
if ((e as DOMException).name === "AbortError") return;
}
}
await navigator.clipboard.writeText(`${text}\n${pageUrl}`).catch(() => {});
shared = true;
setTimeout(() => (shared = false), 2000);
}
// Event-Bild auflösen
const imageField = $derived(item.image ? extractCmsImageField(item.image) : null);
const thumbSrc = $derived(
imageField ? getCmsImageUrl(imageField.url, { width: 900, fit: "contain" }) : null,
);
const fullSrc = $derived(
imageField ? getCmsImageUrl(imageField.url, { width: 1600, fit: "contain" }) : null,
);
const imageAlt = $derived(imageField?.alt ?? item.title);
const descHtml = $derived(item.descriptionHtml || "");
</script>
<svelte:head>
<!-- Fallback-Redirect für Browser ohne JS (~0.5s Delay) -->
<meta http-equiv="refresh" content="1; url={data.calendarUrl}" />
<!-- Robots-Hinweis: Crawler sollen die Hash-URL als Canonical sehen, aber
diese Detail-Route bleibt indexierbar, damit Social-Previews greifen. -->
</svelte:head>
<!-- SEO/OG/Twitter/canonical + Event-JSON-LD + Breadcrumb kommen zentral aus
+layout.svelte (liest seoTitle/seoDescription/socialImage/ogType/jsonLd/
breadcrumbItems aus $page.data). Hier KEINE eigenen Head-Tags → sonst
Doppel-Tags, die Scraper (WhatsApp/FB) verwirren. -->
<!-- Breite kommt vom Layout-Wrapper (.container-custom in +layout.svelte) —
hier nur vertikaler Abstand, KEIN eigenes max-w/mx-auto/px (sonst schmaler
+ doppeltes Padding ggü. den anderen Seiten). -->
<article class="pt-10 pb-4">
<p class="mb-2 text-xs font-semibold uppercase tracking-wider text-wald-700">Termin</p>
<h1 class="mb-4 text-3xl font-bold leading-tight text-stein-900">{item.title}</h1>
<div class="mx-auto max-w-2xl px-4 py-12 text-center">
<p class="mb-2 text-xs uppercase tracking-wider text-stein-500">Termin</p>
<h1 class="mb-4 text-2xl font-bold text-stein-900">{data.item.title}</h1>
{#if data.item.dateLabel}
<p class="text-stein-700">
{data.item.dateLabel}{data.item.timeLabel ? `, ${data.item.timeLabel}` : ""}
<!-- Eckdaten -->
<div class="space-y-1.5 text-stein-700">
{#if item.dateLabel}
<p class="flex items-center gap-2">
<Icon icon="mdi:calendar" class="size-5 shrink-0 text-wald-700" aria-hidden="true" />
<span class="font-medium">
{item.weekdayLabel ? `${item.weekdayLabel}, ` : ""}{item.dateLabel}{item.endDateLabel
? ` ${item.endDateLabel}`
: ""}
</span>
</p>
{/if}
{#if data.item.locationText}
<p class="mt-1 text-stein-500">📍 {data.item.locationText}</p>
{#if item.timeLabel}
<p class="flex items-center gap-2">
<Icon icon="mdi:clock-outline" class="size-5 shrink-0 text-wald-700" aria-hidden="true" />
<span class="tabular-nums">{item.timeLabel}</span>
</p>
{/if}
{#if data.item.descriptionPlain}
<p class="mt-4 text-sm text-stein-600">{data.item.descriptionPlain}</p>
{/if}
<p class="mt-8 text-xs text-stein-400">Du wirst gerade zum Kalender weitergeleitet…</p>
{#if item.locationText}
<p class="flex items-center gap-2">
<Icon icon="mdi:map-marker" class="size-5 shrink-0 text-wald-700" aria-hidden="true" />
<a
href={data.calendarUrl}
class="mt-4 inline-block rounded-md bg-wald-700 px-4 py-2 text-sm font-medium text-white no-underline hover:bg-wald-800"
href={mapsUrl(item.locationText)}
target="_blank"
rel="noopener noreferrer"
class="text-stein-700 underline underline-offset-2 hover:text-wald-700"
>
Im Kalender anzeigen
{item.locationText}
</a>
</p>
{/if}
</div>
{#if item.tags.length > 0}
<div class="mt-4 flex flex-wrap gap-1.5">
{#each item.tags as tg}
<span class="rounded-xs bg-himmel-100 px-2 py-0.5 text-xs text-himmel-800">{tg}</span>
{/each}
</div>
{/if}
{#if thumbSrc && fullSrc}
<button
type="button"
onclick={() => (modalImage = { src: fullSrc, alt: imageAlt })}
class="group relative mt-6 block w-full overflow-hidden rounded-md"
aria-label="Bild vergrößern"
>
<img src={thumbSrc} alt={imageAlt} class="w-full object-contain" loading="lazy" />
</button>
{/if}
{#if descHtml}
<div class="prose prose-stein mt-6 max-w-none font-light leading-relaxed">
{@html descHtml}
</div>
{/if}
{#if item.attachment}
<a
href={`/cms-files?src=${encodeURIComponent(item.attachment.src)}&dl=1`}
class="btn-outline btn-sm mt-6 inline-flex group"
>
<Icon icon="mdi:file-download" class="size-4 shrink-0" aria-hidden="true" />
<span>{item.attachment.label}</span>
</a>
{/if}
{#if item.externalLink}
<div class="mt-4">
<a
href={item.externalLink}
target="_blank"
rel="noopener noreferrer"
class="btn-tertiary btn-sm inline-flex"
>
{t(T.calendar_more_info)}
<Icon icon="mdi:open-in-new" class="size-3.5" aria-hidden="true" />
</a>
</div>
{/if}
<!-- Aktionen ungekürzt -->
<div class="mt-8 flex flex-wrap items-center gap-2 rounded-md border border-stein-200 bg-stein-50 p-2">
<button type="button" onclick={() => downloadICS(toICS(), fileSlug(item.title))} class="action-btn">
<Icon icon="mdi:download" class="size-4" /> ICS
</button>
<a href={googleCalUrl(toICS())} target="_blank" rel="noopener noreferrer" class="action-btn no-underline">
<Icon icon="mdi:google" class="size-4" /> Google
</a>
<span class="mx-1 h-5 w-px bg-stein-300" aria-hidden="true"></span>
<button type="button" onclick={copyLink} class="action-btn">
<Icon icon={copied ? "mdi:check" : "mdi:link-variant"} class="size-4" />
{copied ? t(T.post_action_copied) : "Link"}
</button>
<button type="button" onclick={share} class="action-btn">
<Icon icon={shared ? "mdi:check" : "mdi:share-variant"} class="size-4" />
{shared ? t(T.calendar_share_copied) : t(T.calendar_share)}
</button>
<button type="button" onclick={() => (qrOpen = true)} class="action-btn">
<Icon icon="mdi:qrcode" class="size-4" /> QR
</button>
<button type="button" onclick={() => (socialOpen = true)} class="action-btn">
<Icon icon="mdi:image-outline" class="size-4" /> Bild
</button>
</div>
<!-- Link zum Kalender (kein Redirect) -->
<div class="mt-10 border-t border-stein-200 pt-6">
<a
href={data.calendarUrl}
class="inline-flex items-center gap-1.5 text-sm font-medium text-wald-700 underline underline-offset-2 hover:text-wald-800"
>
<Icon icon="mdi:calendar-month" class="size-4" aria-hidden="true" />
Alle Termine im Kalender
</a>
</div>
</article>
{#if modalImage}
<ImageModal src={modalImage.src} alt={modalImage.alt} onclose={() => (modalImage = null)} />
{/if}
{#if qrOpen}
<QrModal value={pageUrl} label={item.title} sublabel="Termin-Link" onclose={() => (qrOpen = false)} />
{/if}
{#if socialOpen}
<SocialImageModal
open={true}
title={item.title}
slug={item.slug}
endpointBase="/api/social/calendar"
downloadPrefix="termin"
onclose={() => (socialOpen = false)}
/>
{/if}
<style>
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border-radius: 0.25rem;
padding: 0.35rem 0.6rem;
font-size: 0.8rem;
color: var(--color-stein-700);
}
.action-btn:hover {
background: #fff;
}
</style>