feat: Social-Bild-Generator auch für Posts (Endpoint, Modal, PostActions-Button)
Deploy / verify (push) Successful in 1m4s
Deploy / deploy (push) Successful in 1m23s

Neuer Endpoint /api/social/post/[slug] mit gleichem Pattern wie für
Termine: Logo, Tag-Pill aus Title/Tags-Keywords (Presse, Demo,
Rückblick, Interview, Studie, Rechtlich, Meinung etc.), Datum,
Headline mit dynamischer Fontsize, Subheadline/Excerpt-Snippet, QR
zum Beitrag, Footer. 3 Formate × 3 Themes wie Kalender.

SocialImageModal in eigene wiederverwendbare Komponente extrahiert
und sowohl im PostActions als auch im CalendarBlock eingebunden.
PostActions akzeptiert socialSlug-Prop und blendet bei vorhandenem
Slug einen Modal-Trigger-Button neben den anderen Aktions-Icons ein.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-06-07 13:14:14 +02:00
parent 287a44ff6b
commit 155d9fb4b0
6 changed files with 555 additions and 141 deletions
+29
View File
@@ -5,6 +5,8 @@
import { useTranslate, T } from "$lib/translations";
import QrButton from "$lib/components/QrButton.svelte";
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
let {
url,
title,
@@ -12,6 +14,7 @@
commentPageId = null,
commentEnvironment,
commentTargetId = "comments-section",
socialSlug = null,
class: cls = "",
}: {
/** Absolute URL of the page being shared. */
@@ -26,9 +29,13 @@
commentEnvironment?: string;
/** DOM id of the comment accordion (used by the count-badge click). */
commentTargetId?: string;
/** Wenn gesetzt: zeigt Social-Bild-Button, der Modal öffnet. */
socialSlug?: string | null;
class?: string;
} = $props();
let socialModalOpen = $state(false);
const t = useTranslate();
// ── Comment count fetch (only when commentPageId is set) ────────────────
@@ -195,6 +202,17 @@
>
<Icon icon="mdi:printer-outline" class="size-3.5" />
</button>
{#if socialSlug}
<button
type="button"
onclick={() => (socialModalOpen = true)}
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
aria-label="Social-Bild Vorschau"
title="Social-Bild Vorschau (für Instagram, Facebook, WhatsApp)"
>
<Icon icon="mdi:image-outline" class="size-3.5" />
</button>
{/if}
<QrButton
value={url}
label={title}
@@ -203,6 +221,17 @@
/>
</div>
{#if socialSlug}
<SocialImageModal
open={socialModalOpen}
title={title}
slug={socialSlug}
endpointBase="/api/social/post"
downloadPrefix="beitrag"
onclose={() => (socialModalOpen = false)}
/>
{/if}
{#if readingTimeMinutes != null && readingTimeMinutes > 0}
<span
class="inline-flex items-center gap-1 rounded-full border border-stein-200 bg-white px-2.5 py-1 text-xs text-stein-500"
+140
View File
@@ -0,0 +1,140 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
let {
open,
title,
slug,
endpointBase,
downloadPrefix = "social",
onclose,
}: {
open: boolean;
title: string;
slug: string;
/** "/api/social/calendar" oder "/api/social/post" */
endpointBase: string;
/** Prefix für Download-Dateinamen, z.B. "termin" oder "beitrag" */
downloadPrefix?: string;
onclose: () => void;
} = $props();
let theme = $state<"wald" | "transparent-onlight" | "transparent-ondark">("wald");
const themeQ = $derived(theme === "wald" ? "" : `&theme=${theme}`);
const ogUrl = $derived(`${endpointBase}/${slug}?format=og${themeQ}`);
const sqUrl = $derived(`${endpointBase}/${slug}?format=square${themeQ}`);
const storyUrl = $derived(`${endpointBase}/${slug}?format=story${themeQ}`);
const previewBg = $derived(
theme === "wald"
? "bg-stein-50"
: theme === "transparent-onlight"
? "bg-[repeating-linear-gradient(45deg,#f5f5f4,#f5f5f4_10px,#e7e5e4_10px,#e7e5e4_20px)]"
: "bg-[repeating-linear-gradient(45deg,#292524,#292524_10px,#1c1917_10px,#1c1917_20px)]",
);
</script>
{#if open}
<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={onclose}
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}
tabindex="-1"
>
<div
class="relative max-h-[92vh] w-full max-w-5xl 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">{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>
<div class="mb-4 rounded-lg border border-stein-200 bg-stein-50 p-3">
<p class="mb-2 text-[11px] font-medium uppercase tracking-wider text-stein-500">Hintergrund</p>
<div class="flex flex-wrap gap-2">
<button
type="button"
onclick={() => (theme = "wald")}
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
{theme === 'wald' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
>
<span class="size-3 rounded-full bg-gradient-to-br from-wald-900 to-wald-600"></span>
Wald (Standard)
</button>
<button
type="button"
onclick={() => (theme = "transparent-onlight")}
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
{theme === 'transparent-onlight' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
title="Transparenter Hintergrund mit dunklem Text"
>
<span class="size-3 rounded-full border border-stein-300 bg-[repeating-linear-gradient(45deg,#fff,#fff_2px,#e5e5e5_2px,#e5e5e5_4px)]"></span>
Transparent · helle Hintergründe
</button>
<button
type="button"
onclick={() => (theme = "transparent-ondark")}
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
{theme === 'transparent-ondark' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
title="Transparenter Hintergrund mit hellem Text"
>
<span class="size-3 rounded-full border border-stein-300 bg-[repeating-linear-gradient(45deg,#1c1917,#1c1917_2px,#44403c_2px,#44403c_4px)]"></span>
Transparent · dunkle Hintergründe
</button>
</div>
{#if theme !== "wald"}
<p class="mt-2 text-[10px] text-stein-500">
Vorschau-Hintergrund zeigt nur, wie der Text aussieht — heruntergeladene PNG hat echten Alpha-Kanal.
</p>
{/if}
</div>
<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" />
<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
</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 rounded-sm border border-stein-200 {previewBg}" loading="eager" />
<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
</a>
</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" />
<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
</a>
</div>
</div>
<p class="mt-4 text-[11px] text-stein-400">
Quer: Facebook/Twitter/Link-Preview. Quadrat: Instagram-Feed. Story: Instagram/WhatsApp Story.
</p>
</div>
</div>
{/if}
+9 -141
View File
@@ -16,6 +16,7 @@
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
import ImageModal from "$lib/components/ImageModal.svelte";
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
type EventCardItem = CalendarItemData & {
start: Date;
@@ -447,7 +448,6 @@
let modalImage = $state<{ src: string; alt: string } | null>(null);
let linkedSlug = $state<string | null>(null);
let socialModal = $state<{ slug: string; title: string } | null>(null);
let socialTheme = $state<"wald" | "transparent-onlight" | "transparent-ondark">("wald");
function openSocial(item: EventCardItem) {
if (!item._slug) return;
@@ -1294,146 +1294,14 @@
{/if}
{#if socialModal}
{@const themeQ = socialTheme === "wald" ? "" : `&theme=${socialTheme}`}
{@const ogUrl = `/api/social/calendar/${socialModal.slug}?format=og${themeQ}`}
{@const sqUrl = `/api/social/calendar/${socialModal.slug}?format=square${themeQ}`}
{@const storyUrl = `/api/social/calendar/${socialModal.slug}?format=story${themeQ}`}
{@const previewBg =
socialTheme === "wald"
? "bg-stein-50"
: socialTheme === "transparent-onlight"
? "bg-[repeating-linear-gradient(45deg,#f5f5f4,#f5f5f4_10px,#e7e5e4_10px,#e7e5e4_20px)]"
: "bg-[repeating-linear-gradient(45deg,#292524,#292524_10px,#1c1917_10px,#1c1917_20px)]"}
<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-5xl 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>
<!-- Theme-Selector -->
<div class="mb-4 rounded-lg border border-stein-200 bg-stein-50 p-3">
<p class="mb-2 text-[11px] font-medium uppercase tracking-wider text-stein-500">Hintergrund</p>
<div class="flex flex-wrap gap-2">
<button
type="button"
onclick={() => (socialTheme = "wald")}
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
{socialTheme === 'wald' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
>
<span class="size-3 rounded-full bg-gradient-to-br from-wald-900 to-wald-600"></span>
Wald (Standard)
</button>
<button
type="button"
onclick={() => (socialTheme = "transparent-onlight")}
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
{socialTheme === 'transparent-onlight' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
title="Transparenter Hintergrund mit dunklem Text — für eigene helle Bild-Hintergründe"
>
<span class="size-3 rounded-full border border-stein-300 bg-[repeating-linear-gradient(45deg,#fff,#fff_2px,#e5e5e5_2px,#e5e5e5_4px)]"></span>
Transparent · für helle Hintergründe
</button>
<button
type="button"
onclick={() => (socialTheme = "transparent-ondark")}
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
{socialTheme === 'transparent-ondark' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
title="Transparenter Hintergrund mit hellem Text — für eigene dunkle Bild-Hintergründe"
>
<span class="size-3 rounded-full border border-stein-300 bg-[repeating-linear-gradient(45deg,#1c1917,#1c1917_2px,#44403c_2px,#44403c_4px)]"></span>
Transparent · für dunkle Hintergründe
</button>
</div>
{#if socialTheme !== "wald"}
<p class="mt-2 text-[10px] text-stein-500">
Vorschau-Hintergrund zeigt nur, wie der Text aussieht — die heruntergeladene PNG hat echten Alpha-Kanal.
</p>
{/if}
</div>
<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"
/>
<a
href={ogUrl}
download={`termin-${socialModal.slug}-og-${socialTheme}.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 rounded-sm border border-stein-200 {previewBg}"
loading="eager"
/>
<a
href={sqUrl}
download={`termin-${socialModal.slug}-square-${socialTheme}.png`}
class="btn-outline btn-sm justify-center no-underline!"
>
<Icon icon="mdi:download" class="size-3.5" />
Quadrat herunterladen
</a>
</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"
/>
<a
href={storyUrl}
download={`termin-${socialModal.slug}-story-${socialTheme}.png`}
class="btn-outline btn-sm justify-center no-underline!"
>
<Icon icon="mdi:download" class="size-3.5" />
Story herunterladen
</a>
</div>
</div>
<p class="mt-4 text-[11px] text-stein-400">
Quer: Facebook/Twitter/Link-Preview. Quadrat: Instagram-Feed. Story: Instagram/WhatsApp Story.
Transparent-Varianten lassen sich als Overlay auf eigene Hintergründe in Stories einsetzen.
</p>
</div>
</div>
<SocialImageModal
open={true}
title={socialModal.title}
slug={socialModal.slug}
endpointBase="/api/social/calendar"
downloadPrefix="termin"
onclose={closeSocial}
/>
{/if}
<style>
@@ -0,0 +1,375 @@
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 { toBuffer as qrToBuffer } from "qrcode";
import { env } from "$env/dynamic/public";
import { getPostBySlug, getPageConfigs } from "$lib/cms";
type Format = "og" | "square" | "story";
type Theme = "wald" | "transparent-onlight" | "transparent-ondark";
const SIZES: Record<Format, { w: number; h: number }> = {
og: { w: 1200, h: 630 },
square: { w: 1080, h: 1080 },
story: { w: 1080, h: 1920 },
};
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;
let logoSvgCache: { svg: string; recolored: Record<string, string> } | 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;
}
async function loadLogo(): Promise<string | null> {
if (logoSvgCache) return logoSvgCache.svg;
try {
const configs = await getPageConfigs({ locale: "de" });
const cfg = configs[0];
const rawLogo = (cfg as { logo?: unknown })?.logo;
const rawSrc =
typeof rawLogo === "string"
? rawLogo
: (rawLogo as { src?: string } | undefined)?.src ?? null;
if (!rawSrc) return null;
const cmsBase = (env.PUBLIC_CMS_URL || "http://localhost:3000").replace(/\/$/, "");
const u = rawSrc.startsWith("//")
? `https:${rawSrc}`
: rawSrc.startsWith("/")
? `${cmsBase}${rawSrc}`
: rawSrc.startsWith("http")
? rawSrc
: `${cmsBase}/api/assets/${rawSrc}`;
if (!u.split("?")[0].toLowerCase().endsWith(".svg")) return null;
const res = await fetch(u);
if (!res.ok) return null;
let text = (await res.text()).trim();
text = text.replace(/<\?xml[^?]*\?>/g, "").replace(/<!DOCTYPE[^>]*>/g, "").trim();
if (!text.startsWith("<svg")) return null;
logoSvgCache = { svg: text, recolored: {} };
return text;
} catch {
return null;
}
}
function colorizeLogo(svg: string, color: string): string {
if (!logoSvgCache) return svg;
if (logoSvgCache.recolored[color]) return logoSvgCache.recolored[color];
const recolored = svg
.replace(/fill="(?!none)[^"]*"/gi, `fill="${color}"`)
.replace(/stroke="(?!none)[^"]*"/gi, `stroke="${color}"`)
.replace(/currentColor/gi, color);
logoSvgCache.recolored[color] = recolored;
return recolored;
}
function formatDateLong(iso: string | undefined | null): string {
if (!iso) return "";
try {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
});
} catch {
return "";
}
}
const cache = new Map<string, { png: Buffer; ts: number }>();
const CACHE_TTL = 10 * 60_000;
const CACHE_VERSION = "v1";
function parseFormat(v: string | null): Format {
if (v === "square") return "square";
if (v === "story") return "story";
return "og";
}
function parseTheme(v: string | null): Theme {
if (v === "transparent-ondark") return "transparent-ondark";
if (v === "transparent-onlight" || v === "transparent") return "transparent-onlight";
return "wald";
}
function escapeHtml(s: string): string {
return s.replace(/</g, "").replace(/>/g, "");
}
function stripMd(s: string): string {
return s
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/\[[^\]]*\]\([^)]*\)/g, "")
.replace(/[#*_`~>\[\]]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
const slug = params.slug;
const formatParam = parseFormat(url.searchParams.get("format"));
const themeParam = parseTheme(url.searchParams.get("theme"));
const cacheKey = `${CACHE_VERSION}:${slug}:${formatParam}:${themeParam}`;
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 getPostBySlug(slug, { locale: "de" });
if (!itemRaw) throw error(404, "Beitrag nicht gefunden");
const post = itemRaw as typeof itemRaw & {
headline?: string;
linkName?: string;
subheadline?: string;
excerpt?: string;
content?: string;
tags?: unknown;
created?: string;
updated?: string;
};
const { w, h } = SIZES[formatParam];
const fonts = await loadFonts();
const logoSvg = await loadLogo();
const headline = (post.headline ?? post.linkName ?? "").trim();
const subheadline = (post.subheadline ?? "").trim();
const excerpt = (post.excerpt ?? "").trim();
const snippetSrc = subheadline || excerpt || stripMd(post.content ?? "");
const dateLabel = formatDateLong(post.updated ?? post.created);
// Tags → Pill (erster Tag oder Kategorie aus Inhalt erkennen)
const tagList = Array.isArray(post.tags)
? (post.tags as unknown[]).map((t) =>
typeof t === "string" ? t : (t as { _slug?: string; name?: string })?.name ?? (t as { _slug?: string })?._slug ?? "",
)
: [];
const titleLower = headline.toLowerCase();
const tagPillInfo = (() => {
const candidates = [titleLower, ...tagList.map((t) => t.toLowerCase())];
const has = (kw: string) => candidates.some((c) => c.includes(kw));
if (has("pressemitteilung") || has("presse")) return { label: "Presse", color: "#b08a52" };
if (has("demo") || has("protest")) return { label: "Demo", color: "#e35651" };
if (has("rückblick") || has("retrospektive")) return { label: "Rückblick", color: "#5e6fa3" };
if (has("interview")) return { label: "Interview", color: "#436e85" };
if (has("studien") || has("studie") || has("forschung"))
return { label: "Studie", color: "#6e8c46" };
if (has("urteil") || has("gericht") || has("rechtlich"))
return { label: "Rechtlich", color: "#8a567a" };
if (has("kommentar") || has("lesermeinung") || has("meinung"))
return { label: "Meinung", color: "#7f8c6e" };
if (tagList[0]) {
const first = tagList[0].replace(/^tag-?/i, "").replace(/-/g, " ").trim();
if (first) return { label: first.slice(0, 20), color: "#3d6b4f" };
}
return { label: "Beitrag", color: "#3d6b4f" };
})();
const siteUrl = (env.PUBLIC_SITE_URL || "https://windwiderstand.de").replace(/\/$/, "");
const eventUrl = `${siteUrl}/post/${encodeURIComponent(slug)}`;
const isTransparent = themeParam !== "wald";
const isLight = themeParam === "transparent-ondark";
const palette = isTransparent
? isLight
? {
bg: "transparent",
text: "#ffffff",
textMute: "rgba(255,255,255,0.85)",
textDim: "rgba(255,255,255,0.7)",
accent: "#a8e6c0",
headerLabel: "rgba(255,255,255,0.75)",
footerSep: "rgba(255,255,255,0.25)",
qrBoxBg: "#ffffff",
}
: {
bg: "transparent",
text: "#0a2011",
textMute: "#1a4d28",
textDim: "#2d7a45",
accent: "#1a4d28",
headerLabel: "#236335",
footerSep: "rgba(10,32,17,0.25)",
qrBoxBg: "#0a2011",
}
: {
bg: "linear-gradient(135deg,#0a2011 0%,#12361c 55%,#1a4d28 100%)",
text: "#ffffff",
textMute: "#d9e8dc",
textDim: "#b3d1b9",
accent: "#7fb58a",
headerLabel: "#7fb58a",
footerSep: "#1a4d28",
qrBoxBg: "#ffffff",
};
const isStory = formatParam === "story";
const isSquare = formatParam === "square";
const verticalStack = isStory || isSquare;
// Dynamische Headline-Fontsize je Länge/Format
const hLen = headline.length;
const headlineFs = isStory
? hLen > 70 ? 64 : 80
: isSquare
? hLen > 60 ? 48 : 60
: hLen > 40 ? 42 : 56;
const subFs = isStory ? 32 : isSquare ? 26 : 24;
const datePillFs = isStory ? 26 : 20;
const padX = isStory ? 90 : isSquare ? 80 : 90;
const qrSize = isStory ? 260 : 170;
const headerFs = isStory ? 26 : 20;
const footerFs = isStory ? 24 : 20;
// Snippet clampen
const snipMaxLen = isStory ? 220 : isSquare ? 160 : 130;
const snipClamped = snippetSrc
? snippetSrc.length > snipMaxLen
? snippetSrc.slice(0, snipMaxLen - 3) + "…"
: snippetSrc
: "";
const headlineClamped = hLen > 140 ? headline.slice(0, 137) + "…" : headline;
const qrDark = "#0a2011";
const qrLight = "#ffffff";
const qrPngBuf = await qrToBuffer(eventUrl, {
errorCorrectionLevel: "M",
margin: 1,
scale: 8,
color: { dark: `${qrDark}ff`, light: `${qrLight}ff` },
});
const qrDataUrl = `data:image/png;base64,${qrPngBuf.toString("base64")}`;
const logoColor = palette.headerLabel;
const logoDataUrl = logoSvg
? `data:image/svg+xml;base64,${Buffer.from(colorizeLogo(logoSvg, logoColor)).toString("base64")}`
: null;
const logoH = isStory ? 60 : isSquare ? 44 : 36;
// Tag-Pill rechts oben
const tagPill = `<div style="display:flex;align-items:center;background:${tagPillInfo.color};color:#ffffff;padding:6px 14px;border-radius:999px;font-size:${headerFs - 4}px;letter-spacing:0.12em;font-weight:700;text-transform:uppercase;">${escapeHtml(tagPillInfo.label)}</div>`;
const brandBlock = logoDataUrl
? `<div style="display:flex;align-items:center;"><img src="${logoDataUrl}" width="${logoH}" height="${logoH}" style="width:${logoH}px;height:${logoH}px;" /><div style="display:flex;margin-left:14px;">Windwiderstand Thüringen</div></div>`
: `<div style="display:flex;">Windwiderstand Thüringen</div>`;
const headerRow = `
<div style="display:flex;justify-content:space-between;align-items:center;font-size:${headerFs}px;letter-spacing:0.18em;font-weight:600;color:${palette.headerLabel};text-transform:uppercase;">
${brandBlock}
${tagPill}
</div>
`;
// Datum-Pill (klein, oben-links nach Header)
const dateBlock = dateLabel
? `<div style="display:flex;align-items:center;font-size:${datePillFs}px;font-weight:600;color:${palette.textDim};letter-spacing:0.05em;text-transform:uppercase;margin-top:${isStory ? 48 : 28}px;">${dateLabel}</div>`
: "";
const headlineBlock = `
<div style="display:flex;flex-direction:column;margin-top:${isStory ? 20 : 14}px;">
<div style="display:flex;font-size:${headlineFs}px;font-weight:700;color:${palette.text};line-height:1.1;letter-spacing:-0.015em;">${escapeHtml(headlineClamped)}</div>
${
snipClamped
? `<div style="display:flex;font-size:${subFs}px;font-weight:400;color:${palette.textMute};line-height:1.35;margin-top:${isStory ? 32 : 20}px;">${escapeHtml(snipClamped)}</div>`
: ""
}
</div>
`;
const qrAlign = verticalStack ? "flex-start" : "center";
const qrBlock = `
<div style="display:flex;flex-direction:column;align-items:${qrAlign};gap:8px;">
<div style="display:flex;background:${palette.qrBoxBg};padding:14px;border-radius:12px;">
<img src="${qrDataUrl}" style="width:${qrSize}px;height:${qrSize}px;" />
</div>
<div style="display:flex;font-size:${isStory ? 22 : 16}px;color:${palette.textDim};font-weight:500;letter-spacing:0.05em;">Beitrag lesen</div>
</div>
`;
const footerBlock = `
<div style="display:flex;justify-content:space-between;align-items:center;font-size:${footerFs}px;color:${palette.accent};border-top:1px solid ${palette.footerSep};padding-top:${isStory ? 24 : 18}px;margin-top:${isStory ? 36 : 28}px;flex-shrink:0;">
<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>
`;
const mainArea = verticalStack
? `
<div style="display:flex;flex-direction:column;flex:1;">
${dateBlock}
${headlineBlock}
</div>
<div style="display:flex;justify-content:flex-start;margin-top:${isStory ? 40 : 28}px;margin-bottom:${isStory ? 40 : 24}px;">
${qrBlock}
</div>
`
: `
<div style="display:flex;flex:1;gap:40px;">
<div style="display:flex;flex-direction:column;flex:1;min-width:0;">
${dateBlock}
${headlineBlock}
</div>
${qrBlock}
</div>
`;
const template = `
<div style="display:flex;flex-direction:column;width:${w}px;height:${h}px;background:${palette.bg};color:${palette.text};padding:${padX}px;font-family:Inter;position:relative;">
${headerRow}
${mainArea}
${footerBlock}
</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 },
background: isTransparent ? "rgba(0,0,0,0)" : undefined,
});
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 });
};
+1
View File
@@ -300,6 +300,7 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
commentPageId: showCommentSection ? slug : null,
commentCount: showCommentSection ? (await getCommentCounts([slug]))[slug] ?? 0 : 0,
canonicalUrl: canonical,
postSlug: slug,
readingTimeMinutes,
translations,
seoTitle: post.seoTitle ?? postHeadline,
+1
View File
@@ -72,6 +72,7 @@
readingTimeMinutes={data.readingTimeMinutes}
commentPageId={data.commentPageId}
commentTargetId="comments-section"
socialSlug={data.postSlug}
/>
</div>
</div>