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:
@@ -0,0 +1,120 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
import "$lib/iconify-offline";
|
||||||
|
import { useTranslate, T } from "$lib/translations";
|
||||||
|
|
||||||
|
let {
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
readingTimeMinutes = 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;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const t = useTranslate();
|
||||||
|
|
||||||
|
let copied = $state(false);
|
||||||
|
let shared = $state(false);
|
||||||
|
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let shareTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
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 w = window as unknown as { navigator: Navigator & { share?: (data: ShareData) => Promise<void> } };
|
||||||
|
if (typeof w.navigator.share === "function") {
|
||||||
|
try {
|
||||||
|
await w.navigator.share({ title, url });
|
||||||
|
flashShared();
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// user cancelled or share failed → fall through to copy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await copyLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
function print() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="inline-flex flex-wrap items-center gap-x-2 gap-y-1 {cls}">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={share}
|
||||||
|
class="inline-flex items-center gap-1 text-xs font-medium text-zinc-600 hover:text-zinc-900 transition-colors"
|
||||||
|
aria-label={t(T.post_action_share)}
|
||||||
|
>
|
||||||
|
<Icon icon={shared ? "mdi:check" : "mdi:share-variant-outline"} class="size-4" />
|
||||||
|
<span class="hidden sm:inline">{shared ? t(T.post_action_copied) : t(T.post_action_share)}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={copyLink}
|
||||||
|
class="inline-flex items-center gap-1 text-xs font-medium text-zinc-600 hover:text-zinc-900 transition-colors"
|
||||||
|
aria-label={t(T.post_action_copy)}
|
||||||
|
>
|
||||||
|
<Icon icon={copied ? "mdi:check" : "mdi:link-variant"} class="size-4" />
|
||||||
|
<span class="hidden sm:inline">{copied ? t(T.post_action_copied) : t(T.post_action_copy)}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={print}
|
||||||
|
class="inline-flex items-center gap-1 text-xs font-medium text-zinc-600 hover:text-zinc-900 transition-colors print:hidden"
|
||||||
|
aria-label={t(T.post_action_print)}
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:printer-outline" class="size-4" />
|
||||||
|
<span class="hidden sm:inline">{t(T.post_action_print)}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if readingTimeMinutes != null && readingTimeMinutes > 0}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1 text-xs text-zinc-500"
|
||||||
|
title={t(T.post_action_reading_time, { minutes: readingTimeMinutes })}
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:clock-outline" class="size-4" />
|
||||||
|
<span class="tabular-nums">{t(T.post_action_reading_time, { minutes: readingTimeMinutes })}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -162,6 +162,12 @@ const TRANSLATION_KEYS = [
|
|||||||
"comments_pending_summary_other",
|
"comments_pending_summary_other",
|
||||||
"comments_count_aria",
|
"comments_count_aria",
|
||||||
"comments_jump_to",
|
"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;
|
] as const;
|
||||||
|
|
||||||
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
|
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
|
||||||
|
|||||||
@@ -236,6 +236,19 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
|
|||||||
jsonLd.push(eventLd);
|
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 {
|
return {
|
||||||
post,
|
post,
|
||||||
preview: !!preview,
|
preview: !!preview,
|
||||||
@@ -254,6 +267,8 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
|
|||||||
mapEmbedUrl,
|
mapEmbedUrl,
|
||||||
mapLinkUrl,
|
mapLinkUrl,
|
||||||
commentPageId: showCommentSection ? slug : null,
|
commentPageId: showCommentSection ? slug : null,
|
||||||
|
canonicalUrl: canonical,
|
||||||
|
readingTimeMinutes,
|
||||||
translations,
|
translations,
|
||||||
seoTitle: post.seoTitle ?? postHeadline,
|
seoTitle: post.seoTitle ?? postHeadline,
|
||||||
seoDescription: postDescription,
|
seoDescription: postDescription,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import Accordion from "$lib/components/Accordion.svelte";
|
import Accordion from "$lib/components/Accordion.svelte";
|
||||||
import CommentCountBadge from "$lib/components/CommentCountBadge.svelte";
|
import CommentCountBadge from "$lib/components/CommentCountBadge.svelte";
|
||||||
import Comments from "$lib/components/Comments.svelte";
|
import Comments from "$lib/components/Comments.svelte";
|
||||||
|
import PostActions from "$lib/components/PostActions.svelte";
|
||||||
import RustyImage from "$lib/components/RustyImage.svelte";
|
import RustyImage from "$lib/components/RustyImage.svelte";
|
||||||
import { t, T } from "$lib/translations";
|
import { t, T } from "$lib/translations";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
@@ -76,13 +77,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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}
|
{#if data.commentPageId}
|
||||||
<CommentCountBadge
|
<CommentCountBadge
|
||||||
pageId={data.commentPageId}
|
pageId={data.commentPageId}
|
||||||
targetId="comments-section"
|
targetId="comments-section"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
<PostActions
|
||||||
|
url={data.canonicalUrl}
|
||||||
|
title={data.seoTitle}
|
||||||
|
readingTimeMinutes={data.readingTimeMinutes}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article
|
<article
|
||||||
|
|||||||
Reference in New Issue
Block a user