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 linkedSlug = $state<string | null>(null);
let socialModal = $state<{ slug: string; title: string } | null>(null);
let socialTheme = $state<"wald" | "transparent-onlight" | "transparent-ondark">("wald");
function openSocial(item: EventCardItem) {
if (!item._slug) return;
@@ -767,166 +768,136 @@
{/if}
{/if}
<div class="flex flex-wrap gap-2 pt-1 text-xs">
{#if eventHref}
<!-- Aktionen: Primary-Reihe (Info/Karte/Anhang) + kompakte Action-Toolbar -->
<div class="pt-2 space-y-2">
{#if eventHref || locText || item.attachment?.src}
<div class="flex flex-wrap gap-2">
{#if eventHref}
<a
href={eventHref}
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
href={eventHref}
href={googleCalUrl(toICS(item))}
target="_blank"
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:open-in-new"
class="size-3.5"
aria-hidden="true"
/>
<Icon icon="mdi:google" class="size-3.5" />
Google
</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
{#if feedHost}
{@const shareKey = item._slug ?? String(item.start.getTime())}
{@const copied = copyToastKey === shareKey}
{@const shared = shareToastKey === shareKey}
<span class="mx-1 h-4 w-px bg-stein-300" aria-hidden="true"></span>
<span class="px-1.5 text-[10px] uppercase tracking-wide text-stein-400">Teilen</span>
<button
type="button"
onclick={() => copyEventLink(item)}
class="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)}
>
</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 herunterladen"}</span
<Icon icon={copied ? "mdi:check" : "mdi:link-variant"} class="size-3.5" />
{copied ? t(T.post_action_copied) : "Link"}
</button>
<button
type="button"
onclick={() => shareEvent(item, title, locText)}
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)}
>
</a>
{/if}
<button
type="button"
onclick={() =>
downloadICS(toICS(item), fileSlug(title))}
class="btn-outline btn-sm group"
aria-label={t(T.calendar_download_ics)}
>
<Icon
icon="mdi:download"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap"
>{t(T.calendar_download_ics)}</span
>
</button>
<a
href={googleCalUrl(toICS(item))}
target="_blank"
rel="noopener noreferrer"
class="btn-outline btn-sm group"
aria-label={t(T.calendar_add_to_google)}
>
<Icon
icon="mdi:google"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap"
>{t(T.calendar_add_to_google)}</span
>
</a>
{#if feedHost}
{@const shareKey =
item._slug ?? String(item.start.getTime())}
{@const copied = copyToastKey === shareKey}
<Icon icon={shared ? "mdi:check" : "mdi:share-variant"} class="size-3.5" />
{shared ? t(T.calendar_share_copied) : t(T.calendar_share)}
</button>
{#if item._slug}
<button
type="button"
onclick={() => openSocial(item)}
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-700 hover:bg-white"
title="Social-Bild Vorschau"
aria-label="Social-Bild Vorschau"
>
<Icon icon="mdi:image-outline" class="size-3.5" />
Bild
</button>
{/if}
{/if}
<span class="ml-auto"></span>
<button
type="button"
onclick={() => copyEventLink(item)}
class="btn-outline btn-sm group"
aria-label={t(T.post_action_copy)}
onclick={() => jumpToEventInGrid(item)}
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-500 hover:bg-white"
title={t(T.calendar_jump_to_month)}
aria-label={t(T.calendar_jump_to_month)}
>
<Icon
icon={copied
? "mdi:check"
: "mdi:link-variant"}
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap"
>{copied
? t(T.post_action_copied)
: t(T.post_action_copy)}</span
>
<Icon icon="mdi:calendar-search" class="size-3.5" />
Im Grid
</button>
{@const shared = shareToastKey === shareKey}
<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>
</details>
@@ -1323,8 +1294,16 @@
{/if}
{#if socialModal}
{@const ogUrl = `/api/social/calendar/${socialModal.slug}?format=og`}
{@const sqUrl = `/api/social/calendar/${socialModal.slug}?format=square`}
{@const themeQ = socialTheme === "wald" ? "" : `&theme=${socialTheme}`}
{@const ogUrl = `/api/social/calendar/${socialModal.slug}?format=og${themeQ}`}
{@const sqUrl = `/api/social/calendar/${socialModal.slug}?format=square${themeQ}`}
{@const storyUrl = `/api/social/calendar/${socialModal.slug}?format=story${themeQ}`}
{@const previewBg =
socialTheme === "wald"
? "bg-stein-50"
: socialTheme === "transparent-onlight"
? "bg-[repeating-linear-gradient(45deg,#f5f5f4,#f5f5f4_10px,#e7e5e4_10px,#e7e5e4_20px)]"
: "bg-[repeating-linear-gradient(45deg,#292524,#292524_10px,#1c1917_10px,#1c1917_20px)]"}
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4"
role="dialog"
@@ -1335,7 +1314,7 @@
tabindex="-1"
>
<div
class="relative max-h-[92vh] w-full max-w-4xl overflow-y-auto rounded-lg bg-white p-5 shadow-2xl"
class="relative max-h-[92vh] w-full max-w-5xl overflow-y-auto rounded-lg bg-white p-5 shadow-2xl"
onclick={(e) => e.stopPropagation()}
role="presentation"
>
@@ -1354,18 +1333,59 @@
</button>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<!-- Theme-Selector -->
<div class="mb-4 rounded-lg border border-stein-200 bg-stein-50 p-3">
<p class="mb-2 text-[11px] font-medium uppercase tracking-wider text-stein-500">Hintergrund</p>
<div class="flex flex-wrap gap-2">
<button
type="button"
onclick={() => (socialTheme = "wald")}
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
{socialTheme === 'wald' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
>
<span class="size-3 rounded-full bg-gradient-to-br from-wald-900 to-wald-600"></span>
Wald (Standard)
</button>
<button
type="button"
onclick={() => (socialTheme = "transparent-onlight")}
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
{socialTheme === 'transparent-onlight' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
title="Transparenter Hintergrund mit dunklem Text — für eigene helle Bild-Hintergründe"
>
<span class="size-3 rounded-full border border-stein-300 bg-[repeating-linear-gradient(45deg,#fff,#fff_2px,#e5e5e5_2px,#e5e5e5_4px)]"></span>
Transparent · für helle Hintergründe
</button>
<button
type="button"
onclick={() => (socialTheme = "transparent-ondark")}
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
{socialTheme === 'transparent-ondark' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
title="Transparenter Hintergrund mit hellem Text — für eigene dunkle Bild-Hintergründe"
>
<span class="size-3 rounded-full border border-stein-300 bg-[repeating-linear-gradient(45deg,#1c1917,#1c1917_2px,#44403c_2px,#44403c_4px)]"></span>
Transparent · für dunkle Hintergründe
</button>
</div>
{#if socialTheme !== "wald"}
<p class="mt-2 text-[10px] text-stein-500">
Vorschau-Hintergrund zeigt nur, wie der Text aussieht — die heruntergeladene PNG hat echten Alpha-Kanal.
</p>
{/if}
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-stein-600">Quer · 1200 × 630</p>
<img
src={ogUrl}
alt="Social-Bild Quer"
class="w-full rounded-sm border border-stein-200 bg-stein-50"
class="w-full rounded-sm border border-stein-200 {previewBg}"
loading="eager"
/>
<a
href={ogUrl}
download={`termin-${socialModal.slug}-og.png`}
download={`termin-${socialModal.slug}-og-${socialTheme}.png`}
class="btn-outline btn-sm justify-center no-underline!"
>
<Icon icon="mdi:download" class="size-3.5" />
@@ -1377,22 +1397,40 @@
<img
src={sqUrl}
alt="Social-Bild Quadrat"
class="mx-auto w-full max-w-[300px] rounded-sm border border-stein-200 bg-stein-50 sm:max-w-none"
class="mx-auto w-full rounded-sm border border-stein-200 {previewBg}"
loading="eager"
/>
<a
href={sqUrl}
download={`termin-${socialModal.slug}-square.png`}
download={`termin-${socialModal.slug}-square-${socialTheme}.png`}
class="btn-outline btn-sm justify-center no-underline!"
>
<Icon icon="mdi:download" class="size-3.5" />
Quadrat herunterladen
</a>
</div>
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-stein-600">Story · 1080 × 1920</p>
<img
src={storyUrl}
alt="Social-Bild Story"
class="mx-auto w-auto max-h-[280px] rounded-sm border border-stein-200 {previewBg}"
loading="eager"
/>
<a
href={storyUrl}
download={`termin-${socialModal.slug}-story-${socialTheme}.png`}
class="btn-outline btn-sm justify-center no-underline!"
>
<Icon icon="mdi:download" class="size-3.5" />
Story herunterladen
</a>
</div>
</div>
<p class="mt-4 text-[11px] text-stein-400">
Die Bilder werden automatisch generiert. Für Posts auf Instagram, Facebook, WhatsApp oder als Hintergrund-Grafik.
Quer: Facebook/Twitter/Link-Preview. Quadrat: Instagram-Feed. Story: Instagram/WhatsApp Story.
Transparent-Varianten lassen sich als Overlay auf eigene Hintergründe in Stories einsetzen.
</p>
</div>
</div>