From 287a44ff6b9ac4e9b106d209f034b496b890ab8b Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Sun, 7 Jun 2026 13:02:47 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Social-Bild=20erweitert=20(Logo,=20Stat?= =?UTF-8?q?us-Pill,=20Description,=20Story,=20Themes)=20+=20OG-Route=20+?= =?UTF-8?q?=20Calendar-Buttons=20aufger=C3=A4umt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-. 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 --- .../components/blocks/CalendarBlock.svelte | 354 ++++++++++-------- .../api/social/calendar/[slug]/+server.ts | 351 ++++++++++++++--- src/routes/termin/[slug]/+page.server.ts | 90 +++++ src/routes/termin/[slug]/+page.svelte | 42 +++ 4 files changed, 630 insertions(+), 207 deletions(-) create mode 100644 src/routes/termin/[slug]/+page.server.ts create mode 100644 src/routes/termin/[slug]/+page.svelte diff --git a/src/lib/components/blocks/CalendarBlock.svelte b/src/lib/components/blocks/CalendarBlock.svelte index cc650a0..ba81535 100644 --- a/src/lib/components/blocks/CalendarBlock.svelte +++ b/src/lib/components/blocks/CalendarBlock.svelte @@ -447,6 +447,7 @@ let modalImage = $state<{ src: string; alt: string } | null>(null); let linkedSlug = $state(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,166 +768,136 @@ {/if} {/if} -
- {#if eventHref} + +
+ {#if eventHref || locText || item.attachment?.src} +
+ {#if eventHref} + + {t(T.calendar_more_info)} + + {/if} + {#if locText} + + + {/if} + {#if item.attachment?.src} + {@const dlSrc = `/cms-files?src=${encodeURIComponent(item.attachment.src)}&dl=1`} + + + {/if} +
+ {/if} + + +
+ + Kalender + - {t(T.calendar_more_info)} - - {/if} - {#if locText} - - + - - - {#if feedHost} - {@const shareKey = - item._slug ?? String(item.start.getTime())} - {@const copied = copyToastKey === shareKey} + + {shared ? t(T.calendar_share_copied) : t(T.calendar_share)} + + {#if item._slug} + + {/if} + {/if} + + - {@const shared = shareToastKey === shareKey} - - {/if} - {#if item._slug} - - {/if} - +
@@ -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)]"} diff --git a/src/routes/api/social/calendar/[slug]/+server.ts b/src/routes/api/social/calendar/[slug]/+server.ts index 5874a95..9157a66 100644 --- a/src/routes/api/social/calendar/[slug]/+server.ts +++ b/src/routes/api/social/calendar/[slug]/+server.ts @@ -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 = { og: { w: 1200, h: 630 }, square: { w: 1080, h: 1080 }, + story: { w: 1080, h: 1920 }, }; const FONT_URLS: Record = { @@ -22,6 +29,61 @@ const FONT_URLS: Record = { let fontCache: { regular: ArrayBuffer; semibold: ArrayBuffer; bold: ArrayBuffer } | null = null; +let logoSvgCache: { svg: string; recolored: Record } | 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 { + 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 . + text = text.replace(/<\?xml[^?]*\?>/g, "").replace(/]*>/g, "").trim(); + if (!text.startsWith("(); 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- + // 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 = ``; + // Pin-Icon Theme-aware + const pinFill = isTransparent ? (isLight ? "#ffffff" : "#1a4d28") : "#7fb58a"; + const pinDot = isTransparent ? (isLight ? "#0a2011" : "#ffffff") : "#0a2011"; + const pinSvg = ``; const pinDataUrl = `data:image/svg+xml;base64,${Buffer.from(pinSvg).toString("base64")}`; - const template = ` -
-
-
Windwiderstand Thüringen
-
Termin
-
+ // Clock-Icon Theme-aware — gleiche Akzent-Farbe wie Pin + const clockStroke = pinFill; + const clockSvg = ``; + const clockDataUrl = `data:image/svg+xml;base64,${Buffer.from(clockSvg).toString("base64")}`; + const clockSize = isStory ? 40 : 30; -
-
-
${day}
-
-
${date}
- ${time ? `
${time} Uhr
` : ""} -
+ // 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; -
${escapeHtml(titleClamped)}
-
+ // Status-Pill (rechts neben "TERMIN"): farbiger Hintergrund, weiße Schrift + const statusPill = statusInfo + ? `
${escapeHtml(statusInfo.label)}
` + : `
Termin
`; -
-
- -
-
Mehr Infos
-
-
+ const brandBlock = logoDataUrl + ? `
Windwiderstand Thüringen
` + : `
Windwiderstand Thüringen
`; + const headerRow = ` +
+ ${brandBlock} + ${statusPill} +
+ `; + + const datumBlock = ` +
+
${day}
+
${date}
+ ${time ? `
${time.trim()} Uhr
` : ""} +
+ `; + + // 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 = ` +
+
${escapeHtml(titleClamped)}
${ - locClamped - ? `
${escapeHtml(locClamped)}
` + descClamped + ? `
${escapeHtml(descClamped)}
` : "" } +
+ `; -
-
windwiderstand.de
-
Mit Vernunft in die Zukunft
+ // OG bleibt mit rechter Spalte (align-items:center). Square/Story stack linksbündig. + const qrAlign = verticalStack ? "flex-start" : "center"; + const qrBlock = ` +
+
+
+
Mehr Infos
+
+ `; + + const locationBlock = locClamped + ? `
${escapeHtml(locClamped)}
` + : ""; + + const footerBlock = ` +
+
windwiderstand.de
+
Mit Vernunft in die Zukunft
+
+ `; + + // Story + Square: vertikales Stack-Layout (QR unter Text). OG: 2-Spalten (Text + QR rechts). + const mainArea = verticalStack + ? ` +
+ ${datumBlock} + ${titleBlock} +
+
+ ${qrBlock} +
+ ` + : ` +
+
+ ${datumBlock} + ${titleBlock} +
+ ${qrBlock} +
+ `; + + const template = ` +
+ ${headerRow} + ${mainArea} + ${locationBlock} + ${footerBlock}
`; @@ -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 }); diff --git a/src/routes/termin/[slug]/+page.server.ts b/src/routes/termin/[slug]/+page.server.ts new file mode 100644 index 0000000..bfce940 --- /dev/null +++ b/src/routes/termin/[slug]/+page.server.ts @@ -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- + * (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), + }, + }; +}; diff --git a/src/routes/termin/[slug]/+page.svelte b/src/routes/termin/[slug]/+page.svelte new file mode 100644 index 0000000..058de86 --- /dev/null +++ b/src/routes/termin/[slug]/+page.svelte @@ -0,0 +1,42 @@ + + + + + + + + +
+

Termin

+

{data.item.title}

+ {#if data.item.dateLabel} +

+ {data.item.dateLabel}{data.item.timeLabel ? `, ${data.item.timeLabel}` : ""} +

+ {/if} + {#if data.item.locationText} +

📍 {data.item.locationText}

+ {/if} + {#if data.item.descriptionPlain} +

{data.item.descriptionPlain}

+ {/if} +

Du wirst gerade zum Kalender weitergeleitet…

+ + Im Kalender anzeigen + +