feat: Social-Bild-Modal mit QR-Code für Kalender-Termine
Deploy / verify (push) Successful in 1m7s
Deploy / deploy (push) Successful in 1m39s

Single Button öffnet Vorschau-Modal mit beiden Formaten side-by-side.
Pro Format Download-Button. ESC + Click-outside schließen.

Endpoint generiert zusätzlich QR-Code (qrcode → PNG-Buffer), der
direkt auf den Termin im Kalender-Grid verweist
(/kalender/#event-<slug>). Pin-Icon, QR und Layout für OG (1200x630)
und Square (1080x1080).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-06-06 10:25:07 +02:00
parent f0d9fecfb8
commit ace3562111
4 changed files with 135 additions and 43 deletions
+93 -21
View File
@@ -446,6 +446,16 @@
let copyToastKey = $state<string | null>(null);
let modalImage = $state<{ src: string; alt: string } | null>(null);
let linkedSlug = $state<string | null>(null);
let socialModal = $state<{ slug: string; title: string } | null>(null);
function openSocial(item: EventCardItem) {
if (!item._slug) return;
socialModal = { slug: item._slug, title: item.title || "Termin" };
}
function closeSocial() {
socialModal = null;
}
function eventId(item: EventCardItem): string {
if (item._slug) return `event-${item._slug}`;
@@ -887,34 +897,20 @@
</button>
{/if}
{#if item._slug}
<a
href={`/api/social/calendar/${item._slug}?format=og`}
download={`termin-${item._slug}-og.png`}
<button
type="button"
onclick={() => openSocial(item)}
class="btn-outline btn-sm group"
aria-label="Social-Bild (Quer 1200×630)"
title="Social-Bild Quer (1200×630)"
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">Bild quer</span>
</a>
<a
href={`/api/social/calendar/${item._slug}?format=square`}
download={`termin-${item._slug}-square.png`}
class="btn-outline btn-sm group"
aria-label="Social-Bild (Quadrat 1080×1080)"
title="Social-Bild Quadrat (1080×1080)"
>
<Icon
icon="mdi:crop-square"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">Bild quadr.</span>
</a>
<span class="whitespace-nowrap">Social-Bild</span>
</button>
{/if}
<button
type="button"
@@ -1326,6 +1322,82 @@
/>
{/if}
{#if socialModal}
{@const ogUrl = `/api/social/calendar/${socialModal.slug}?format=og`}
{@const sqUrl = `/api/social/calendar/${socialModal.slug}?format=square`}
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4"
role="dialog"
aria-modal="true"
aria-label="Social-Bild-Vorschau"
onclick={closeSocial}
onkeydown={(e) => { if (e.key === 'Escape') closeSocial(); }}
tabindex="-1"
>
<div
class="relative max-h-[92vh] w-full max-w-4xl overflow-y-auto rounded-lg bg-white p-5 shadow-2xl"
onclick={(e) => e.stopPropagation()}
role="presentation"
>
<div class="mb-4 flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-xs uppercase tracking-wider text-stein-500">Social-Bild</p>
<p class="truncate text-sm font-semibold text-stein-800">{socialModal.title}</p>
</div>
<button
type="button"
onclick={closeSocial}
class="shrink-0 text-stein-400 hover:text-stein-700"
aria-label="Schließen"
>
<Icon icon="mdi:close" class="size-5" />
</button>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<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"
loading="eager"
/>
<a
href={ogUrl}
download={`termin-${socialModal.slug}-og.png`}
class="btn-outline btn-sm justify-center no-underline!"
>
<Icon icon="mdi:download" class="size-3.5" />
Quer herunterladen
</a>
</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 max-w-[300px] rounded-sm border border-stein-200 bg-stein-50 sm:max-w-none"
loading="eager"
/>
<a
href={sqUrl}
download={`termin-${socialModal.slug}-square.png`}
class="btn-outline btn-sm justify-center no-underline!"
>
<Icon icon="mdi:download" class="size-3.5" />
Quadrat 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.
</p>
</div>
</div>
{/if}
<style>
/* Heute-Markierung im Kalendergrid: dezenter Rand + leichtes Glow.
Ergänzt das Event-Badge, damit auch leere Tage als "Heute"
@@ -117,9 +117,6 @@
"image-outline": {
"body": "<path fill=\"currentColor\" d=\"M19 19H5V5h14m0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-5.04 9.29l-2.75 3.54l-1.96-2.36L6.5 17h11z\"/>"
},
"crop-square": {
"body": "<path fill=\"currentColor\" d=\"M18 18H6V6h12m0-2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2\"/>"
},
"calendar-search": {
"body": "<path fill=\"currentColor\" d=\"M15.5 12c2.5 0 4.5 2 4.5 4.5c0 .88-.25 1.71-.69 2.4l3.08 3.1L21 23.39l-3.12-3.07c-.69.43-1.51.68-2.38.68c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5m0 2a2.5 2.5 0 0 0-2.5 2.5a2.5 2.5 0 0 0 2.5 2.5a2.5 2.5 0 0 0 2.5-2.5a2.5 2.5 0 0 0-2.5-2.5M19 8H5v11h4.5c.31.75.76 1.42 1.31 2H5a2 2 0 0 1-2-2V5c0-1.11.89-2 2-2h1V1h2v2h8V1h2v2h1a2 2 0 0 1 2 2v8.03c-.5-.81-1.2-1.49-2-2.03z\"/>"
},
@@ -3,6 +3,8 @@ import { error } from "@sveltejs/kit";
import satori from "satori";
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";
type Format = "og" | "square";
@@ -49,11 +51,12 @@ function weekday(iso: string | undefined): string {
const cache = new Map<string, { png: Buffer; ts: number }>();
const CACHE_TTL = 10 * 60_000; // 10min
const CACHE_VERSION = "v4";
export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
const slug = params.slug;
const formatParam = url.searchParams.get("format") === "square" ? "square" : "og";
const cacheKey = `${slug}:${formatParam}`;
const cacheKey = `${CACHE_VERSION}:${slug}:${formatParam}`;
const now = Date.now();
const cached = cache.get(cacheKey);
if (cached && now - cached.ts < CACHE_TTL) {
@@ -79,15 +82,28 @@ export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
? ((item.location as { text?: string }).text ?? "").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, {
errorCorrectionLevel: "M",
margin: 1,
scale: 8,
color: { dark: "#0a2011ff", light: "#ffffffff" },
});
const qrDataUrl = `data:image/png;base64,${qrPngBuf.toString("base64")}`;
const square = formatParam === "square";
const titleFs = square ? 56 : 54;
const dateFs = square ? 84 : 78;
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 = `<svg width="34" height="34" 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 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 template = `
<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;">
@@ -96,19 +112,28 @@ export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
<div style="display:flex;align-items:center;color:#b3d1b9;">Termin</div>
</div>
<div style="display:flex;flex-direction:column;margin-top:${square ? 56 : 28}px;flex:1;">
<div style="display:flex;font-size:28px;color:#b3d1b9;font-weight:500;text-transform:capitalize;margin-bottom:6px;">${day}</div>
<div style="display:flex;align-items:baseline;flex-wrap:wrap;">
<div style="display:flex;font-size:${dateFs}px;font-weight:700;color:#ffffff;letter-spacing:-0.02em;line-height:1;">${date}</div>
${time ? `<div style="display:flex;font-size:44px;font-weight:600;color:#d9e8dc;margin-left:26px;line-height:1;">${time} Uhr</div>` : ""}
<div style="display:flex;margin-top:${square ? 48 : 24}px;flex:1;gap:40px;">
<div style="display:flex;flex-direction:column;flex:1;min-width:0;">
<div style="display:flex;font-size:26px;color:#b3d1b9;font-weight:500;text-transform:capitalize;margin-bottom:6px;">${day}</div>
<div style="display:flex;align-items:baseline;flex-wrap:wrap;">
<div style="display:flex;font-size:${dateFs}px;font-weight:700;color:#ffffff;letter-spacing:-0.02em;line-height:1;">${date}</div>
${time ? `<div style="display:flex;font-size:40px;font-weight:600;color:#d9e8dc;margin-left:24px;line-height:1;">${time} Uhr</div>` : ""}
</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>
</div>
<div style="display:flex;font-size:${titleFs}px;font-weight:700;color:#ffffff;line-height:1.15;margin-top:${square ? 48 : 30}px;letter-spacing:-0.015em;">${escapeHtml(titleClamped)}</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
<div style="display:flex;background:#ffffff;padding:14px;border-radius:12px;">
<img src="${qrDataUrl}" style="width:${qrSize}px;height:${qrSize}px;" />
</div>
<div style="display:flex;font-size:16px;color:#b3d1b9;font-weight:500;letter-spacing:0.05em;">Mehr Infos</div>
</div>
</div>
${
locClamped
? `<div style="display:flex;align-items:center;font-size:26px;color:#d9e8dc;font-weight:500;margin-bottom:24px;gap:14px;">${pinSvg}<div style="display:flex;">${escapeHtml(locClamped)}</div></div>`
? `<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>`
: ""
}
@@ -144,11 +169,10 @@ export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
return new Response(new Uint8Array(pngBuf), { status: 200 });
};
// satori-html / ultrahtml dekodiert HTML-Entities im Text-Node NICHT zurück,
// d.h. "&amp;" würde 1:1 als Text gerendert. Daher nur die Zeichen entfernen,
// die das HTML-Parsing brechen würden ("<" und ">"). "&" bleibt raw —
// ultrahtml akzeptiert das und satori rendert es als "&".
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
return s.replace(/</g, "").replace(/>/g, "");
}