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 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,7 +768,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/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}
|
{#if eventHref}
|
||||||
<a
|
<a
|
||||||
href={eventHref}
|
href={eventHref}
|
||||||
@@ -796,9 +800,7 @@
|
|||||||
class="size-3.5 shrink-0"
|
class="size-3.5 shrink-0"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span class="whitespace-nowrap"
|
<span class="whitespace-nowrap">{t(T.calendar_open_maps)}</span>
|
||||||
>{t(T.calendar_open_maps)}</span
|
|
||||||
>
|
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.attachment?.src}
|
{#if item.attachment?.src}
|
||||||
@@ -806,129 +808,98 @@
|
|||||||
<a
|
<a
|
||||||
href={dlSrc}
|
href={dlSrc}
|
||||||
class="btn-outline btn-sm group"
|
class="btn-outline btn-sm group"
|
||||||
aria-label={item.attachment.label ||
|
aria-label={item.attachment.label || "Anhang herunterladen"}
|
||||||
"Anhang herunterladen"}
|
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon="mdi:file-download"
|
icon="mdi:file-download"
|
||||||
class="size-3.5 shrink-0"
|
class="size-3.5 shrink-0"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span class="whitespace-nowrap"
|
<span class="whitespace-nowrap">{item.attachment.label || "Anhang"}</span>
|
||||||
>{item.attachment.label ||
|
|
||||||
"Anhang herunterladen"}</span
|
|
||||||
>
|
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() =>
|
onclick={() => downloadICS(toICS(item), fileSlug(title))}
|
||||||
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"
|
||||||
class="btn-outline btn-sm group"
|
title={t(T.calendar_download_ics)}
|
||||||
aria-label={t(T.calendar_download_ics)}
|
aria-label={t(T.calendar_download_ics)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon="mdi:download" class="size-3.5" />
|
||||||
icon="mdi:download"
|
ICS
|
||||||
class="size-3.5 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span class="whitespace-nowrap"
|
|
||||||
>{t(T.calendar_download_ics)}</span
|
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
href={googleCalUrl(toICS(item))}
|
href={googleCalUrl(toICS(item))}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="btn-outline btn-sm group"
|
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-700 no-underline hover:bg-white"
|
||||||
aria-label={t(T.calendar_add_to_google)}
|
title={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
|
|
||||||
>
|
>
|
||||||
|
<Icon icon="mdi:google" class="size-3.5" />
|
||||||
|
Google
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{#if feedHost}
|
{#if feedHost}
|
||||||
{@const shareKey =
|
{@const shareKey = item._slug ?? String(item.start.getTime())}
|
||||||
item._slug ?? String(item.start.getTime())}
|
|
||||||
{@const copied = copyToastKey === shareKey}
|
{@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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => copyEventLink(item)}
|
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)}
|
aria-label={t(T.post_action_copy)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon={copied ? "mdi:check" : "mdi:link-variant"} class="size-3.5" />
|
||||||
icon={copied
|
{copied ? t(T.post_action_copied) : "Link"}
|
||||||
? "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}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => shareEvent(item, title, locText)}
|
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)}
|
aria-label={t(T.calendar_share_aria)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon={shared ? "mdi:check" : "mdi:share-variant"} class="size-3.5" />
|
||||||
icon={shared
|
{shared ? t(T.calendar_share_copied) : t(T.calendar_share)}
|
||||||
? "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>
|
</button>
|
||||||
{/if}
|
|
||||||
{#if item._slug}
|
{#if item._slug}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => openSocial(item)}
|
onclick={() => openSocial(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"
|
||||||
aria-label="Social-Bild als Vorschau öffnen"
|
|
||||||
title="Social-Bild Vorschau"
|
title="Social-Bild Vorschau"
|
||||||
|
aria-label="Social-Bild Vorschau"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon="mdi:image-outline" class="size-3.5" />
|
||||||
icon="mdi:image-outline"
|
Bild
|
||||||
class="size-3.5 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span class="whitespace-nowrap">Social-Bild</span>
|
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class="ml-auto"></span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => jumpToEventInGrid(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"
|
||||||
|
title={t(T.calendar_jump_to_month)}
|
||||||
aria-label={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="mdi:calendar-search"
|
Im Grid
|
||||||
class="size-3.5 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span class="whitespace-nowrap"
|
|
||||||
>{t(T.calendar_jump_to_month)}</span
|
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</li>
|
</li>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
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: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 style="display:flex;align-items:center;font-weight:500;letter-spacing:0.04em;">Mit Vernunft in die Zukunft</div>
|
||||||
</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 });
|
||||||
|
|||||||
@@ -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