Files
windwiderstand/src/lib/components/SocialImageModal.svelte
T
Peter Meier 0ab2c58eb4
Deploy / verify (push) Successful in 1m26s
Deploy / deploy (push) Successful in 2m26s
feat(header): icon nav redesign + migrate icons mdi→lucide
- Header desktop nav: social icons grouped in a wald-tinted pill with
  labels, active-state (aria-current + bold), opacity hover (no per-item bg)
- Mobile overlay: social links as icon+label rows with active-state
- Search button: lucide:search, icon-only
- Migrate all icons mdi→lucide app-wide (234 refs); brand/language logos
  (google, whatsapp, mastodon, telegram, language-*) stay on mdi
- generate-icons: emit mdi + lucide subsets; add @iconify-json/lucide
- iconify-offline: register lucide collection
- scripts/icon-map|validate|apply: reusable migration tooling

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:44:56 +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 ? "lucide:check" : "lucide:link"} 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="lucide:x" 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="lucide: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="lucide: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="lucide: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}