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 { useTranslate, T } from "$lib/translations";
|
||||||
import QrButton from "$lib/components/QrButton.svelte";
|
import QrButton from "$lib/components/QrButton.svelte";
|
||||||
|
|
||||||
|
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
url,
|
url,
|
||||||
title,
|
title,
|
||||||
@@ -12,6 +14,7 @@
|
|||||||
commentPageId = null,
|
commentPageId = null,
|
||||||
commentEnvironment,
|
commentEnvironment,
|
||||||
commentTargetId = "comments-section",
|
commentTargetId = "comments-section",
|
||||||
|
socialSlug = null,
|
||||||
class: cls = "",
|
class: cls = "",
|
||||||
}: {
|
}: {
|
||||||
/** Absolute URL of the page being shared. */
|
/** Absolute URL of the page being shared. */
|
||||||
@@ -26,9 +29,13 @@
|
|||||||
commentEnvironment?: string;
|
commentEnvironment?: string;
|
||||||
/** DOM id of the comment accordion (used by the count-badge click). */
|
/** DOM id of the comment accordion (used by the count-badge click). */
|
||||||
commentTargetId?: string;
|
commentTargetId?: string;
|
||||||
|
/** Wenn gesetzt: zeigt Social-Bild-Button, der Modal öffnet. */
|
||||||
|
socialSlug?: string | null;
|
||||||
class?: string;
|
class?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
let socialModalOpen = $state(false);
|
||||||
|
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
|
||||||
// ── Comment count fetch (only when commentPageId is set) ────────────────
|
// ── Comment count fetch (only when commentPageId is set) ────────────────
|
||||||
@@ -195,6 +202,17 @@
|
|||||||
>
|
>
|
||||||
<Icon icon="mdi:printer-outline" class="size-3.5" />
|
<Icon icon="mdi:printer-outline" class="size-3.5" />
|
||||||
</button>
|
</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
|
<QrButton
|
||||||
value={url}
|
value={url}
|
||||||
label={title}
|
label={title}
|
||||||
@@ -203,6 +221,17 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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}
|
{#if readingTimeMinutes != null && readingTimeMinutes > 0}
|
||||||
<span
|
<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"
|
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 "$lib/iconify-offline";
|
||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
import ImageModal from "$lib/components/ImageModal.svelte";
|
import ImageModal from "$lib/components/ImageModal.svelte";
|
||||||
|
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
|
||||||
|
|
||||||
type EventCardItem = CalendarItemData & {
|
type EventCardItem = CalendarItemData & {
|
||||||
start: Date;
|
start: Date;
|
||||||
@@ -447,7 +448,6 @@
|
|||||||
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);
|
let socialModal = $state<{ slug: string; title: string } | null>(null);
|
||||||
let socialTheme = $state<"wald" | "transparent-onlight" | "transparent-ondark">("wald");
|
|
||||||
|
|
||||||
function openSocial(item: EventCardItem) {
|
function openSocial(item: EventCardItem) {
|
||||||
if (!item._slug) return;
|
if (!item._slug) return;
|
||||||
@@ -1294,146 +1294,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if socialModal}
|
{#if socialModal}
|
||||||
{@const themeQ = socialTheme === "wald" ? "" : `&theme=${socialTheme}`}
|
<SocialImageModal
|
||||||
{@const ogUrl = `/api/social/calendar/${socialModal.slug}?format=og${themeQ}`}
|
open={true}
|
||||||
{@const sqUrl = `/api/social/calendar/${socialModal.slug}?format=square${themeQ}`}
|
title={socialModal.title}
|
||||||
{@const storyUrl = `/api/social/calendar/${socialModal.slug}?format=story${themeQ}`}
|
slug={socialModal.slug}
|
||||||
{@const previewBg =
|
endpointBase="/api/social/calendar"
|
||||||
socialTheme === "wald"
|
downloadPrefix="termin"
|
||||||
? "bg-stein-50"
|
onclose={closeSocial}
|
||||||
: 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>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<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,
|
commentPageId: showCommentSection ? slug : null,
|
||||||
commentCount: showCommentSection ? (await getCommentCounts([slug]))[slug] ?? 0 : 0,
|
commentCount: showCommentSection ? (await getCommentCounts([slug]))[slug] ?? 0 : 0,
|
||||||
canonicalUrl: canonical,
|
canonicalUrl: canonical,
|
||||||
|
postSlug: slug,
|
||||||
readingTimeMinutes,
|
readingTimeMinutes,
|
||||||
translations,
|
translations,
|
||||||
seoTitle: post.seoTitle ?? postHeadline,
|
seoTitle: post.seoTitle ?? postHeadline,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
readingTimeMinutes={data.readingTimeMinutes}
|
readingTimeMinutes={data.readingTimeMinutes}
|
||||||
commentPageId={data.commentPageId}
|
commentPageId={data.commentPageId}
|
||||||
commentTargetId="comments-section"
|
commentTargetId="comments-section"
|
||||||
|
socialSlug={data.postSlug}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user