feat(post): post actions toolbar (share / copy / print / reading time)

Adds a small toolbar next to the comment-count badge with four actions:

- Share — uses `navigator.share()` on supporting devices, falls back
  to copy-link otherwise
- Copy link — clipboard API with `<input>` + `execCommand` fallback,
  flashes a checkmark for 1.5s on success
- Print — `window.print()`, hidden via `print:hidden` so it doesn't
  show up in the printed output itself
- Reading time — server-computed from headline+body HTML at ~200 wpm,
  hidden when 0

Reading time + canonical URL come from `+page.server.ts` so SSR
output is identical and shareable URLs render correctly.

All labels go through the translations layer (5 new
`post_action_*` keys).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-04-28 14:42:16 +02:00
parent e4804ce673
commit 3318fe9e17
4 changed files with 148 additions and 1 deletions
+15
View File
@@ -236,6 +236,19 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
jsonLd.push(eventLd);
}
// Reading-time estimate. ~200 words per minute for German non-fiction.
// Strips HTML tags, then counts whitespace-separated tokens. Includes only
// the prose body (headline + content); rows aren't part of the prose flow.
const readingTimeMinutes = (() => {
const plain = (contentHeadlineHtml + " " + contentBodyHtml)
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!plain) return 0;
const wordCount = plain.split(" ").length;
return Math.max(1, Math.ceil(wordCount / 200));
})();
return {
post,
preview: !!preview,
@@ -254,6 +267,8 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
mapEmbedUrl,
mapLinkUrl,
commentPageId: showCommentSection ? slug : null,
canonicalUrl: canonical,
readingTimeMinutes,
translations,
seoTitle: post.seoTitle ?? postHeadline,
seoDescription: postDescription,
+7 -1
View File
@@ -7,6 +7,7 @@
import Accordion from "$lib/components/Accordion.svelte";
import CommentCountBadge from "$lib/components/CommentCountBadge.svelte";
import Comments from "$lib/components/Comments.svelte";
import PostActions from "$lib/components/PostActions.svelte";
import RustyImage from "$lib/components/RustyImage.svelte";
import { t, T } from "$lib/translations";
import type { PageData } from "./$types";
@@ -76,13 +77,18 @@
</div>
</div>
</div>
<div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-zinc-600">
{#if data.commentPageId}
<CommentCountBadge
pageId={data.commentPageId}
targetId="comments-section"
/>
{/if}
<PostActions
url={data.canonicalUrl}
title={data.seoTitle}
readingTimeMinutes={data.readingTimeMinutes}
/>
</div>
<article