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
+120
View File
@@ -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>
+6
View File
@@ -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];