Files
windwiderstand/src/lib/components/PostActions.svelte
T
Peter Meier 5591bd86f0
Deploy / verify (push) Successful in 1m17s
Deploy / deploy (push) Successful in 1m39s
feat(post): Vorlesen-Button (Web Speech API), nur wenn unterstützt
PostActions: 🔊 Vorlesen liest Artikeltext (Titel+Body, versteckte Elemente
gefiltert), Toggle Pause/Weiter + Stop, deutsche Stimme, satzweise, stoppt
bei Seitenwechsel. Button erscheint nur wenn speechSynthesis verfügbar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 09:54:12 +02:00

346 lines
11 KiB
Svelte

<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount, onDestroy } 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();
}
// ── Vorlesen (Web Speech API) ───────────────────────────────────────────
// Button erscheint nur, wenn der Browser Sprachausgabe unterstützt.
let canSpeak = $state(false);
let speaking = $state(false);
let paused = $state(false);
$effect(() => {
canSpeak = typeof window !== "undefined" && "speechSynthesis" in window;
});
function getArticleText(): string {
const art = document.querySelector("article");
if (!art) return "";
const clone = art.cloneNode(true) as HTMLElement;
clone
.querySelectorAll("[hidden], .sr-only, script, style, nav, figure figcaption")
.forEach((n) => n.remove());
return (clone.textContent ?? "").replace(/\s+/g, " ").trim();
}
function speak() {
const synth = window.speechSynthesis;
synth.cancel();
const text = getArticleText();
if (!text) return;
// In Sätze zerlegen — manche Engines schneiden lange Utterances ab.
const chunks = text.match(/[^.!?]+[.!?]*(?:\s|$)/g) ?? [text];
const voice =
synth.getVoices().find((v) => v.lang?.toLowerCase().startsWith("de")) ?? null;
chunks.forEach((c, i) => {
const u = new SpeechSynthesisUtterance(c.trim());
u.lang = "de-DE";
if (voice) u.voice = voice;
if (i === chunks.length - 1)
u.onend = () => {
speaking = false;
paused = false;
};
synth.speak(u);
});
speaking = true;
paused = false;
}
function toggleSpeak() {
const synth = window.speechSynthesis;
if (!speaking) {
speak();
} else if (paused) {
synth.resume();
paused = false;
} else {
synth.pause();
paused = true;
}
}
function stopSpeak() {
window.speechSynthesis.cancel();
speaking = false;
paused = false;
}
onDestroy(() => {
if (typeof window !== "undefined" && "speechSynthesis" in window)
window.speechSynthesis.cancel();
});
</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}
{#if canSpeak}
<button
type="button"
onclick={toggleSpeak}
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors {speaking && !paused ? 'text-wald-700!' : ''}"
aria-label={!speaking ? "Vorlesen" : paused ? "Weiterlesen" : "Pause"}
title={!speaking ? "Vorlesen" : paused ? "Weiterlesen" : "Pause"}
>
<Icon icon={!speaking ? "lucide:volume-2" : paused ? "lucide:play" : "lucide:pause"} class="size-3.5" />
</button>
{#if speaking}
<button
type="button"
onclick={stopSpeak}
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="Vorlesen stoppen"
title="Vorlesen stoppen"
>
<Icon icon="lucide:square" class="size-3.5" />
</button>
{/if}
{/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>