feat: Social-Bild erweitert (Logo, Status-Pill, Description, Story, Themes) + OG-Route + Calendar-Buttons aufgeräumt
Social-Bild-Endpoint: - Logo aus page_config (gefärbt in Theme-Akzent) statt Wordmark - Status-Pill (ONLINE/Demo/Info/Anhörung/Stammtisch/Sitzung) aus Titel-Keywords und Tags, mit eigener Farbe - Description-Snippet unter Titel (Square + Story) - Story-Format 1080x1920 ergänzt - Theme-Varianten: wald (default), transparent-onlight, transparent-ondark - Uhrzeit unter Datum (2. Zeile) mit Clock-Icon im Akzent-Grün - QR linksbündig bei Square + Story, kleiner (180px) - Location-Pin top-aligned, Title-Fontsize dynamisch bei langen Titeln Neue Route /termin/[slug]: OG-Meta-Tags für Link-Previews (FB/WhatsApp/Twitter), JS-Redirect zu /kalender/#event-<slug>. QR-Code im Social-Bild zeigt jetzt auf /termin/[slug]. Calendar-Block Aktionen reorganisiert: - Primary-Reihe: Mehr Info, Auf Karte, Anhang - Kompakte Toolbar (gerahmt): Kalender (ICS, Google) | Teilen (Link, Share, Bild) | Im Grid - Theme-Selector im Social-Modal mit Vorschau-Hintergrund-Indikator Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -447,6 +447,7 @@
|
||||
let modalImage = $state<{ src: string; alt: string } | null>(null);
|
||||
let linkedSlug = $state<string | null>(null);
|
||||
let socialModal = $state<{ slug: string; title: string } | null>(null);
|
||||
let socialTheme = $state<"wald" | "transparent-onlight" | "transparent-ondark">("wald");
|
||||
|
||||
function openSocial(item: EventCardItem) {
|
||||
if (!item._slug) return;
|
||||
@@ -767,7 +768,10 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap gap-2 pt-1 text-xs">
|
||||
<!-- Aktionen: Primary-Reihe (Info/Karte/Anhang) + kompakte Action-Toolbar -->
|
||||
<div class="pt-2 space-y-2">
|
||||
{#if eventHref || locText || item.attachment?.src}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if eventHref}
|
||||
<a
|
||||
href={eventHref}
|
||||
@@ -796,9 +800,7 @@
|
||||
class="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="whitespace-nowrap"
|
||||
>{t(T.calendar_open_maps)}</span
|
||||
>
|
||||
<span class="whitespace-nowrap">{t(T.calendar_open_maps)}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if item.attachment?.src}
|
||||
@@ -806,129 +808,98 @@
|
||||
<a
|
||||
href={dlSrc}
|
||||
class="btn-outline btn-sm group"
|
||||
aria-label={item.attachment.label ||
|
||||
"Anhang herunterladen"}
|
||||
aria-label={item.attachment.label || "Anhang herunterladen"}
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:file-download"
|
||||
class="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="whitespace-nowrap"
|
||||
>{item.attachment.label ||
|
||||
"Anhang herunterladen"}</span
|
||||
>
|
||||
<span class="whitespace-nowrap">{item.attachment.label || "Anhang"}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Kompakte Action-Toolbar: gruppiert (Kalender · Teilen · Anzeigen) mit Separatoren -->
|
||||
<div class="flex flex-wrap items-center gap-1 rounded-md border border-stein-200 bg-stein-50 p-1">
|
||||
<!-- Kalender-Aktionen -->
|
||||
<span class="px-1.5 text-[10px] uppercase tracking-wide text-stein-400">Kalender</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() =>
|
||||
downloadICS(toICS(item), fileSlug(title))}
|
||||
class="btn-outline btn-sm group"
|
||||
onclick={() => downloadICS(toICS(item), fileSlug(title))}
|
||||
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-700 hover:bg-white"
|
||||
title={t(T.calendar_download_ics)}
|
||||
aria-label={t(T.calendar_download_ics)}
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:download"
|
||||
class="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="whitespace-nowrap"
|
||||
>{t(T.calendar_download_ics)}</span
|
||||
>
|
||||
<Icon icon="mdi:download" class="size-3.5" />
|
||||
ICS
|
||||
</button>
|
||||
<a
|
||||
href={googleCalUrl(toICS(item))}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-outline btn-sm group"
|
||||
aria-label={t(T.calendar_add_to_google)}
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:google"
|
||||
class="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="whitespace-nowrap"
|
||||
>{t(T.calendar_add_to_google)}</span
|
||||
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-700 no-underline hover:bg-white"
|
||||
title={t(T.calendar_add_to_google)}
|
||||
>
|
||||
<Icon icon="mdi:google" class="size-3.5" />
|
||||
Google
|
||||
</a>
|
||||
|
||||
{#if feedHost}
|
||||
{@const shareKey =
|
||||
item._slug ?? String(item.start.getTime())}
|
||||
{@const shareKey = item._slug ?? String(item.start.getTime())}
|
||||
{@const copied = copyToastKey === shareKey}
|
||||
{@const shared = shareToastKey === shareKey}
|
||||
<span class="mx-1 h-4 w-px bg-stein-300" aria-hidden="true"></span>
|
||||
<span class="px-1.5 text-[10px] uppercase tracking-wide text-stein-400">Teilen</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => copyEventLink(item)}
|
||||
class="btn-outline btn-sm group"
|
||||
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-700 hover:bg-white"
|
||||
title={t(T.post_action_copy)}
|
||||
aria-label={t(T.post_action_copy)}
|
||||
>
|
||||
<Icon
|
||||
icon={copied
|
||||
? "mdi:check"
|
||||
: "mdi:link-variant"}
|
||||
class="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="whitespace-nowrap"
|
||||
>{copied
|
||||
? t(T.post_action_copied)
|
||||
: t(T.post_action_copy)}</span
|
||||
>
|
||||
<Icon icon={copied ? "mdi:check" : "mdi:link-variant"} class="size-3.5" />
|
||||
{copied ? t(T.post_action_copied) : "Link"}
|
||||
</button>
|
||||
{@const shared = shareToastKey === shareKey}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => shareEvent(item, title, locText)}
|
||||
class="btn-outline btn-sm group"
|
||||
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-700 hover:bg-white"
|
||||
title={t(T.calendar_share_aria)}
|
||||
aria-label={t(T.calendar_share_aria)}
|
||||
>
|
||||
<Icon
|
||||
icon={shared
|
||||
? "mdi:check"
|
||||
: "mdi:share-variant"}
|
||||
class="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="whitespace-nowrap"
|
||||
>{shared
|
||||
? t(T.calendar_share_copied)
|
||||
: t(T.calendar_share)}</span
|
||||
>
|
||||
<Icon icon={shared ? "mdi:check" : "mdi:share-variant"} class="size-3.5" />
|
||||
{shared ? t(T.calendar_share_copied) : t(T.calendar_share)}
|
||||
</button>
|
||||
{/if}
|
||||
{#if item._slug}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openSocial(item)}
|
||||
class="btn-outline btn-sm group"
|
||||
aria-label="Social-Bild als Vorschau öffnen"
|
||||
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-700 hover:bg-white"
|
||||
title="Social-Bild Vorschau"
|
||||
aria-label="Social-Bild Vorschau"
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:image-outline"
|
||||
class="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="whitespace-nowrap">Social-Bild</span>
|
||||
<Icon icon="mdi:image-outline" class="size-3.5" />
|
||||
Bild
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<span class="ml-auto"></span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => jumpToEventInGrid(item)}
|
||||
class="btn-outline btn-sm group"
|
||||
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-500 hover:bg-white"
|
||||
title={t(T.calendar_jump_to_month)}
|
||||
aria-label={t(T.calendar_jump_to_month)}
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:calendar-search"
|
||||
class="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="whitespace-nowrap"
|
||||
>{t(T.calendar_jump_to_month)}</span
|
||||
>
|
||||
<Icon icon="mdi:calendar-search" class="size-3.5" />
|
||||
Im Grid
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
{/snippet}
|
||||
@@ -1323,8 +1294,16 @@
|
||||
{/if}
|
||||
|
||||
{#if socialModal}
|
||||
{@const ogUrl = `/api/social/calendar/${socialModal.slug}?format=og`}
|
||||
{@const sqUrl = `/api/social/calendar/${socialModal.slug}?format=square`}
|
||||
{@const themeQ = socialTheme === "wald" ? "" : `&theme=${socialTheme}`}
|
||||
{@const ogUrl = `/api/social/calendar/${socialModal.slug}?format=og${themeQ}`}
|
||||
{@const sqUrl = `/api/social/calendar/${socialModal.slug}?format=square${themeQ}`}
|
||||
{@const storyUrl = `/api/social/calendar/${socialModal.slug}?format=story${themeQ}`}
|
||||
{@const previewBg =
|
||||
socialTheme === "wald"
|
||||
? "bg-stein-50"
|
||||
: socialTheme === "transparent-onlight"
|
||||
? "bg-[repeating-linear-gradient(45deg,#f5f5f4,#f5f5f4_10px,#e7e5e4_10px,#e7e5e4_20px)]"
|
||||
: "bg-[repeating-linear-gradient(45deg,#292524,#292524_10px,#1c1917_10px,#1c1917_20px)]"}
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4"
|
||||
role="dialog"
|
||||
@@ -1335,7 +1314,7 @@
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="relative max-h-[92vh] w-full max-w-4xl overflow-y-auto rounded-lg bg-white p-5 shadow-2xl"
|
||||
class="relative max-h-[92vh] w-full max-w-5xl overflow-y-auto rounded-lg bg-white p-5 shadow-2xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
@@ -1354,18 +1333,59 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<!-- Theme-Selector -->
|
||||
<div class="mb-4 rounded-lg border border-stein-200 bg-stein-50 p-3">
|
||||
<p class="mb-2 text-[11px] font-medium uppercase tracking-wider text-stein-500">Hintergrund</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (socialTheme = "wald")}
|
||||
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
|
||||
{socialTheme === 'wald' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
|
||||
>
|
||||
<span class="size-3 rounded-full bg-gradient-to-br from-wald-900 to-wald-600"></span>
|
||||
Wald (Standard)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (socialTheme = "transparent-onlight")}
|
||||
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
|
||||
{socialTheme === 'transparent-onlight' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
|
||||
title="Transparenter Hintergrund mit dunklem Text — für eigene helle Bild-Hintergründe"
|
||||
>
|
||||
<span class="size-3 rounded-full border border-stein-300 bg-[repeating-linear-gradient(45deg,#fff,#fff_2px,#e5e5e5_2px,#e5e5e5_4px)]"></span>
|
||||
Transparent · für helle Hintergründe
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (socialTheme = "transparent-ondark")}
|
||||
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
|
||||
{socialTheme === 'transparent-ondark' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
|
||||
title="Transparenter Hintergrund mit hellem Text — für eigene dunkle Bild-Hintergründe"
|
||||
>
|
||||
<span class="size-3 rounded-full border border-stein-300 bg-[repeating-linear-gradient(45deg,#1c1917,#1c1917_2px,#44403c_2px,#44403c_4px)]"></span>
|
||||
Transparent · für dunkle Hintergründe
|
||||
</button>
|
||||
</div>
|
||||
{#if socialTheme !== "wald"}
|
||||
<p class="mt-2 text-[10px] text-stein-500">
|
||||
Vorschau-Hintergrund zeigt nur, wie der Text aussieht — die heruntergeladene PNG hat echten Alpha-Kanal.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-stein-600">Quer · 1200 × 630</p>
|
||||
<img
|
||||
src={ogUrl}
|
||||
alt="Social-Bild Quer"
|
||||
class="w-full rounded-sm border border-stein-200 bg-stein-50"
|
||||
class="w-full rounded-sm border border-stein-200 {previewBg}"
|
||||
loading="eager"
|
||||
/>
|
||||
<a
|
||||
href={ogUrl}
|
||||
download={`termin-${socialModal.slug}-og.png`}
|
||||
download={`termin-${socialModal.slug}-og-${socialTheme}.png`}
|
||||
class="btn-outline btn-sm justify-center no-underline!"
|
||||
>
|
||||
<Icon icon="mdi:download" class="size-3.5" />
|
||||
@@ -1377,22 +1397,40 @@
|
||||
<img
|
||||
src={sqUrl}
|
||||
alt="Social-Bild Quadrat"
|
||||
class="mx-auto w-full max-w-[300px] rounded-sm border border-stein-200 bg-stein-50 sm:max-w-none"
|
||||
class="mx-auto w-full rounded-sm border border-stein-200 {previewBg}"
|
||||
loading="eager"
|
||||
/>
|
||||
<a
|
||||
href={sqUrl}
|
||||
download={`termin-${socialModal.slug}-square.png`}
|
||||
download={`termin-${socialModal.slug}-square-${socialTheme}.png`}
|
||||
class="btn-outline btn-sm justify-center no-underline!"
|
||||
>
|
||||
<Icon icon="mdi:download" class="size-3.5" />
|
||||
Quadrat herunterladen
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-stein-600">Story · 1080 × 1920</p>
|
||||
<img
|
||||
src={storyUrl}
|
||||
alt="Social-Bild Story"
|
||||
class="mx-auto w-auto max-h-[280px] rounded-sm border border-stein-200 {previewBg}"
|
||||
loading="eager"
|
||||
/>
|
||||
<a
|
||||
href={storyUrl}
|
||||
download={`termin-${socialModal.slug}-story-${socialTheme}.png`}
|
||||
class="btn-outline btn-sm justify-center no-underline!"
|
||||
>
|
||||
<Icon icon="mdi:download" class="size-3.5" />
|
||||
Story herunterladen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-[11px] text-stein-400">
|
||||
Die Bilder werden automatisch generiert. Für Posts auf Instagram, Facebook, WhatsApp oder als Hintergrund-Grafik.
|
||||
Quer: Facebook/Twitter/Link-Preview. Quadrat: Instagram-Feed. Story: Instagram/WhatsApp Story.
|
||||
Transparent-Varianten lassen sich als Overlay auf eigene Hintergründe in Stories einsetzen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,13 +5,20 @@ import { html as htmlToReact } from "satori-html";
|
||||
import { Resvg } from "@resvg/resvg-js";
|
||||
import { toBuffer as qrToBuffer } from "qrcode";
|
||||
import { env } from "$env/dynamic/public";
|
||||
import { getCalendarItemBySlug } from "$lib/cms";
|
||||
import { getCalendarItemBySlug, getPageConfigs } from "$lib/cms";
|
||||
|
||||
type Format = "og" | "square";
|
||||
type Format = "og" | "square" | "story";
|
||||
/**
|
||||
* - `wald` = voller dunkler Hintergrund (default, Stand-Alone-Post)
|
||||
* - `transparent-onlight` = transparent + dunkler Text (für helle Bild-Hintergründe)
|
||||
* - `transparent-ondark` = transparent + heller Text (für dunkle Bild-Hintergründe)
|
||||
*/
|
||||
type Theme = "wald" | "transparent-onlight" | "transparent-ondark";
|
||||
|
||||
const SIZES: Record<Format, { w: number; h: number }> = {
|
||||
og: { w: 1200, h: 630 },
|
||||
square: { w: 1080, h: 1080 },
|
||||
story: { w: 1080, h: 1920 },
|
||||
};
|
||||
|
||||
const FONT_URLS: Record<string, string> = {
|
||||
@@ -22,6 +29,61 @@ const FONT_URLS: Record<string, string> = {
|
||||
|
||||
let fontCache: { regular: ArrayBuffer; semibold: ArrayBuffer; bold: ArrayBuffer } | null = null;
|
||||
|
||||
let logoSvgCache: { svg: string; recolored: Record<string, string> } | null = null;
|
||||
|
||||
/**
|
||||
* Holt das site-Logo aus page_config (inline SVG-Buffer). Nur SVGs werden
|
||||
* unterstützt — Bitmap-Logos werden für die statische Wordmark-Fallback-
|
||||
* Variante übersprungen.
|
||||
*/
|
||||
async function loadLogo(): Promise<string | null> {
|
||||
if (logoSvgCache) return logoSvgCache.svg;
|
||||
try {
|
||||
const configs = await getPageConfigs({ locale: "de" });
|
||||
const cfg = configs[0];
|
||||
const rawLogo = (cfg as { logo?: unknown })?.logo;
|
||||
const rawSrc =
|
||||
typeof rawLogo === "string"
|
||||
? rawLogo
|
||||
: (rawLogo as { src?: string } | undefined)?.src ?? null;
|
||||
if (!rawSrc) return null;
|
||||
const cmsBase = (env.PUBLIC_CMS_URL || "http://localhost:3000").replace(/\/$/, "");
|
||||
const u = rawSrc.startsWith("//")
|
||||
? `https:${rawSrc}`
|
||||
: rawSrc.startsWith("/")
|
||||
? `${cmsBase}${rawSrc}`
|
||||
: rawSrc.startsWith("http")
|
||||
? rawSrc
|
||||
: `${cmsBase}/api/assets/${rawSrc}`;
|
||||
if (!u.split("?")[0].toLowerCase().endsWith(".svg")) return null;
|
||||
const res = await fetch(u);
|
||||
if (!res.ok) return null;
|
||||
let text = (await res.text()).trim();
|
||||
// XML-Prolog + DOCTYPE entfernen — satori/resvg embed brauchen pures <svg>.
|
||||
text = text.replace(/<\?xml[^?]*\?>/g, "").replace(/<!DOCTYPE[^>]*>/g, "").trim();
|
||||
if (!text.startsWith("<svg")) return null;
|
||||
logoSvgCache = { svg: text, recolored: {} };
|
||||
return text;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Färbt das Logo in einer Theme-Farbe ein — naive Replace-Approach für
|
||||
* gängige Mono-SVGs (alle currentColor/black/fill-Werte werden ersetzt).
|
||||
*/
|
||||
function colorizeLogo(svg: string, color: string): string {
|
||||
if (!logoSvgCache) return svg;
|
||||
if (logoSvgCache.recolored[color]) return logoSvgCache.recolored[color];
|
||||
const recolored = svg
|
||||
.replace(/fill="(?!none)[^"]*"/gi, `fill="${color}"`)
|
||||
.replace(/stroke="(?!none)[^"]*"/gi, `stroke="${color}"`)
|
||||
.replace(/currentColor/gi, color);
|
||||
logoSvgCache.recolored[color] = recolored;
|
||||
return recolored;
|
||||
}
|
||||
|
||||
async function loadFonts() {
|
||||
if (fontCache) return fontCache;
|
||||
const [regular, semibold, bold] = await Promise.all(
|
||||
@@ -51,12 +113,25 @@ function weekday(iso: string | undefined): string {
|
||||
|
||||
const cache = new Map<string, { png: Buffer; ts: number }>();
|
||||
const CACHE_TTL = 10 * 60_000; // 10min
|
||||
const CACHE_VERSION = "v4";
|
||||
const CACHE_VERSION = "v18";
|
||||
|
||||
function parseFormat(v: string | null): Format {
|
||||
if (v === "square") return "square";
|
||||
if (v === "story") return "story";
|
||||
return "og";
|
||||
}
|
||||
|
||||
function parseTheme(v: string | null): Theme {
|
||||
if (v === "transparent-ondark" || v === "transparent-light") return "transparent-ondark";
|
||||
if (v === "transparent-onlight" || v === "transparent-dark" || v === "transparent") return "transparent-onlight";
|
||||
return "wald";
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
|
||||
const slug = params.slug;
|
||||
const formatParam = url.searchParams.get("format") === "square" ? "square" : "og";
|
||||
const cacheKey = `${CACHE_VERSION}:${slug}:${formatParam}`;
|
||||
const formatParam = parseFormat(url.searchParams.get("format"));
|
||||
const themeParam = parseTheme(url.searchParams.get("theme"));
|
||||
const cacheKey = `${CACHE_VERSION}:${slug}:${formatParam}:${themeParam}`;
|
||||
const now = Date.now();
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && now - cached.ts < CACHE_TTL) {
|
||||
@@ -69,10 +144,15 @@ export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
|
||||
|
||||
const itemRaw = await getCalendarItemBySlug(slug, { locale: "de" });
|
||||
if (!itemRaw) throw error(404, "Termin nicht gefunden");
|
||||
const item = itemRaw as typeof itemRaw & { location?: unknown };
|
||||
const item = itemRaw as typeof itemRaw & {
|
||||
location?: unknown;
|
||||
tags?: unknown;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const { w, h } = SIZES[formatParam];
|
||||
const fonts = await loadFonts();
|
||||
const logoSvg = await loadLogo();
|
||||
|
||||
const title = (item.title ?? "").trim();
|
||||
const { date, time } = formatDate(item.terminZeit);
|
||||
@@ -82,65 +162,235 @@ export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
|
||||
? ((item.location as { text?: string }).text ?? "").trim()
|
||||
: "";
|
||||
|
||||
// Status-Pill aus erstem Tag (oder Pattern in Titel).
|
||||
// Erkennt: online, demo, anhörung, infoabend, stammtisch, sitzung
|
||||
const titleLower = title.toLowerCase();
|
||||
const tagList = Array.isArray(item.tags)
|
||||
? (item.tags as unknown[]).map((t) =>
|
||||
typeof t === "string" ? t : (t as { _slug?: string })?._slug ?? "",
|
||||
)
|
||||
: [];
|
||||
const statusInfo = (() => {
|
||||
const candidates = [titleLower, ...tagList.map((t) => t.toLowerCase())];
|
||||
const has = (kw: string) => candidates.some((c) => c.includes(kw));
|
||||
if (has("demo") || has("protest") || has("kundgebung"))
|
||||
return { label: "Demo", color: "#e35651" };
|
||||
if (has("online") || has("zoom") || has("webinar"))
|
||||
return { label: "Online", color: "#436e85" };
|
||||
if (has("anhörung") || has("anhoerung"))
|
||||
return { label: "Anhörung", color: "#b08a52" };
|
||||
if (has("infoabend") || has("infoveranstaltung") || has("info-"))
|
||||
return { label: "Info", color: "#5e6fa3" };
|
||||
if (has("stammtisch") || has("treffen"))
|
||||
return { label: "Stammtisch", color: "#8a567a" };
|
||||
if (has("sitzung") || has("ratssitzung"))
|
||||
return { label: "Sitzung", color: "#6e8c46" };
|
||||
return null;
|
||||
})();
|
||||
|
||||
// Plain-Text-Snippet aus Description (~110 Zeichen) für Snippet unter Titel.
|
||||
let descSnippet = "";
|
||||
if (item.description) {
|
||||
descSnippet = item.description
|
||||
.replace(/[#*_`~>\[\]]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const siteUrl = (env.PUBLIC_SITE_URL || "https://windwiderstand.de").replace(/\/$/, "");
|
||||
const eventUrl = `${siteUrl}/kalender/#event-${slug}`;
|
||||
// QR als PNG (satori/resvg embeddet PNG zuverlässiger als SVG mit kleinen viewBox).
|
||||
const qrPngBuf = await qrToBuffer(eventUrl, {
|
||||
// /termin/[slug] hat eigene OG-Meta-Tags + leitet zu /kalender/#event-<slug>
|
||||
// weiter. Damit greifen Link-Previews (FB/WhatsApp/Twitter).
|
||||
const eventUrl = `${siteUrl}/termin/${encodeURIComponent(slug)}`;
|
||||
// Theme-Palette: drei Varianten — Wald (full-bleed), transparent-dark
|
||||
// (für helle Hintergründe, dunkler Text), transparent-light (für dunkle).
|
||||
const isTransparent = themeParam !== "wald";
|
||||
const isLight = themeParam === "transparent-ondark"; // helle Schrift auf transparent
|
||||
const palette = isTransparent
|
||||
? isLight
|
||||
? {
|
||||
bg: "transparent",
|
||||
text: "#ffffff",
|
||||
textMute: "rgba(255,255,255,0.85)",
|
||||
textDim: "rgba(255,255,255,0.7)",
|
||||
accent: "#a8e6c0",
|
||||
headerLabel: "rgba(255,255,255,0.75)",
|
||||
headerSep: "rgba(255,255,255,0.25)",
|
||||
footerSep: "rgba(255,255,255,0.25)",
|
||||
qrBoxBg: "#ffffff",
|
||||
}
|
||||
: {
|
||||
bg: "transparent",
|
||||
text: "#0a2011",
|
||||
textMute: "#1a4d28",
|
||||
textDim: "#2d7a45",
|
||||
accent: "#1a4d28",
|
||||
headerLabel: "#236335",
|
||||
headerSep: "rgba(10,32,17,0.25)",
|
||||
footerSep: "rgba(10,32,17,0.25)",
|
||||
qrBoxBg: "#0a2011",
|
||||
}
|
||||
: {
|
||||
bg: "linear-gradient(135deg,#0a2011 0%,#12361c 55%,#1a4d28 100%)",
|
||||
text: "#ffffff",
|
||||
textMute: "#d9e8dc",
|
||||
textDim: "#b3d1b9",
|
||||
accent: "#7fb58a",
|
||||
headerLabel: "#7fb58a",
|
||||
headerSep: "#1a4d28",
|
||||
footerSep: "#1a4d28",
|
||||
qrBoxBg: "#ffffff",
|
||||
};
|
||||
|
||||
const isStory = formatParam === "story";
|
||||
const isSquare = formatParam === "square";
|
||||
const verticalStack = isStory || isSquare;
|
||||
|
||||
// Format-spezifische Größen — Title-Fontsize dynamisch, damit lange Titel
|
||||
// (≥30 Zeichen → 2+ Zeilen) keinen Overlap mit Location verursachen.
|
||||
const titleLen = title.length;
|
||||
const titleFs = isStory
|
||||
? titleLen > 70 ? 60 : 76
|
||||
: isSquare
|
||||
? titleLen > 50 ? 44 : 56
|
||||
: titleLen > 35 ? 40 : 52;
|
||||
const dateFs = isStory ? 130 : isSquare ? 84 : 76;
|
||||
const dayFs = isStory ? 38 : isSquare ? 26 : 26;
|
||||
const timeFs = isStory ? 56 : isSquare ? 40 : 40;
|
||||
const padX = isStory ? 90 : isSquare ? 80 : 90;
|
||||
const qrSize = isStory ? 280 : isSquare ? 180 : 180;
|
||||
const headerFs = isStory ? 26 : 20;
|
||||
const locFs = isStory ? 34 : 24;
|
||||
const footerFs = isStory ? 24 : 20;
|
||||
const titleMaxLen = isStory ? 130 : 110;
|
||||
const locMaxLen = isStory ? 90 : 70;
|
||||
|
||||
const titleClamped = title.length > titleMaxLen ? title.slice(0, titleMaxLen - 3) + "…" : title;
|
||||
const locClamped =
|
||||
locationText.length > locMaxLen ? locationText.slice(0, locMaxLen - 3) + "…" : locationText;
|
||||
|
||||
// QR — Farbe an Theme anpassen für transparent-Variante
|
||||
const qrDark = isTransparent && !isLight ? "#0a2011" : "#0a2011";
|
||||
const qrLight = "#ffffff";
|
||||
// QR neu rendern mit Theme-Farben falls nötig (default-Theme nutzt schon dark text)
|
||||
const qrPngBufThemed = await qrToBuffer(eventUrl, {
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
scale: 8,
|
||||
color: { dark: "#0a2011ff", light: "#ffffffff" },
|
||||
color: { dark: `${qrDark}ff`, light: `${qrLight}ff` },
|
||||
});
|
||||
const qrDataUrl = `data:image/png;base64,${qrPngBuf.toString("base64")}`;
|
||||
const qrDataUrlThemed = `data:image/png;base64,${qrPngBufThemed.toString("base64")}`;
|
||||
|
||||
const square = formatParam === "square";
|
||||
const titleFs = square ? 56 : 52;
|
||||
const dateFs = square ? 84 : 76;
|
||||
const padX = square ? 80 : 90;
|
||||
const qrSize = square ? 220 : 180;
|
||||
const titleClamped = title.length > 110 ? title.slice(0, 107) + "…" : title;
|
||||
const locClamped =
|
||||
locationText.length > 70 ? locationText.slice(0, 67) + "…" : locationText;
|
||||
|
||||
const pinSvg = `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 22s7-7.58 7-13a7 7 0 1 0-14 0c0 5.42 7 13 7 13z" fill="#7fb58a"/><circle cx="12" cy="9" r="2.5" fill="#0a2011"/></svg>`;
|
||||
// Pin-Icon Theme-aware
|
||||
const pinFill = isTransparent ? (isLight ? "#ffffff" : "#1a4d28") : "#7fb58a";
|
||||
const pinDot = isTransparent ? (isLight ? "#0a2011" : "#ffffff") : "#0a2011";
|
||||
const pinSvg = `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 22s7-7.58 7-13a7 7 0 1 0-14 0c0 5.42 7 13 7 13z" fill="${pinFill}"/><circle cx="12" cy="9" r="2.5" fill="${pinDot}"/></svg>`;
|
||||
const pinDataUrl = `data:image/svg+xml;base64,${Buffer.from(pinSvg).toString("base64")}`;
|
||||
|
||||
const template = `
|
||||
<div style="display:flex;flex-direction:column;width:${w}px;height:${h}px;background:linear-gradient(135deg,#0a2011 0%,#12361c 55%,#1a4d28 100%);color:#f0f5f1;padding:${padX}px;font-family:Inter;position:relative;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:20px;letter-spacing:0.18em;font-weight:600;color:#7fb58a;text-transform:uppercase;">
|
||||
<div style="display:flex;align-items:center;">Windwiderstand Thüringen</div>
|
||||
<div style="display:flex;align-items:center;color:#b3d1b9;">Termin</div>
|
||||
</div>
|
||||
// Clock-Icon Theme-aware — gleiche Akzent-Farbe wie Pin
|
||||
const clockStroke = pinFill;
|
||||
const clockSvg = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="${clockStroke}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 14"/></svg>`;
|
||||
const clockDataUrl = `data:image/svg+xml;base64,${Buffer.from(clockSvg).toString("base64")}`;
|
||||
const clockSize = isStory ? 40 : 30;
|
||||
|
||||
<div style="display:flex;margin-top:${square ? 48 : 24}px;flex:1;gap:40px;">
|
||||
<div style="display:flex;flex-direction:column;flex:1;min-width:0;">
|
||||
<div style="display:flex;font-size:26px;color:#b3d1b9;font-weight:500;text-transform:capitalize;margin-bottom:6px;">${day}</div>
|
||||
<div style="display:flex;align-items:baseline;flex-wrap:wrap;">
|
||||
<div style="display:flex;font-size:${dateFs}px;font-weight:700;color:#ffffff;letter-spacing:-0.02em;line-height:1;">${date}</div>
|
||||
${time ? `<div style="display:flex;font-size:40px;font-weight:600;color:#d9e8dc;margin-left:24px;line-height:1;">${time} Uhr</div>` : ""}
|
||||
</div>
|
||||
// Logo als data-URL, gefärbt im Theme-Farbton.
|
||||
const logoColor = palette.headerLabel;
|
||||
const logoDataUrl = logoSvg
|
||||
? `data:image/svg+xml;base64,${Buffer.from(colorizeLogo(logoSvg, logoColor)).toString("base64")}`
|
||||
: null;
|
||||
const logoH = isStory ? 60 : isSquare ? 44 : 36;
|
||||
|
||||
<div style="display:flex;font-size:${titleFs}px;font-weight:700;color:#ffffff;line-height:1.15;margin-top:${square ? 40 : 26}px;letter-spacing:-0.015em;">${escapeHtml(titleClamped)}</div>
|
||||
</div>
|
||||
// Status-Pill (rechts neben "TERMIN"): farbiger Hintergrund, weiße Schrift
|
||||
const statusPill = statusInfo
|
||||
? `<div style="display:flex;align-items:center;background:${statusInfo.color};color:#ffffff;padding:6px 14px;border-radius:999px;font-size:${headerFs - 4}px;letter-spacing:0.12em;font-weight:700;text-transform:uppercase;">${escapeHtml(statusInfo.label)}</div>`
|
||||
: `<div style="display:flex;align-items:center;color:${palette.textDim};">Termin</div>`;
|
||||
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
|
||||
<div style="display:flex;background:#ffffff;padding:14px;border-radius:12px;">
|
||||
<img src="${qrDataUrl}" style="width:${qrSize}px;height:${qrSize}px;" />
|
||||
</div>
|
||||
<div style="display:flex;font-size:16px;color:#b3d1b9;font-weight:500;letter-spacing:0.05em;">Mehr Infos</div>
|
||||
</div>
|
||||
</div>
|
||||
const brandBlock = logoDataUrl
|
||||
? `<div style="display:flex;align-items:center;"><img src="${logoDataUrl}" width="${logoH}" height="${logoH}" style="width:${logoH}px;height:${logoH}px;" /><div style="display:flex;margin-left:14px;">Windwiderstand Thüringen</div></div>`
|
||||
: `<div style="display:flex;">Windwiderstand Thüringen</div>`;
|
||||
|
||||
const headerRow = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:${headerFs}px;letter-spacing:0.18em;font-weight:600;color:${palette.headerLabel};text-transform:uppercase;">
|
||||
${brandBlock}
|
||||
${statusPill}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const datumBlock = `
|
||||
<div style="display:flex;flex-direction:column;">
|
||||
<div style="display:flex;font-size:${dayFs}px;color:${palette.textDim};font-weight:500;text-transform:capitalize;margin-bottom:6px;">${day}</div>
|
||||
<div style="display:flex;font-size:${dateFs}px;font-weight:700;color:${palette.text};letter-spacing:-0.02em;line-height:1;">${date}</div>
|
||||
${time ? `<div style="display:flex;align-items:center;gap:10px;font-size:${timeFs}px;font-weight:600;color:${palette.textMute};margin-top:${isStory ? 14 : 8}px;line-height:1;"><img src="${clockDataUrl}" style="width:${clockSize}px;height:${clockSize}px;" /><span style="display:flex;">${time.trim()} Uhr</span></div>` : ""}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Description-Snippet: nur bei Square + Story (OG zu wenig vertikaler Platz)
|
||||
const showDesc = isSquare || isStory;
|
||||
const descMaxLen = isStory ? 200 : 140;
|
||||
const descClamped = showDesc
|
||||
? (descSnippet.length > descMaxLen ? descSnippet.slice(0, descMaxLen - 3) + "…" : descSnippet)
|
||||
: "";
|
||||
const descFs = isStory ? 30 : 24;
|
||||
|
||||
const titleBlock = `
|
||||
<div style="display:flex;flex-direction:column;margin-top:${isStory ? 56 : isSquare ? 40 : 26}px;">
|
||||
<div style="display:flex;font-size:${titleFs}px;font-weight:700;color:${palette.text};line-height:1.15;letter-spacing:-0.015em;">${escapeHtml(titleClamped)}</div>
|
||||
${
|
||||
locClamped
|
||||
? `<div style="display:flex;align-items:center;font-size:24px;color:#d9e8dc;font-weight:500;margin-bottom:24px;gap:12px;"><img src="${pinDataUrl}" style="width:28px;height:28px;" /><div style="display:flex;">${escapeHtml(locClamped)}</div></div>`
|
||||
descClamped
|
||||
? `<div style="display:flex;font-size:${descFs}px;font-weight:400;color:${palette.textMute};line-height:1.35;margin-top:${isStory ? 24 : 14}px;">${escapeHtml(descClamped)}</div>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:20px;color:#7fb58a;border-top:1px solid #1a4d28;padding-top:18px;">
|
||||
// OG bleibt mit rechter Spalte (align-items:center). Square/Story stack linksbündig.
|
||||
const qrAlign = verticalStack ? "flex-start" : "center";
|
||||
const qrBlock = `
|
||||
<div style="display:flex;flex-direction:column;align-items:${qrAlign};gap:8px;">
|
||||
<div style="display:flex;background:${palette.qrBoxBg};padding:14px;border-radius:12px;">
|
||||
<img src="${qrDataUrlThemed}" style="width:${qrSize}px;height:${qrSize}px;" />
|
||||
</div>
|
||||
<div style="display:flex;font-size:${isStory ? 22 : 16}px;color:${palette.textDim};font-weight:500;letter-spacing:0.05em;">Mehr Infos</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const locationBlock = locClamped
|
||||
? `<div style="display:flex;align-items:flex-start;font-size:${locFs}px;color:${palette.textMute};font-weight:500;line-height:1.25;margin-top:${isStory ? 36 : 28}px;margin-bottom:${isStory ? 40 : 24}px;gap:12px;flex-shrink:0;"><img src="${pinDataUrl}" style="width:${isStory ? 36 : 28}px;height:${isStory ? 36 : 28}px;margin-top:${isStory ? 4 : 2}px;flex-shrink:0;" /><div style="display:flex;">${escapeHtml(locClamped)}</div></div>`
|
||||
: "";
|
||||
|
||||
const footerBlock = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:${footerFs}px;color:${palette.accent};border-top:1px solid ${palette.footerSep};padding-top:${isStory ? 24 : 18}px;">
|
||||
<div style="display:flex;align-items:center;font-weight:600;">windwiderstand.de</div>
|
||||
<div style="display:flex;align-items:center;font-weight:500;letter-spacing:0.04em;">Mit Vernunft in die Zukunft</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Story + Square: vertikales Stack-Layout (QR unter Text). OG: 2-Spalten (Text + QR rechts).
|
||||
const mainArea = verticalStack
|
||||
? `
|
||||
<div style="display:flex;flex-direction:column;margin-top:${isStory ? 60 : 40}px;flex:1;">
|
||||
${datumBlock}
|
||||
${titleBlock}
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-start;margin-top:${isStory ? 40 : 28}px;margin-bottom:${isStory ? 40 : 24}px;">
|
||||
${qrBlock}
|
||||
</div>
|
||||
`
|
||||
: `
|
||||
<div style="display:flex;margin-top:24px;flex:1;gap:40px;">
|
||||
<div style="display:flex;flex-direction:column;flex:1;min-width:0;">
|
||||
${datumBlock}
|
||||
${titleBlock}
|
||||
</div>
|
||||
${qrBlock}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const template = `
|
||||
<div style="display:flex;flex-direction:column;width:${w}px;height:${h}px;background:${palette.bg};color:${palette.text};padding:${padX}px;font-family:Inter;position:relative;">
|
||||
${headerRow}
|
||||
${mainArea}
|
||||
${locationBlock}
|
||||
${footerBlock}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -157,7 +407,10 @@ export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
|
||||
],
|
||||
});
|
||||
|
||||
const resvg = new Resvg(svg, { fitTo: { mode: "width", value: w } });
|
||||
const resvg = new Resvg(svg, {
|
||||
fitTo: { mode: "width", value: w },
|
||||
background: isTransparent ? "rgba(0,0,0,0)" : undefined,
|
||||
});
|
||||
const png = resvg.render().asPng();
|
||||
const pngBuf = Buffer.from(png);
|
||||
cache.set(cacheKey, { png: pngBuf, ts: now });
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* Crawler bekommen die Meta-Tags zu sehen, normale Browser werden via
|
||||
* Client-Side-Redirect (im +page.svelte) sofort zum Kalender geschickt.
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
const siteBaseUrl = (env.PUBLIC_SITE_URL || "").replace(/\/$/, "");
|
||||
const title = (item.title ?? "Termin").trim();
|
||||
const terminZeit = item.terminZeit;
|
||||
const dateLabel = terminZeit
|
||||
? new Date(terminZeit).toLocaleDateString("de-DE", {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
: "";
|
||||
const timeLabel =
|
||||
terminZeit && !/T00:00:00/.test(terminZeit)
|
||||
? new Date(terminZeit).toLocaleTimeString("de-DE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}) + " Uhr"
|
||||
: "";
|
||||
|
||||
const locationText =
|
||||
typeof item.location === "object" && item.location !== null
|
||||
? (((item.location as { text?: string }).text ?? "").trim() || null)
|
||||
: null;
|
||||
|
||||
// Plain-Text-Snippet aus Description (Markdown → Text, ~200 Zeichen)
|
||||
let descriptionPlain = "";
|
||||
if (item.description) {
|
||||
const html = marked.parse(item.description) as string;
|
||||
descriptionPlain = html
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
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}`;
|
||||
|
||||
// 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",
|
||||
});
|
||||
|
||||
return {
|
||||
seoTitle: `${title} – Windwiderstand Thüringen`,
|
||||
seoDescription,
|
||||
socialImage,
|
||||
ogType: "article" as const,
|
||||
calendarUrl,
|
||||
item: {
|
||||
slug,
|
||||
title,
|
||||
dateLabel,
|
||||
timeLabel,
|
||||
locationText,
|
||||
descriptionPlain: descriptionPlain.slice(0, 400),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
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);
|
||||
});
|
||||
</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>
|
||||
|
||||
<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}` : ""}
|
||||
</p>
|
||||
{/if}
|
||||
{#if data.item.locationText}
|
||||
<p class="mt-1 text-stein-500">📍 {data.item.locationText}</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>
|
||||
<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"
|
||||
>
|
||||
Im Kalender anzeigen
|
||||
</a>
|
||||
</div>
|
||||
Reference in New Issue
Block a user