feat: Event-Bild im Social-Bild + UX-Verbesserungen
Deploy / verify (push) Successful in 1m3s
Deploy / deploy (push) Successful in 1m23s

Social-Bild Calendar:
- Termin-Bild als Thumbnail neben QR-Code (Reihenfolge: QR | Bild)
- Wrapper transparent, Bild im natürlichen Aspect via object-fit:contain
  und fit=inside auf transform-API — kein Crop, kein Letterbox
- Wachstum auf qrSize+28 begrenzt
- Title-Fontsize bei vorhandenem Thumb zusätzlich verkleinert
- Eckige Kanten (kein border-radius) für QR-Box und Thumb

Calendar-Block:
- Vorschaubild im Termin-Item gleich gerendert: object-contain,
  max-w-24/max-h-24, kein Crop
- "Teilen"-Gruppe in Aktions-Toolbar bricht auf Mobile auf eigene
  Zeile um, Trennstrich nur auf sm+ sichtbar

Direkt-Link:
- Calendar: /kalender/?social=<slug> öffnet Modal automatisch
- Post: /post/<slug>?social=1 öffnet Modal automatisch
- Modal-Header: "Direktlink"-Button kopiert URL ins Clipboard

Skeleton-Loader:
- Animierter grauer Skeleton während Bild-Render via satori
- Per Theme-Wechsel resetten, fade-in via opacity-Transition
- Story-Container Fix: explizite 280x158 Maße statt aspect-ratio

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-06-09 13:35:38 +02:00
parent c225fb8be5
commit 08181129c4
4 changed files with 188 additions and 23 deletions
+10
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import "$lib/iconify-offline";
import { env } from "$env/dynamic/public";
import { useTranslate, T } from "$lib/translations";
@@ -36,6 +37,15 @@
let socialModalOpen = $state(false);
// Direkt-Link-Support: ?social=1 öffnet das Social-Bild-Modal automatisch
onMount(() => {
if (!socialSlug) return;
const params = new URLSearchParams(window.location.search);
if (params.get("social") === "1") {
socialModalOpen = true;
}
});
const t = useTranslate();
// ── Comment count fetch (only when commentPageId is set) ────────────────
+93 -11
View File
@@ -21,6 +21,43 @@
} = $props();
let theme = $state<"wald" | "transparent-onlight" | "transparent-ondark">("wald");
let linkCopied = $state(false);
/** Direktlink, der dieses Modal erneut öffnet — abhängig von endpointBase. */
const directLink = $derived.by(() => {
if (typeof window === "undefined") return "";
const origin = window.location.origin;
if (endpointBase.includes("/calendar")) {
return `${origin}/kalender/?social=${encodeURIComponent(slug)}`;
}
if (endpointBase.includes("/post")) {
return `${origin}/post/${encodeURIComponent(slug)}?social=1`;
}
return `${origin}/`;
});
async function copyDirectLink() {
try {
await navigator.clipboard.writeText(directLink);
linkCopied = true;
setTimeout(() => (linkCopied = false), 2000);
} catch {
linkCopied = false;
}
}
// Loading-State pro Format. Skeleton wird angezeigt bis das jeweilige
// Bild fertig geladen ist. Bei Theme-Wechsel resetten.
let loadedOg = $state(false);
let loadedSq = $state(false);
let loadedStory = $state(false);
$effect(() => {
// Reset bei Theme-Änderung (neuer Request → neuer Loading-Zustand)
void theme;
loadedOg = false;
loadedSq = false;
loadedStory = false;
});
const themeQ = $derived(theme === "wald" ? "" : `&theme=${theme}`);
const ogUrl = $derived(`${endpointBase}/${slug}?format=og${themeQ}`);
@@ -55,14 +92,26 @@
<p class="text-xs uppercase tracking-wider text-stein-500">Social-Bild</p>
<p class="truncate text-sm font-semibold text-stein-800">{title}</p>
</div>
<button
type="button"
onclick={onclose}
class="shrink-0 text-stein-400 hover:text-stein-700"
aria-label="Schließen"
>
<Icon icon="mdi:close" class="size-5" />
</button>
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
onclick={copyDirectLink}
class="inline-flex items-center gap-1 rounded-md border border-stein-300 bg-white px-2 py-1 text-[11px] font-medium text-stein-700 hover:bg-stein-50"
title="Direktlink zu dieser Vorschau kopieren"
aria-label="Direktlink kopieren"
>
<Icon icon={linkCopied ? "mdi:check" : "mdi:link-variant"} class="size-3.5" />
{linkCopied ? "Kopiert" : "Direktlink"}
</button>
<button
type="button"
onclick={onclose}
class="text-stein-400 hover:text-stein-700"
aria-label="Schließen"
>
<Icon icon="mdi:close" class="size-5" />
</button>
</div>
</div>
<div class="mb-4 rounded-lg border border-stein-200 bg-stein-50 p-3">
@@ -108,7 +157,18 @@
<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 {previewBg}" loading="eager" />
<div class="relative w-full aspect-[1200/630] rounded-sm border border-stein-200 overflow-hidden {previewBg}">
{#if !loadedOg}
<div class="absolute inset-0 animate-pulse bg-gradient-to-br from-stein-200 via-stein-300 to-stein-200"></div>
{/if}
<img
src={ogUrl}
alt="Social-Bild Quer"
class="absolute inset-0 h-full w-full transition-opacity duration-200 {loadedOg ? 'opacity-100' : 'opacity-0'}"
loading="eager"
onload={() => (loadedOg = true)}
/>
</div>
<a href={ogUrl} download={`${downloadPrefix}-${slug}-og-${theme}.png`} class="btn-outline btn-sm justify-center no-underline!">
<Icon icon="mdi:download" class="size-3.5" />
Quer herunterladen
@@ -116,7 +176,18 @@
</div>
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-stein-600">Quadrat · 1080 × 1080</p>
<img src={sqUrl} alt="Social-Bild Quadrat" class="mx-auto w-full rounded-sm border border-stein-200 {previewBg}" loading="eager" />
<div class="relative mx-auto w-full aspect-square rounded-sm border border-stein-200 overflow-hidden {previewBg}">
{#if !loadedSq}
<div class="absolute inset-0 animate-pulse bg-gradient-to-br from-stein-200 via-stein-300 to-stein-200"></div>
{/if}
<img
src={sqUrl}
alt="Social-Bild Quadrat"
class="absolute inset-0 h-full w-full transition-opacity duration-200 {loadedSq ? 'opacity-100' : 'opacity-0'}"
loading="eager"
onload={() => (loadedSq = true)}
/>
</div>
<a href={sqUrl} download={`${downloadPrefix}-${slug}-square-${theme}.png`} class="btn-outline btn-sm justify-center no-underline!">
<Icon icon="mdi:download" class="size-3.5" />
Quadrat herunterladen
@@ -124,7 +195,18 @@
</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" />
<div class="relative mx-auto h-[280px] w-[158px] rounded-sm border border-stein-200 overflow-hidden {previewBg}">
{#if !loadedStory}
<div class="absolute inset-0 animate-pulse bg-gradient-to-br from-stein-200 via-stein-300 to-stein-200"></div>
{/if}
<img
src={storyUrl}
alt="Social-Bild Story"
class="absolute inset-0 h-full w-full transition-opacity duration-200 {loadedStory ? 'opacity-100' : 'opacity-0'}"
loading="eager"
onload={() => (loadedStory = true)}
/>
</div>
<a href={storyUrl} download={`${downloadPrefix}-${slug}-story-${theme}.png`} class="btn-outline btn-sm justify-center no-underline!">
<Icon icon="mdi:download" class="size-3.5" />
Story herunterladen
+19 -6
View File
@@ -540,6 +540,17 @@
.getElementById(hash)
?.scrollIntoView({ behavior: "smooth", block: "start" });
}
// Direkt-Link für Social-Bild-Modal: ?social=<slug>
// Beispiel: /kalender/?social=calendar-item-060626-...
const params = new URLSearchParams(window.location.search);
const socialSlug = params.get("social");
if (socialSlug) {
const item = items.find((it) => it._slug === socialSlug);
if (item) {
openSocial(item);
}
}
});
onDestroy(() => {
if (tickTimer) clearInterval(tickTimer);
@@ -725,9 +736,9 @@
{@const imageField = extractCmsImageField(item.image)}
{#if imageField}
{@const thumbSrc = getCmsImageUrl(imageField.url, {
width: 160,
height: 160,
fit: "cover",
width: 240,
height: 240,
fit: "contain",
})}
{@const fullSrc = getCmsImageUrl(imageField.url, {
width: 1400,
@@ -746,13 +757,13 @@
src: fullSrc,
alt: imageAlt,
})}
class="group relative w-24 h-24 rounded overflow-hidden border border-stein-200 shrink-0 hover:opacity-90 transition-opacity"
class="group relative flex max-w-24 max-h-24 shrink-0 items-center justify-center overflow-hidden hover:opacity-90 transition-opacity"
aria-label="Bild vergrößern"
>
<img
src={thumbSrc}
alt={imageAlt}
class="w-full h-full object-cover"
class="max-w-24 max-h-24 object-contain"
loading="lazy"
/>
<span
@@ -854,8 +865,10 @@
{@const shareKey = item._slug ?? String(item.start.getTime())}
{@const copied = copyToastKey === shareKey}
{@const shared = shareToastKey === shareKey}
<!-- Force-Break vor Teilen-Gruppe auf Mobile -->
<div class="w-full sm:hidden" aria-hidden="true"></div>
<!-- Gruppe: Teilen -->
<div class="inline-flex items-center gap-1 border-l border-stein-300 pl-2">
<div class="inline-flex items-center gap-1 sm:border-l sm:border-stein-300 sm:pl-2">
<span class="px-1.5 text-[10px] uppercase tracking-wide text-stein-400">Teilen</span>
<button
type="button"