Files
windwiderstand/src/lib/components/SocialImageModal.svelte
T
Peter Meier 08181129c4
Deploy / verify (push) Successful in 1m3s
Deploy / deploy (push) Successful in 1m23s
feat: Event-Bild im Social-Bild + UX-Verbesserungen
Social-Bild Calendar:
- Termin-Bild als Thumbnail neben QR-Code (Reihenfolge: QR | Bild)
- Wrapper transparent, Bild im natürlichen Aspect via object-fit:contain
  und fit=inside auf transform-API — kein Crop, kein Letterbox
- Wachstum auf qrSize+28 begrenzt
- Title-Fontsize bei vorhandenem Thumb zusätzlich verkleinert
- Eckige Kanten (kein border-radius) für QR-Box und Thumb

Calendar-Block:
- Vorschaubild im Termin-Item gleich gerendert: object-contain,
  max-w-24/max-h-24, kein Crop
- "Teilen"-Gruppe in Aktions-Toolbar bricht auf Mobile auf eigene
  Zeile um, Trennstrich nur auf sm+ sichtbar

Direkt-Link:
- Calendar: /kalender/?social=<slug> öffnet Modal automatisch
- Post: /post/<slug>?social=1 öffnet Modal automatisch
- Modal-Header: "Direktlink"-Button kopiert URL ins Clipboard

Skeleton-Loader:
- Animierter grauer Skeleton während Bild-Render via satori
- Per Theme-Wechsel resetten, fade-in via opacity-Transition
- Story-Container Fix: explizite 280x158 Maße statt aspect-ratio

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 13:35:38 +02:00

223 lines
9.3 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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");
let linkCopied = $state(false);
/** Direktlink, der dieses Modal erneut öffnet — abhängig von endpointBase. */
const directLink = $derived.by(() => {
if (typeof window === "undefined") return "";
const origin = window.location.origin;
if (endpointBase.includes("/calendar")) {
return `${origin}/kalender/?social=${encodeURIComponent(slug)}`;
}
if (endpointBase.includes("/post")) {
return `${origin}/post/${encodeURIComponent(slug)}?social=1`;
}
return `${origin}/`;
});
async function copyDirectLink() {
try {
await navigator.clipboard.writeText(directLink);
linkCopied = true;
setTimeout(() => (linkCopied = false), 2000);
} catch {
linkCopied = false;
}
}
// Loading-State pro Format. Skeleton wird angezeigt bis das jeweilige
// Bild fertig geladen ist. Bei Theme-Wechsel resetten.
let loadedOg = $state(false);
let loadedSq = $state(false);
let loadedStory = $state(false);
$effect(() => {
// Reset bei Theme-Änderung (neuer Request → neuer Loading-Zustand)
void theme;
loadedOg = false;
loadedSq = false;
loadedStory = false;
});
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>
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
onclick={copyDirectLink}
class="inline-flex items-center gap-1 rounded-md border border-stein-300 bg-white px-2 py-1 text-[11px] font-medium text-stein-700 hover:bg-stein-50"
title="Direktlink zu dieser Vorschau kopieren"
aria-label="Direktlink kopieren"
>
<Icon icon={linkCopied ? "mdi:check" : "mdi:link-variant"} class="size-3.5" />
{linkCopied ? "Kopiert" : "Direktlink"}
</button>
<button
type="button"
onclick={onclose}
class="text-stein-400 hover:text-stein-700"
aria-label="Schließen"
>
<Icon icon="mdi:close" class="size-5" />
</button>
</div>
</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>
<div class="relative w-full aspect-[1200/630] rounded-sm border border-stein-200 overflow-hidden {previewBg}">
{#if !loadedOg}
<div class="absolute inset-0 animate-pulse bg-gradient-to-br from-stein-200 via-stein-300 to-stein-200"></div>
{/if}
<img
src={ogUrl}
alt="Social-Bild Quer"
class="absolute inset-0 h-full w-full transition-opacity duration-200 {loadedOg ? 'opacity-100' : 'opacity-0'}"
loading="eager"
onload={() => (loadedOg = true)}
/>
</div>
<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>
<div class="relative mx-auto w-full aspect-square rounded-sm border border-stein-200 overflow-hidden {previewBg}">
{#if !loadedSq}
<div class="absolute inset-0 animate-pulse bg-gradient-to-br from-stein-200 via-stein-300 to-stein-200"></div>
{/if}
<img
src={sqUrl}
alt="Social-Bild Quadrat"
class="absolute inset-0 h-full w-full transition-opacity duration-200 {loadedSq ? 'opacity-100' : 'opacity-0'}"
loading="eager"
onload={() => (loadedSq = true)}
/>
</div>
<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>
<div class="relative mx-auto h-[280px] w-[158px] rounded-sm border border-stein-200 overflow-hidden {previewBg}">
{#if !loadedStory}
<div class="absolute inset-0 animate-pulse bg-gradient-to-br from-stein-200 via-stein-300 to-stein-200"></div>
{/if}
<img
src={storyUrl}
alt="Social-Bild Story"
class="absolute inset-0 h-full w-full transition-opacity duration-200 {loadedStory ? 'opacity-100' : 'opacity-0'}"
loading="eager"
onload={() => (loadedStory = true)}
/>
</div>
<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}