5591bd86f0
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>
346 lines
11 KiB
Svelte
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>
|