feat: Social-Media-Bild-Generator für Kalender-Termine
Neuer Endpoint /api/social/calendar/[slug] rendert Termin via satori + resvg zu PNG. Formate: og (1200x630) und square (1080x1080). Gebrandetes Layout mit Datum, Titel, Ort. Inter-Font lazy von gstatic, 10min In-Memory-Cache pro Slug+Format. CalendarBlock: zwei Download-Buttons pro Termin (Bild quer / quadr.). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -886,6 +886,36 @@
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
{#if item._slug}
|
||||
<a
|
||||
href={`/api/social/calendar/${item._slug}?format=og`}
|
||||
download={`termin-${item._slug}-og.png`}
|
||||
class="btn-outline btn-sm group"
|
||||
aria-label="Social-Bild (Quer 1200×630)"
|
||||
title="Social-Bild Quer (1200×630)"
|
||||
>
|
||||
<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>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => jumpToEventInGrid(item)}
|
||||
|
||||
@@ -114,6 +114,12 @@
|
||||
"google": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M21.35 11.1h-9.17v2.73h6.51c-.33 3.81-3.5 5.44-6.5 5.44C8.36 19.27 5 16.25 5 12c0-4.1 3.2-7.27 7.2-7.27c3.09 0 4.9 1.97 4.9 1.97L19 4.72S16.56 2 12.1 2C6.42 2 2.03 6.8 2.03 12c0 5.05 4.13 10 10.22 10c5.35 0 9.25-3.67 9.25-9.09c0-1.15-.15-1.81-.15-1.81\"/>"
|
||||
},
|
||||
"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\"/>"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { RequestHandler } from "./$types";
|
||||
import { error } from "@sveltejs/kit";
|
||||
import satori from "satori";
|
||||
import { html as htmlToReact } from "satori-html";
|
||||
import { Resvg } from "@resvg/resvg-js";
|
||||
import { getCalendarItemBySlug } from "$lib/cms";
|
||||
|
||||
type Format = "og" | "square";
|
||||
|
||||
const SIZES: Record<Format, { w: number; h: number }> = {
|
||||
og: { w: 1200, h: 630 },
|
||||
square: { w: 1080, h: 1080 },
|
||||
};
|
||||
|
||||
const FONT_URLS: Record<string, string> = {
|
||||
regular: "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfMZhrib2Bg-4.ttf",
|
||||
semibold: "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYMZhrib2Bg-4.ttf",
|
||||
bold: "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuGKYMZhrib2Bg-4.ttf",
|
||||
};
|
||||
|
||||
let fontCache: { regular: ArrayBuffer; semibold: ArrayBuffer; bold: ArrayBuffer } | null = null;
|
||||
|
||||
async function loadFonts() {
|
||||
if (fontCache) return fontCache;
|
||||
const [regular, semibold, bold] = await Promise.all(
|
||||
(["regular", "semibold", "bold"] as const).map(async (w) => {
|
||||
const res = await fetch(FONT_URLS[w]);
|
||||
if (!res.ok) throw new Error(`Font fetch failed (${w}): ${res.status}`);
|
||||
return res.arrayBuffer();
|
||||
}),
|
||||
);
|
||||
fontCache = { regular, semibold, bold };
|
||||
return fontCache;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | undefined): { date: string; time: string | null } {
|
||||
if (!iso) return { date: "", time: null };
|
||||
const d = new Date(iso);
|
||||
const date = d.toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
|
||||
const hasTime = !/T00:00:00/.test(iso);
|
||||
const time = hasTime ? d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : null;
|
||||
return { date, time };
|
||||
}
|
||||
|
||||
function weekday(iso: string | undefined): string {
|
||||
if (!iso) return "";
|
||||
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long" });
|
||||
}
|
||||
|
||||
const cache = new Map<string, { png: Buffer; ts: number }>();
|
||||
const CACHE_TTL = 10 * 60_000; // 10min
|
||||
|
||||
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 now = Date.now();
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && now - cached.ts < CACHE_TTL) {
|
||||
setHeaders({
|
||||
"content-type": "image/png",
|
||||
"cache-control": "public, max-age=600, stale-while-revalidate=86400",
|
||||
});
|
||||
return new Response(new Uint8Array(cached.png), { status: 200 });
|
||||
}
|
||||
|
||||
const itemRaw = await getCalendarItemBySlug(slug, { locale: "de" });
|
||||
if (!itemRaw) throw error(404, "Termin nicht gefunden");
|
||||
const item = itemRaw as typeof itemRaw & { location?: unknown };
|
||||
|
||||
const { w, h } = SIZES[formatParam];
|
||||
const fonts = await loadFonts();
|
||||
|
||||
const title = (item.title ?? "").trim();
|
||||
const { date, time } = formatDate(item.terminZeit);
|
||||
const day = weekday(item.terminZeit);
|
||||
const locationText =
|
||||
typeof item.location === "object" && item.location !== null
|
||||
? ((item.location as { text?: string }).text ?? "").trim()
|
||||
: "";
|
||||
|
||||
const square = formatParam === "square";
|
||||
const titleFs = square ? 56 : 54;
|
||||
const dateFs = square ? 84 : 78;
|
||||
const padX = square ? 80 : 90;
|
||||
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 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;justify-content:space-between;align-items:center;font-size:20px;letter-spacing:0.18em;font-weight:600;color:#7fb58a;text-transform:uppercase;">
|
||||
<div style="display:flex;align-items:center;">Windwiderstand Thüringen</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
${
|
||||
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;justify-content:space-between;align-items:center;font-size:20px;color:#7fb58a;border-top:1px solid #1a4d28;padding-top:18px;">
|
||||
<div style="display:flex;align-items:center;font-weight:600;">windwiderstand.de</div>
|
||||
<div style="display:flex;align-items:center;font-weight:500;letter-spacing:0.04em;">Mit Vernunft in die Zukunft</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const reactLike = htmlToReact(template);
|
||||
|
||||
const svg = await satori(reactLike as Parameters<typeof satori>[0], {
|
||||
width: w,
|
||||
height: h,
|
||||
fonts: [
|
||||
{ name: "Inter", data: fonts.regular, weight: 400, style: "normal" },
|
||||
{ name: "Inter", data: fonts.semibold, weight: 500, style: "normal" },
|
||||
{ name: "Inter", data: fonts.semibold, weight: 600, style: "normal" },
|
||||
{ name: "Inter", data: fonts.bold, weight: 700, style: "normal" },
|
||||
],
|
||||
});
|
||||
|
||||
const resvg = new Resvg(svg, { fitTo: { mode: "width", value: w } });
|
||||
const png = resvg.render().asPng();
|
||||
const pngBuf = Buffer.from(png);
|
||||
cache.set(cacheKey, { png: pngBuf, ts: now });
|
||||
|
||||
setHeaders({
|
||||
"content-type": "image/png",
|
||||
"cache-control": "public, max-age=600, stale-while-revalidate=86400",
|
||||
});
|
||||
return new Response(new Uint8Array(pngBuf), { status: 200 });
|
||||
};
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
Reference in New Issue
Block a user