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>
|
||||
|
||||
Reference in New Issue
Block a user