Files
windwiderstand/src/lib/components/PostActions.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

257 lines
8.1 KiB
Svelte

<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import "$lib/iconify-offline";
import { env } from "$env/dynamic/public";
import { useTranslate, T } from "$lib/translations";
import QrButton from "$lib/components/QrButton.svelte";
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
let {
url,
title,
readingTimeMinutes = null,
commentPageId = null,
commentEnvironment,
commentTargetId = "comments-section",
socialSlug = null,
class: cls = "",
}: {
/** Absolute URL of the page being shared. */
url: string;
/** Page headline — used by the native share-sheet. */
title: string;
/** Estimated reading time. `null` hides the badge. */
readingTimeMinutes?: number | null;
/** Page id for the comment plugin. `null` hides the count badge. */
commentPageId?: string | null;
/** Optional space override for the comment count fetch. */
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);
// Direkt-Link-Support: ?social=1 öffnet das Social-Bild-Modal automatisch
onMount(() => {
if (!socialSlug) return;
const params = new URLSearchParams(window.location.search);
if (params.get("social") === "1") {
socialModalOpen = true;
}
});
const t = useTranslate();
// ── Comment count fetch (only when commentPageId is set) ────────────────
let commentCount = $state<number | null>(null);
const cmsBase = (
env.PUBLIC_CMS_URL ||
import.meta.env.PUBLIC_CMS_URL ||
"http://localhost:3000"
).replace(/\/$/, "");
$effect(() => {
if (!commentPageId) {
commentCount = null;
return;
}
const fetchUrl = `${cmsBase}/api/comments/${encodeURIComponent(commentPageId)}/count${
commentEnvironment
? `?_environment=${encodeURIComponent(commentEnvironment)}`
: ""
}`;
let cancelled = false;
fetch(fetchUrl)
.then((r) => r.json())
.then((d) => {
if (cancelled) return;
commentCount = typeof d?.count === "number" ? d.count : 0;
})
.catch(() => {
if (!cancelled) commentCount = 0;
});
return () => {
cancelled = true;
};
});
function onCommentClick(e: MouseEvent) {
const el = document.getElementById(
commentTargetId,
) as HTMLDetailsElement | null;
if (!el) return;
e.preventDefault();
el.open = true;
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
let copied = $state(false);
let shared = $state(false);
let copyTimer: ReturnType<typeof setTimeout> | null = null;
let shareTimer: ReturnType<typeof setTimeout> | null = null;
// Share button only renders when the browser actually has a native share-
// sheet — otherwise it would just be a duplicate of "Link kopieren".
// SSR has no `navigator`, so this stays false until client mount.
let canShare = $state(false);
$effect(() => {
canShare =
typeof navigator !== "undefined" &&
typeof (navigator as Navigator & { share?: unknown }).share ===
"function";
});
function flashCopied() {
copied = true;
if (copyTimer) clearTimeout(copyTimer);
copyTimer = setTimeout(() => (copied = false), 1500);
}
function flashShared() {
shared = true;
if (shareTimer) clearTimeout(shareTimer);
shareTimer = setTimeout(() => (shared = false), 1500);
}
async function copyLink() {
try {
await navigator.clipboard.writeText(url);
flashCopied();
} catch {
// Fallback: temporary <input> + execCommand for older browsers.
const el = document.createElement("input");
el.value = url;
document.body.appendChild(el);
el.select();
try {
document.execCommand("copy");
flashCopied();
} catch {
// give up — clipboard not available
} finally {
el.remove();
}
}
}
async function share() {
const nav = navigator as Navigator & {
share?: (data: ShareData) => Promise<void>;
};
if (typeof nav.share !== "function") return;
try {
await nav.share({ title, url });
flashShared();
} catch {
// user cancelled — no fallback, copy-link button covers that case
}
}
function print() {
window.print();
}
</script>
<!--
Post-actions toolbar. Each item is a rounded-full pill with a subtle
border so they read as visually equal "badges". Order:
[info badges] | [action buttons]
i.e. info-style items (count, reading time) stand left/right, the
three icon-only actions cluster in the middle.
-->
<div class="inline-flex flex-wrap items-center gap-2 {cls}">
{#if commentPageId}
<a
href="#{commentTargetId}"
onclick={onCommentClick}
aria-label={t(T.comments_jump_to)}
title={t(T.comments_jump_to)}
class="no-underline! inline-flex items-center gap-1 rounded-full border border-stein-200 bg-white px-2.5 py-1 text-xs font-medium text-stein-700! hover:bg-stein-50 hover:border-stein-300 transition-colors"
>
<Icon icon="lucide:message-circle" class="size-3.5" />
<span class="tabular-nums">{commentCount ?? "…"}</span>
</a>
{/if}
<div class="inline-flex items-center rounded-full border border-stein-200 bg-white overflow-hidden divide-x divide-stein-200 print:hidden">
{#if canShare}
<button
type="button"
onclick={share}
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={t(T.post_action_share)}
title={t(T.post_action_share)}
>
<Icon
icon={shared ? "lucide:check" : "lucide:share-2"}
class="size-3.5"
/>
</button>
{/if}
<button
type="button"
onclick={copyLink}
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={copied ? t(T.post_action_copied) : t(T.post_action_copy)}
title={copied ? t(T.post_action_copied) : t(T.post_action_copy)}
>
<Icon icon={copied ? "lucide:check" : "lucide:link"} class="size-3.5" />
</button>
<button
type="button"
onclick={print}
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={t(T.post_action_print)}
title={t(T.post_action_print)}
>
<Icon icon="lucide:printer" 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="lucide:image" class="size-3.5" />
</button>
{/if}
<QrButton
value={url}
label={title}
sublabel="Link teilen"
class="inline-flex items-center px-2.5 py-1 text-base text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
/>
</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"
title={t(T.post_action_reading_time, { minutes: readingTimeMinutes })}
>
<Icon icon="lucide:clock" class="size-3.5" />
<span class="tabular-nums"
>{t(T.post_action_reading_time, { minutes: readingTimeMinutes })}</span
>
</span>
{/if}
</div>