From 3318fe9e17d9957626f5c362287a754602600e51 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Tue, 28 Apr 2026 14:42:16 +0200 Subject: [PATCH] feat(post): post actions toolbar (share / copy / print / reading time) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` + `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 --- src/lib/components/PostActions.svelte | 120 +++++++++++++++++++++++++ src/lib/translations.ts | 6 ++ src/routes/post/[slug]/+page.server.ts | 15 ++++ src/routes/post/[slug]/+page.svelte | 8 +- 4 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 src/lib/components/PostActions.svelte diff --git a/src/lib/components/PostActions.svelte b/src/lib/components/PostActions.svelte new file mode 100644 index 0000000..7045e3a --- /dev/null +++ b/src/lib/components/PostActions.svelte @@ -0,0 +1,120 @@ + + +
+ + + + + + + {#if readingTimeMinutes != null && readingTimeMinutes > 0} + + + {t(T.post_action_reading_time, { minutes: readingTimeMinutes })} + + {/if} +
diff --git a/src/lib/translations.ts b/src/lib/translations.ts index 66dcaa9..8dfa41c 100644 --- a/src/lib/translations.ts +++ b/src/lib/translations.ts @@ -162,6 +162,12 @@ const TRANSLATION_KEYS = [ "comments_pending_summary_other", "comments_count_aria", "comments_jump_to", + // Post actions toolbar (share / copy link / print / reading time). + "post_action_share", + "post_action_copy", + "post_action_copied", + "post_action_print", + "post_action_reading_time", ] as const; export type TranslationKey = (typeof TRANSLATION_KEYS)[number]; diff --git a/src/routes/post/[slug]/+page.server.ts b/src/routes/post/[slug]/+page.server.ts index fff18ca..21e523c 100644 --- a/src/routes/post/[slug]/+page.server.ts +++ b/src/routes/post/[slug]/+page.server.ts @@ -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, diff --git a/src/routes/post/[slug]/+page.svelte b/src/routes/post/[slug]/+page.svelte index 93d0272..fcb83e0 100644 --- a/src/routes/post/[slug]/+page.svelte +++ b/src/routes/post/[slug]/+page.svelte @@ -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 @@ -
+
{#if data.commentPageId} {/if} +