style(post): polish post header + actions toolbar
- PostActions: every item is a rounded-full pill with subtle border; the three icon-only actions (share/copy/print) cluster in one segmented control with divider lines, count + reading-time stand alone left and right - Share button only renders when `navigator.share` is available so desktop doesn't get a duplicate of "Copy link" - All action buttons drop their inline labels and use `title=` + `aria-label` for tooltip-only access; copy still flips icon to a checkmark for 1.5s on success - Empty comment state stacks vertically on mobile (icon above), goes horizontal + left-aligned on ≥sm - Post header rebuilt: image fixed at 150x150 (no more mobile blow-up), right column is `flex flex-col gap-3` so date/tags/actions share one rhythm instead of ad-hoc margins - Reading-time now also walks `row*Content` and pulls `content`/`body`/ `text` strings — Termin-Posts with empty body but markdown rows now show a meaningful estimate Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -400,8 +400,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if topLevel.length === 0}
|
{#if topLevel.length === 0}
|
||||||
<div class="flex flex-col items-center gap-2 py-8 text-center">
|
<div class="flex flex-col items-center gap-2 py-8 text-center sm:flex-row sm:items-center sm:text-left sm:py-4 sm:gap-3">
|
||||||
<Icon icon="mdi:comment-text-outline" class="size-10 text-zinc-300" />
|
<Icon icon="mdi:comment-text-outline" class="size-10 text-zinc-300 shrink-0" />
|
||||||
<p class="text-sm text-zinc-500">{t(T.comments_empty)}</p>
|
<p class="text-sm text-zinc-500">{t(T.comments_empty)}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -64,7 +64,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function onCommentClick(e: MouseEvent) {
|
function onCommentClick(e: MouseEvent) {
|
||||||
const el = document.getElementById(commentTargetId) as HTMLDetailsElement | null;
|
const el = document.getElementById(
|
||||||
|
commentTargetId,
|
||||||
|
) as HTMLDetailsElement | null;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
el.open = true;
|
el.open = true;
|
||||||
@@ -76,6 +78,17 @@
|
|||||||
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let shareTimer: 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() {
|
function flashCopied() {
|
||||||
copied = true;
|
copied = true;
|
||||||
if (copyTimer) clearTimeout(copyTimer);
|
if (copyTimer) clearTimeout(copyTimer);
|
||||||
@@ -110,74 +123,88 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function share() {
|
async function share() {
|
||||||
const w = window as unknown as { navigator: Navigator & { share?: (data: ShareData) => Promise<void> } };
|
const nav = navigator as Navigator & {
|
||||||
if (typeof w.navigator.share === "function") {
|
share?: (data: ShareData) => Promise<void>;
|
||||||
|
};
|
||||||
|
if (typeof nav.share !== "function") return;
|
||||||
try {
|
try {
|
||||||
await w.navigator.share({ title, url });
|
await nav.share({ title, url });
|
||||||
flashShared();
|
flashShared();
|
||||||
return;
|
|
||||||
} catch {
|
} catch {
|
||||||
// user cancelled or share failed → fall through to copy
|
// user cancelled — no fallback, copy-link button covers that case
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await copyLink();
|
|
||||||
}
|
|
||||||
|
|
||||||
function print() {
|
function print() {
|
||||||
window.print();
|
window.print();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="inline-flex flex-wrap items-center gap-x-3 gap-y-1 {cls}">
|
<!--
|
||||||
|
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}
|
{#if commentPageId}
|
||||||
<a
|
<a
|
||||||
href="#{commentTargetId}"
|
href="#{commentTargetId}"
|
||||||
onclick={onCommentClick}
|
onclick={onCommentClick}
|
||||||
aria-label={t(T.comments_jump_to)}
|
aria-label={t(T.comments_jump_to)}
|
||||||
class="no-underline! inline-flex items-center gap-1 text-xs font-medium text-zinc-600 hover:text-zinc-900 transition-colors"
|
title={t(T.comments_jump_to)}
|
||||||
|
class="no-underline! inline-flex items-center gap-1 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs font-medium text-zinc-700! hover:bg-zinc-50 hover:border-zinc-300 transition-colors"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:comment-outline" class="size-4" />
|
<Icon icon="mdi:comment-outline" class="size-3.5" />
|
||||||
<span class="tabular-nums">{commentCount ?? "…"}</span>
|
<span class="tabular-nums">{commentCount ?? "…"}</span>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="inline-flex items-center rounded-full border border-zinc-200 bg-white overflow-hidden divide-x divide-zinc-200 print:hidden">
|
||||||
|
{#if canShare}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={share}
|
onclick={share}
|
||||||
class="inline-flex items-center gap-1 text-xs font-medium text-zinc-600 hover:text-zinc-900 transition-colors"
|
class="inline-flex items-center px-2.5 py-1 text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 transition-colors"
|
||||||
aria-label={t(T.post_action_share)}
|
aria-label={t(T.post_action_share)}
|
||||||
|
title={t(T.post_action_share)}
|
||||||
>
|
>
|
||||||
<Icon icon={shared ? "mdi:check" : "mdi:share-variant-outline"} class="size-4" />
|
<Icon
|
||||||
<span class="hidden sm:inline">{shared ? t(T.post_action_copied) : t(T.post_action_share)}</span>
|
icon={shared ? "mdi:check" : "mdi:share-variant-outline"}
|
||||||
|
class="size-3.5"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={copyLink}
|
onclick={copyLink}
|
||||||
class="inline-flex items-center gap-1 text-xs font-medium text-zinc-600 hover:text-zinc-900 transition-colors"
|
class="inline-flex items-center px-2.5 py-1 text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 transition-colors"
|
||||||
aria-label={t(T.post_action_copy)}
|
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 ? "mdi:check" : "mdi:link-variant"} class="size-4" />
|
<Icon icon={copied ? "mdi:check" : "mdi:link-variant"} class="size-3.5" />
|
||||||
<span class="hidden sm:inline">{copied ? t(T.post_action_copied) : t(T.post_action_copy)}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={print}
|
onclick={print}
|
||||||
class="inline-flex items-center gap-1 text-xs font-medium text-zinc-600 hover:text-zinc-900 transition-colors print:hidden"
|
class="inline-flex items-center px-2.5 py-1 text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 transition-colors"
|
||||||
aria-label={t(T.post_action_print)}
|
aria-label={t(T.post_action_print)}
|
||||||
|
title={t(T.post_action_print)}
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:printer-outline" class="size-4" />
|
<Icon icon="mdi:printer-outline" class="size-3.5" />
|
||||||
<span class="hidden sm:inline">{t(T.post_action_print)}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if readingTimeMinutes != null && readingTimeMinutes > 0}
|
{#if readingTimeMinutes != null && readingTimeMinutes > 0}
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1 text-xs text-zinc-500"
|
class="inline-flex items-center gap-1 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs text-zinc-500"
|
||||||
title={t(T.post_action_reading_time, { minutes: readingTimeMinutes })}
|
title={t(T.post_action_reading_time, { minutes: readingTimeMinutes })}
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:clock-outline" class="size-4" />
|
<Icon icon="mdi:clock-outline" class="size-3.5" />
|
||||||
<span class="tabular-nums">{t(T.post_action_reading_time, { minutes: readingTimeMinutes })}</span>
|
<span class="tabular-nums"
|
||||||
|
>{t(T.post_action_reading_time, { minutes: readingTimeMinutes })}</span
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -237,10 +237,36 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reading-time estimate. ~200 words per minute for German non-fiction.
|
// Reading-time estimate. ~200 words per minute for German non-fiction.
|
||||||
// Strips HTML tags, then counts whitespace-separated tokens. Includes only
|
// Pulls text from headline + body markdown AND from referenced row blocks
|
||||||
// the prose body (headline + content); rows aren't part of the prose flow.
|
// (Termin-Posts have an empty body; their text lives in row1Content
|
||||||
|
// markdown/text references). Walks the post tree once, collecting any
|
||||||
|
// `content` / `body` / `text` string fields below row*Content. HTML tags
|
||||||
|
// get stripped before the word count.
|
||||||
const readingTimeMinutes = (() => {
|
const readingTimeMinutes = (() => {
|
||||||
const plain = (contentHeadlineHtml + " " + contentBodyHtml)
|
const parts: string[] = [contentHeadlineHtml, contentBodyHtml];
|
||||||
|
const collect = (node: unknown) => {
|
||||||
|
if (!node || typeof node !== "object") return;
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const item of node) collect(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const obj = node as Record<string, unknown>;
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (key === "content" || key === "body" || key === "text") {
|
||||||
|
parts.push(value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
collect(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collect((post as { row1Content?: unknown }).row1Content);
|
||||||
|
collect((post as { row2Content?: unknown }).row2Content);
|
||||||
|
collect((post as { row3Content?: unknown }).row3Content);
|
||||||
|
|
||||||
|
const plain = parts
|
||||||
|
.join(" ")
|
||||||
.replace(/<[^>]+>/g, " ")
|
.replace(/<[^>]+>/g, " ")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
|
|||||||
@@ -24,10 +24,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="md:flex gap-2 items-end">
|
<div class="my-4 md:flex md:items-start md:gap-4">
|
||||||
{#if data.rawPostImageUrl}
|
{#if data.rawPostImageUrl}
|
||||||
<div
|
<div
|
||||||
class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg aspect-square"
|
class="w-[150px] shrink-0 overflow-hidden border border-white ring-1 ring-black/50 shadow-lg aspect-square"
|
||||||
>
|
>
|
||||||
<RustyImage
|
<RustyImage
|
||||||
src={data.rawPostImageUrl}
|
src={data.rawPostImageUrl}
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if data.postImageUrl}
|
{:else if data.postImageUrl}
|
||||||
<div
|
<div
|
||||||
class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg aspect-square"
|
class="w-[150px] shrink-0 overflow-hidden border border-white ring-1 ring-black/50 shadow-lg aspect-square"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={data.postImageUrl}
|
src={data.postImageUrl}
|
||||||
@@ -57,26 +57,23 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="mt-2 mb-4! md:mb-1 min-w-0 gap-2">
|
|
||||||
{#if data.postDate || data.commentPageId}
|
<!-- Right column: stacked rows with consistent rhythm -->
|
||||||
<div class="flex flex-nowrap gap-2 items-center shrink-0 mb-2">
|
<div class="mt-3 md:mt-0 min-w-0 flex flex-col gap-3">
|
||||||
{#if data.postDate}
|
{#if data.postDate}
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<Tag label={data.postDate} variant="date" />
|
<Tag label={data.postDate} variant="date" />
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
{#if (data.post.postTag ?? []).length > 0}
|
||||||
class="flex flex-nowrap gap-2 items-center shrink-0 p-1 pb-4 -ml-1 overflow-x-auto overflow-y-hidden"
|
<div class="flex flex-wrap items-center gap-1.5 -ml-0.5">
|
||||||
>
|
|
||||||
<Tags
|
<Tags
|
||||||
tags={data.post.postTag ?? []}
|
tags={data.post.postTag ?? []}
|
||||||
variant="green"
|
variant="green"
|
||||||
tagBase="/posts/tag"
|
tagBase="/posts/tag"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-zinc-600">
|
|
||||||
<PostActions
|
<PostActions
|
||||||
url={data.canonicalUrl}
|
url={data.canonicalUrl}
|
||||||
title={data.seoTitle}
|
title={data.seoTitle}
|
||||||
@@ -85,6 +82,7 @@
|
|||||||
commentTargetId="comments-section"
|
commentTargetId="comments-section"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<article
|
<article
|
||||||
class="mt-8 h-entry"
|
class="mt-8 h-entry"
|
||||||
|
|||||||
Reference in New Issue
Block a user