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:
@@ -67,9 +67,8 @@ const EXTRA_ICONS = [
|
|||||||
'information-outline',
|
'information-outline',
|
||||||
// OrganisationsMapBlock: activate-overlay
|
// OrganisationsMapBlock: activate-overlay
|
||||||
'cursor-default-click-outline',
|
'cursor-default-click-outline',
|
||||||
// CalendarBlock: social-image buttons
|
// CalendarBlock: social-image modal button
|
||||||
'image-outline',
|
'image-outline',
|
||||||
'crop-square',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATIC_PATTERN = /icon="mdi:([a-z0-9-]+)"/g;
|
const STATIC_PATTERN = /icon="mdi:([a-z0-9-]+)"/g;
|
||||||
|
|||||||
@@ -446,6 +446,16 @@
|
|||||||
let copyToastKey = $state<string | null>(null);
|
let copyToastKey = $state<string | null>(null);
|
||||||
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);
|
||||||
|
|
||||||
|
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 {
|
function eventId(item: EventCardItem): string {
|
||||||
if (item._slug) return `event-${item._slug}`;
|
if (item._slug) return `event-${item._slug}`;
|
||||||
@@ -887,34 +897,20 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if item._slug}
|
{#if item._slug}
|
||||||
<a
|
<button
|
||||||
href={`/api/social/calendar/${item._slug}?format=og`}
|
type="button"
|
||||||
download={`termin-${item._slug}-og.png`}
|
onclick={() => openSocial(item)}
|
||||||
class="btn-outline btn-sm group"
|
class="btn-outline btn-sm group"
|
||||||
aria-label="Social-Bild (Quer 1200×630)"
|
aria-label="Social-Bild als Vorschau öffnen"
|
||||||
title="Social-Bild Quer (1200×630)"
|
title="Social-Bild Vorschau"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon="mdi:image-outline"
|
icon="mdi:image-outline"
|
||||||
class="size-3.5 shrink-0"
|
class="size-3.5 shrink-0"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span class="whitespace-nowrap">Bild quer</span>
|
<span class="whitespace-nowrap">Social-Bild</span>
|
||||||
</a>
|
</button>
|
||||||
<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>
|
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1326,6 +1322,82 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/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>
|
<style>
|
||||||
/* Heute-Markierung im Kalendergrid: dezenter Rand + leichtes Glow.
|
/* Heute-Markierung im Kalendergrid: dezenter Rand + leichtes Glow.
|
||||||
Ergänzt das Event-Badge, damit auch leere Tage als "Heute"
|
Ergänzt das Event-Badge, damit auch leere Tage als "Heute"
|
||||||
|
|||||||
@@ -117,9 +117,6 @@
|
|||||||
"image-outline": {
|
"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\"/>"
|
"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": {
|
"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\"/>"
|
"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 satori from "satori";
|
||||||
import { html as htmlToReact } from "satori-html";
|
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 { env } from "$env/dynamic/public";
|
||||||
import { getCalendarItemBySlug } from "$lib/cms";
|
import { getCalendarItemBySlug } from "$lib/cms";
|
||||||
|
|
||||||
type Format = "og" | "square";
|
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 = 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";
|
||||||
|
|
||||||
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 = url.searchParams.get("format") === "square" ? "square" : "og";
|
||||||
const cacheKey = `${slug}:${formatParam}`;
|
const cacheKey = `${CACHE_VERSION}:${slug}:${formatParam}`;
|
||||||
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) {
|
||||||
@@ -79,15 +82,28 @@ export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
|
|||||||
? ((item.location as { text?: string }).text ?? "").trim()
|
? ((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 square = formatParam === "square";
|
||||||
const titleFs = square ? 56 : 54;
|
const titleFs = square ? 56 : 52;
|
||||||
const dateFs = square ? 84 : 78;
|
const dateFs = square ? 84 : 76;
|
||||||
const padX = square ? 80 : 90;
|
const padX = square ? 80 : 90;
|
||||||
|
const qrSize = square ? 220 : 180;
|
||||||
const titleClamped = title.length > 110 ? title.slice(0, 107) + "…" : title;
|
const titleClamped = title.length > 110 ? title.slice(0, 107) + "…" : title;
|
||||||
const locClamped =
|
const locClamped =
|
||||||
locationText.length > 70 ? locationText.slice(0, 67) + "…" : locationText;
|
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 = `
|
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;">
|
<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 style="display:flex;align-items:center;color:#b3d1b9;">Termin</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;flex-direction:column;margin-top:${square ? 56 : 28}px;flex:1;">
|
<div style="display:flex;margin-top:${square ? 48 : 24}px;flex:1;gap:40px;">
|
||||||
<div style="display:flex;font-size:28px;color:#b3d1b9;font-weight:500;text-transform:capitalize;margin-bottom:6px;">${day}</div>
|
<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;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>
|
<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>` : ""}
|
${time ? `<div style="display:flex;font-size:40px;font-weight:600;color:#d9e8dc;margin-left:24px;line-height:1;">${time} Uhr</div>` : ""}
|
||||||
</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;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;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>
|
</div>
|
||||||
|
|
||||||
${
|
${
|
||||||
locClamped
|
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 });
|
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 {
|
function escapeHtml(s: string): string {
|
||||||
return s
|
return s.replace(/</g, "‹").replace(/>/g, "›");
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user