feat: Social-Bild-Generator auch für Posts (Endpoint, Modal, PostActions-Button)
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:
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
readingTimeMinutes={data.readingTimeMinutes}
|
||||
commentPageId={data.commentPageId}
|
||||
commentTargetId="comments-section"
|
||||
socialSlug={data.postSlug}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user