feat: Social-Bild-Modal mit QR-Code für Kalender-Termine
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:
@@ -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. "&" 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
return s.replace(/</g, "‹").replace(/>/g, "›");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user