feat: Social-Bild erweitert (Logo, Status-Pill, Description, Story, Themes) + OG-Route + Calendar-Buttons aufgeräumt
Deploy / verify (push) Successful in 1m0s
Deploy / deploy (push) Successful in 1m19s

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:
Peter Meier
2026-06-07 13:02:47 +02:00
parent 6d1a084973
commit 287a44ff6b
4 changed files with 630 additions and 207 deletions
+196 -158
View File
@@ -447,6 +447,7 @@
let modalImage = $state<{ src: string; alt: string } | null>(null); let modalImage = $state<{ src: string; alt: string } | null>(null);
let linkedSlug = $state<string | null>(null); let linkedSlug = $state<string | null>(null);
let socialModal = $state<{ slug: string; title: 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) { function openSocial(item: EventCardItem) {
if (!item._slug) return; if (!item._slug) return;
@@ -767,166 +768,136 @@
{/if} {/if}
{/if} {/if}
<div class="flex flex-wrap gap-2 pt-1 text-xs"> <!-- Aktionen: Primary-Reihe (Info/Karte/Anhang) + kompakte Action-Toolbar -->
{#if eventHref} <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}
target="_blank"
rel="noopener noreferrer"
class="btn-tertiary btn-sm"
>
{t(T.calendar_more_info)}
<Icon
icon="mdi:open-in-new"
class="size-3.5"
aria-hidden="true"
/>
</a>
{/if}
{#if locText}
<a
href={mapsUrl(locText)}
target="_blank"
rel="noopener noreferrer"
class="btn-outline btn-sm group"
aria-label={t(T.calendar_open_maps)}
>
<Icon
icon="mdi:map-marker"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{t(T.calendar_open_maps)}</span>
</a>
{/if}
{#if item.attachment?.src}
{@const dlSrc = `/cms-files?src=${encodeURIComponent(item.attachment.src)}&dl=1`}
<a
href={dlSrc}
class="btn-outline btn-sm group"
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"}</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="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" />
ICS
</button>
<a <a
href={eventHref} href={googleCalUrl(toICS(item))}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="btn-tertiary btn-sm" 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)}
> >
{t(T.calendar_more_info)} <Icon icon="mdi:google" class="size-3.5" />
<Icon Google
icon="mdi:open-in-new"
class="size-3.5"
aria-hidden="true"
/>
</a> </a>
{/if}
{#if locText} {#if feedHost}
<a {@const shareKey = item._slug ?? String(item.start.getTime())}
href={mapsUrl(locText)} {@const copied = copyToastKey === shareKey}
target="_blank" {@const shared = shareToastKey === shareKey}
rel="noopener noreferrer" <span class="mx-1 h-4 w-px bg-stein-300" aria-hidden="true"></span>
class="btn-outline btn-sm group" <span class="px-1.5 text-[10px] uppercase tracking-wide text-stein-400">Teilen</span>
aria-label={t(T.calendar_open_maps)} <button
> type="button"
<Icon onclick={() => copyEventLink(item)}
icon="mdi:map-marker" class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-700 hover:bg-white"
class="size-3.5 shrink-0" title={t(T.post_action_copy)}
aria-hidden="true" aria-label={t(T.post_action_copy)}
/>
<span class="whitespace-nowrap"
>{t(T.calendar_open_maps)}</span
> >
</a> <Icon icon={copied ? "mdi:check" : "mdi:link-variant"} class="size-3.5" />
{/if} {copied ? t(T.post_action_copied) : "Link"}
{#if item.attachment?.src} </button>
{@const dlSrc = `/cms-files?src=${encodeURIComponent(item.attachment.src)}&dl=1`} <button
<a type="button"
href={dlSrc} 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"
aria-label={item.attachment.label || title={t(T.calendar_share_aria)}
"Anhang herunterladen"} aria-label={t(T.calendar_share_aria)}
>
<Icon
icon="mdi:file-download"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap"
>{item.attachment.label ||
"Anhang herunterladen"}</span
> >
</a> <Icon icon={shared ? "mdi:check" : "mdi:share-variant"} class="size-3.5" />
{/if} {shared ? t(T.calendar_share_copied) : t(T.calendar_share)}
<button </button>
type="button" {#if item._slug}
onclick={() => <button
downloadICS(toICS(item), fileSlug(title))} type="button"
class="btn-outline btn-sm group" onclick={() => openSocial(item)}
aria-label={t(T.calendar_download_ics)} 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"
<Icon aria-label="Social-Bild Vorschau"
icon="mdi:download" >
class="size-3.5 shrink-0" <Icon icon="mdi:image-outline" class="size-3.5" />
aria-hidden="true" Bild
/> </button>
<span class="whitespace-nowrap" {/if}
>{t(T.calendar_download_ics)}</span {/if}
>
</button> <span class="ml-auto"></span>
<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
>
</a>
{#if feedHost}
{@const shareKey =
item._slug ?? String(item.start.getTime())}
{@const copied = copyToastKey === shareKey}
<button <button
type="button" type="button"
onclick={() => copyEventLink(item)} 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"
aria-label={t(T.post_action_copy)} title={t(T.calendar_jump_to_month)}
aria-label={t(T.calendar_jump_to_month)}
> >
<Icon <Icon icon="mdi:calendar-search" class="size-3.5" />
icon={copied Im Grid
? "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
>
</button> </button>
{@const shared = shareToastKey === shareKey} </div>
<button
type="button"
onclick={() => shareEvent(item, title, locText)}
class="btn-outline btn-sm group"
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
>
</button>
{/if}
{#if item._slug}
<button
type="button"
onclick={() => openSocial(item)}
class="btn-outline btn-sm group"
aria-label="Social-Bild als Vorschau öffnen"
title="Social-Bild Vorschau"
>
<Icon
icon="mdi:image-outline"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">Social-Bild</span>
</button>
{/if}
<button
type="button"
onclick={() => jumpToEventInGrid(item)}
class="btn-outline btn-sm group"
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
>
</button>
</div> </div>
</div> </div>
</details> </details>
@@ -1323,8 +1294,16 @@
{/if} {/if}
{#if socialModal} {#if socialModal}
{@const ogUrl = `/api/social/calendar/${socialModal.slug}?format=og`} {@const themeQ = socialTheme === "wald" ? "" : `&theme=${socialTheme}`}
{@const sqUrl = `/api/social/calendar/${socialModal.slug}?format=square`} {@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 <div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4"
role="dialog" role="dialog"
@@ -1335,7 +1314,7 @@
tabindex="-1" tabindex="-1"
> >
<div <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()} onclick={(e) => e.stopPropagation()}
role="presentation" role="presentation"
> >
@@ -1354,18 +1333,59 @@
</button> </button>
</div> </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"> <div class="flex flex-col gap-2">
<p class="text-xs font-medium text-stein-600">Quer · 1200 × 630</p> <p class="text-xs font-medium text-stein-600">Quer · 1200 × 630</p>
<img <img
src={ogUrl} src={ogUrl}
alt="Social-Bild Quer" 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" loading="eager"
/> />
<a <a
href={ogUrl} href={ogUrl}
download={`termin-${socialModal.slug}-og.png`} download={`termin-${socialModal.slug}-og-${socialTheme}.png`}
class="btn-outline btn-sm justify-center no-underline!" class="btn-outline btn-sm justify-center no-underline!"
> >
<Icon icon="mdi:download" class="size-3.5" /> <Icon icon="mdi:download" class="size-3.5" />
@@ -1377,22 +1397,40 @@
<img <img
src={sqUrl} src={sqUrl}
alt="Social-Bild Quadrat" 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" loading="eager"
/> />
<a <a
href={sqUrl} href={sqUrl}
download={`termin-${socialModal.slug}-square.png`} download={`termin-${socialModal.slug}-square-${socialTheme}.png`}
class="btn-outline btn-sm justify-center no-underline!" class="btn-outline btn-sm justify-center no-underline!"
> >
<Icon icon="mdi:download" class="size-3.5" /> <Icon icon="mdi:download" class="size-3.5" />
Quadrat herunterladen Quadrat herunterladen
</a> </a>
</div> </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> </div>
<p class="mt-4 text-[11px] text-stein-400"> <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> </p>
</div> </div>
</div> </div>
+302 -49
View File
@@ -5,13 +5,20 @@ import { html as htmlToReact } from "satori-html";
import { Resvg } from "@resvg/resvg-js"; import { Resvg } from "@resvg/resvg-js";
import { toBuffer as qrToBuffer } from "qrcode"; import { toBuffer as qrToBuffer } from "qrcode";
import { env } from "$env/dynamic/public"; 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 }> = { const SIZES: Record<Format, { w: number; h: number }> = {
og: { w: 1200, h: 630 }, og: { w: 1200, h: 630 },
square: { w: 1080, h: 1080 }, square: { w: 1080, h: 1080 },
story: { w: 1080, h: 1920 },
}; };
const FONT_URLS: Record<string, string> = { 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 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() { async function loadFonts() {
if (fontCache) return fontCache; if (fontCache) return fontCache;
const [regular, semibold, bold] = await Promise.all( 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 = new Map<string, { png: Buffer; ts: number }>();
const CACHE_TTL = 10 * 60_000; // 10min 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 }) => { export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
const slug = params.slug; const slug = params.slug;
const formatParam = url.searchParams.get("format") === "square" ? "square" : "og"; const formatParam = parseFormat(url.searchParams.get("format"));
const cacheKey = `${CACHE_VERSION}:${slug}:${formatParam}`; const themeParam = parseTheme(url.searchParams.get("theme"));
const cacheKey = `${CACHE_VERSION}:${slug}:${formatParam}:${themeParam}`;
const now = Date.now(); const now = Date.now();
const cached = cache.get(cacheKey); const cached = cache.get(cacheKey);
if (cached && now - cached.ts < CACHE_TTL) { 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" }); const itemRaw = await getCalendarItemBySlug(slug, { locale: "de" });
if (!itemRaw) throw error(404, "Termin nicht gefunden"); 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 { w, h } = SIZES[formatParam];
const fonts = await loadFonts(); const fonts = await loadFonts();
const logoSvg = await loadLogo();
const title = (item.title ?? "").trim(); const title = (item.title ?? "").trim();
const { date, time } = formatDate(item.terminZeit); 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() ? ((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 siteUrl = (env.PUBLIC_SITE_URL || "https://windwiderstand.de").replace(/\/$/, "");
const eventUrl = `${siteUrl}/kalender/#event-${slug}`; // /termin/[slug] hat eigene OG-Meta-Tags + leitet zu /kalender/#event-<slug>
// QR als PNG (satori/resvg embeddet PNG zuverlässiger als SVG mit kleinen viewBox). // weiter. Damit greifen Link-Previews (FB/WhatsApp/Twitter).
const qrPngBuf = await qrToBuffer(eventUrl, { 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", errorCorrectionLevel: "M",
margin: 1, margin: 1,
scale: 8, 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"; // Pin-Icon Theme-aware
const titleFs = square ? 56 : 52; const pinFill = isTransparent ? (isLight ? "#ffffff" : "#1a4d28") : "#7fb58a";
const dateFs = square ? 84 : 76; const pinDot = isTransparent ? (isLight ? "#0a2011" : "#ffffff") : "#0a2011";
const padX = square ? 80 : 90; 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 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>`;
const pinDataUrl = `data:image/svg+xml;base64,${Buffer.from(pinSvg).toString("base64")}`; const pinDataUrl = `data:image/svg+xml;base64,${Buffer.from(pinSvg).toString("base64")}`;
const template = ` // Clock-Icon Theme-aware — gleiche Akzent-Farbe wie Pin
<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;"> const clockStroke = pinFill;
<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;"> 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>`;
<div style="display:flex;align-items:center;">Windwiderstand Thüringen</div> const clockDataUrl = `data:image/svg+xml;base64,${Buffer.from(clockSvg).toString("base64")}`;
<div style="display:flex;align-items:center;color:#b3d1b9;">Termin</div> const clockSize = isStory ? 40 : 30;
</div>
<div style="display:flex;margin-top:${square ? 48 : 24}px;flex:1;gap:40px;"> // Logo als data-URL, gefärbt im Theme-Farbton.
<div style="display:flex;flex-direction:column;flex:1;min-width:0;"> const logoColor = palette.headerLabel;
<div style="display:flex;font-size:26px;color:#b3d1b9;font-weight:500;text-transform:capitalize;margin-bottom:6px;">${day}</div> const logoDataUrl = logoSvg
<div style="display:flex;align-items:baseline;flex-wrap:wrap;"> ? `data:image/svg+xml;base64,${Buffer.from(colorizeLogo(logoSvg, logoColor)).toString("base64")}`
<div style="display:flex;font-size:${dateFs}px;font-weight:700;color:#ffffff;letter-spacing:-0.02em;line-height:1;">${date}</div> : null;
${time ? `<div style="display:flex;font-size:40px;font-weight:600;color:#d9e8dc;margin-left:24px;line-height:1;">${time} Uhr</div>` : ""} const logoH = isStory ? 60 : isSquare ? 44 : 36;
</div>
<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> // Status-Pill (rechts neben "TERMIN"): farbiger Hintergrund, weiße Schrift
</div> 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;"> const brandBlock = logoDataUrl
<div style="display:flex;background:#ffffff;padding:14px;border-radius:12px;"> ? `<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>`
<img src="${qrDataUrl}" style="width:${qrSize}px;height:${qrSize}px;" /> : `<div style="display:flex;">Windwiderstand Thüringen</div>`;
</div>
<div style="display:flex;font-size:16px;color:#b3d1b9;font-weight:500;letter-spacing:0.05em;">Mehr Infos</div>
</div>
</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 descClamped
? `<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>` ? `<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.
<div style="display:flex;align-items:center;font-weight:600;">windwiderstand.de</div> const qrAlign = verticalStack ? "flex-start" : "center";
<div style="display:flex;align-items:center;font-weight:500;letter-spacing:0.04em;">Mit Vernunft in die Zukunft</div> 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>
<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> </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 png = resvg.render().asPng();
const pngBuf = Buffer.from(png); const pngBuf = Buffer.from(png);
cache.set(cacheKey, { png: pngBuf, ts: now }); cache.set(cacheKey, { png: pngBuf, ts: now });
+90
View File
@@ -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),
},
};
};
+42
View File
@@ -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>