Compare commits

...

4 Commits

Author SHA1 Message Date
Peter Meier 6f14e4f150 style(post): polish post header + actions toolbar
Deploy / verify (push) Successful in 43s
Deploy / deploy (push) Successful in 1m4s
- 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>
2026-04-28 15:08:04 +02:00
Peter Meier 6b2b7245f4 refactor: fold comment count into PostActions toolbar
CommentCountBadge was a separate component rendered next to PostActions
— two adjacent strips of buttons that should have been one toolbar.
Merges the count fetch + click-to-scroll behaviour into PostActions
behind `commentPageId` / `commentTargetId` props. The standalone
CommentCountBadge.svelte is now redundant; deleted.

Layout under the post hero is now a single row:
[💬 3]  [↗ Teilen]  [📋 Link]  [🖨️]  [⏱ 4 min]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 14:52:30 +02:00
Peter Meier 3318fe9e17 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>
2026-04-28 14:42:16 +02:00
Peter Meier e4804ce673 i18n(comments): all UI strings through translations
Pulls every German string out of Comments.svelte, PostCard.svelte and
CommentCountBadge.svelte and routes it through `useTranslate()` +
the `T.*` key registry. New keys (`comments_*`, `post_overview` count
aria, `comments_jump_to`) are registered in `lib/translations.ts`.

Default DE values land in
`cms_content/windwiderstand/de/translation_bundle/app.json5` (committed
separately) so the CMS-loaded bundle keeps the same look without each
component falling back to its raw key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 14:34:48 +02:00
7 changed files with 351 additions and 118 deletions
@@ -1,62 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { env } from "$env/dynamic/public";
let {
pageId,
environment,
targetId = "comments-section",
class: cls = "",
}: {
pageId: string;
environment?: string;
targetId?: string;
class?: string;
} = $props();
let count = $state<number | null>(null);
const cmsBase = (
env.PUBLIC_CMS_URL ||
import.meta.env.PUBLIC_CMS_URL ||
"http://localhost:3000"
).replace(/\/$/, "");
$effect(() => {
const url = `${cmsBase}/api/comments/${encodeURIComponent(pageId)}/count${
environment ? `?_environment=${encodeURIComponent(environment)}` : ""
}`;
let cancelled = false;
fetch(url)
.then((r) => r.json())
.then((d) => {
if (cancelled) return;
count = typeof d?.count === "number" ? d.count : 0;
})
.catch(() => {
if (!cancelled) count = 0;
});
return () => {
cancelled = true;
};
});
function onClick(e: MouseEvent) {
const el = document.getElementById(targetId) as HTMLDetailsElement | null;
if (!el) return;
e.preventDefault();
el.open = true;
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
</script>
<a
href="#{targetId}"
onclick={onClick}
aria-label="Zu Kommentaren springen"
class="no-underline! inline-flex items-center gap-1 text-xs font-medium transition-colors {cls}"
>
<Icon icon="mdi:comment-outline" class="size-4" />
<span class="tabular-nums">{count ?? "…"}</span>
</a>
+34 -27
View File
@@ -2,6 +2,7 @@
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { env } from "$env/dynamic/public";
import { useTranslate, T } from "$lib/translations";
let {
pageId,
@@ -15,6 +16,8 @@
bodyMax?: number;
} = $props();
const t = useTranslate();
type Status = "approved" | "pending" | "spam";
type PublicComment = {
@@ -187,7 +190,7 @@
}
async function deleteComment(id: string) {
if (!confirm("Kommentar löschen?")) return;
if (!confirm(t(T.comments_confirm_delete))) return;
try {
const r = await fetch(`${apiUrl()}`.replace(`/${pageId}`, `/${pageId}/${id}`), {
method: "DELETE",
@@ -310,12 +313,12 @@
<button
type="submit"
class="rounded bg-zinc-800 px-3 py-1 font-medium text-white hover:bg-zinc-700"
>Speichern</button>
>{t(T.comments_save)}</button>
<button
type="button"
onclick={cancelEdit}
class="text-zinc-500 hover:text-zinc-900"
>Abbrechen</button>
>{t(T.comments_cancel)}</button>
<span class="ml-auto text-zinc-400 tabular-nums">{editBody.length} / {bodyMax}</span>
</div>
{#if editError}<p class="text-red-700 text-xs mt-1">{editError}</p>{/if}
@@ -330,7 +333,7 @@
class="text-xs text-zinc-600 hover:text-zinc-900 inline-flex items-center gap-1"
>
<Icon icon="mdi:pencil-outline" class="size-3" />
Bearbeiten
{t(T.comments_edit)}
</button>
<button
type="button"
@@ -338,7 +341,7 @@
class="text-xs text-zinc-600 hover:text-red-700 inline-flex items-center gap-1"
>
<Icon icon="mdi:trash-can-outline" class="size-3" />
Löschen
{t(T.comments_delete)}
</button>
</div>
{/if}
@@ -349,7 +352,7 @@
<div class="flex flex-wrap items-baseline gap-2 text-sm">
<strong class="font-medium text-zinc-900">{c.author}</strong>
{#if c.can_edit}
<span class="text-[10px] uppercase tracking-wide text-zinc-400">du</span>
<span class="text-[10px] uppercase tracking-wide text-zinc-400">{t(T.comments_self_marker)}</span>
{/if}
<time
class="text-zinc-500 text-xs"
@@ -357,12 +360,12 @@
title={fmtAbs(c.created_at)}
>{fmtRel(c.created_at)}</time>
{#if c.updated_at}
<span class="text-zinc-400 text-xs" title={fmtAbs(c.updated_at)}>(bearbeitet)</span>
<span class="text-zinc-400 text-xs" title={fmtAbs(c.updated_at)}>{t(T.comments_edited)}</span>
{/if}
{#if c.status === "pending"}
<span class="ml-auto inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-800">
<Icon icon="mdi:clock-outline" class="size-3" />
wartet
{t(T.comments_pending_short)}
</span>
{/if}
</div>
@@ -370,7 +373,7 @@
<div class="comments {cls}">
{#if loading && comments.length === 0}
<ul class="space-y-4 list-none p-0" aria-busy="true" aria-label="Lade Kommentare">
<ul class="space-y-4 list-none p-0" aria-busy="true" aria-label={t(T.comments_loading)}>
{#each Array(2) as _, i (i)}
<li class="flex gap-3 list-none!">
<div class="size-9 shrink-0 rounded-full bg-zinc-200 animate-pulse"></div>
@@ -386,20 +389,20 @@
<div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800">
<Icon icon="mdi:alert-circle-outline" class="size-5 shrink-0 mt-0.5" />
<div class="flex-1">
<p class="font-medium">Kommentare konnten nicht geladen werden.</p>
<p class="font-medium">{t(T.comments_load_error_title)}</p>
<p class="mt-1 text-xs text-red-700">{loadError}</p>
</div>
<button
type="button"
onclick={loadComments}
class="text-xs text-red-800 underline hover:no-underline"
>Erneut versuchen</button>
>{t(T.comments_retry)}</button>
</div>
{:else}
{#if topLevel.length === 0}
<div class="flex flex-col items-center gap-2 py-8 text-center">
<Icon icon="mdi:comment-text-outline" class="size-10 text-zinc-300" />
<p class="text-sm text-zinc-500">Noch keine Kommentare. Sei der erste.</p>
<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 shrink-0" />
<p class="text-sm text-zinc-500">{t(T.comments_empty)}</p>
</div>
{:else}
<ul class="space-y-5 mb-8 list-none p-0">
@@ -423,10 +426,12 @@
>
<Icon icon="mdi:reply" class="size-3" />
{replyTo === c.id
? "Abbrechen"
: replies.length > 0
? `${replies.length} ${replies.length === 1 ? 'Antwort' : 'Antworten'}`
: "Antworten"}
? t(T.comments_cancel)
: replies.length === 0
? t(T.comments_reply)
: replies.length === 1
? t(T.comments_reply_count_one)
: t(T.comments_reply_count_other, { count: replies.length })}
</button>
</div>
@@ -435,7 +440,7 @@
<input
type="text"
bind:value={replyAuthor}
placeholder="Dein Name"
placeholder={t(T.comments_name_placeholder)}
required
maxlength="80"
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
@@ -444,7 +449,7 @@
bind:value={replyBody}
use:autosize
onkeydown={submitOnCmdEnter}
placeholder="Deine Antwort"
placeholder={t(T.comments_reply_placeholder)}
required
rows="2"
maxlength={bodyMax}
@@ -456,7 +461,7 @@
type="submit"
disabled={submitting}
class="px-3 py-1.5 bg-zinc-800 text-white rounded text-sm font-medium hover:bg-zinc-700 disabled:opacity-50"
>Antwort senden</button>
>{t(T.comments_reply_send)}</button>
<span class="ml-auto text-xs text-zinc-400 tabular-nums">{replyBody.length} / {bodyMax}</span>
</div>
</form>
@@ -486,11 +491,11 @@
{/if}
<form onsubmit={onSubmit} class="border-t border-zinc-200 pt-5 space-y-3">
<h3 class="font-medium text-sm text-zinc-900">Kommentar schreiben</h3>
<h3 class="font-medium text-sm text-zinc-900">{t(T.comments_form_title)}</h3>
<input
type="text"
bind:value={author}
placeholder="Dein Name"
placeholder={t(T.comments_name_placeholder)}
required
maxlength="80"
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
@@ -499,7 +504,7 @@
bind:value={body}
use:autosize
onkeydown={submitOnCmdEnter}
placeholder="Schreibe etwas Konstruktives. Cmd/Ctrl+Enter sendet."
placeholder={t(T.comments_body_placeholder)}
required
rows="3"
maxlength={bodyMax}
@@ -522,19 +527,21 @@
disabled={submitting || !body.trim() || !author.trim()}
class="px-4 py-2 bg-zinc-800 text-white rounded text-sm font-medium hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? "Sende …" : "Senden"}
{submitting ? t(T.comments_sending) : t(T.comments_send)}
</button>
{#if requireApproval}
<span class="text-zinc-500 inline-flex items-center gap-1">
<Icon icon="mdi:shield-check-outline" class="size-3.5" />
Wird vor Veröffentlichung geprüft.
{t(T.comments_approval_hint)}
</span>
{/if}
<span class="ml-auto text-zinc-400 tabular-nums">{body.length} / {bodyMax}</span>
</div>
{#if totalPending > 0}
<p class="text-xs text-zinc-500 italic">
{totalPending} {totalPending === 1 ? 'Kommentar wartet' : 'Kommentare warten'} noch auf Freigabe (nur für dich sichtbar).
{totalPending === 1
? t(T.comments_pending_summary_one)
: t(T.comments_pending_summary_other, { count: totalPending })}
</p>
{/if}
</form>
+210
View File
@@ -0,0 +1,210 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { env } from "$env/dynamic/public";
import { useTranslate, T } from "$lib/translations";
let {
url,
title,
readingTimeMinutes = null,
commentPageId = null,
commentEnvironment,
commentTargetId = "comments-section",
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;
/** Page id for the comment plugin. `null` hides the count badge. */
commentPageId?: string | null;
/** Optional space override for the comment count fetch. */
commentEnvironment?: string;
/** DOM id of the comment accordion (used by the count-badge click). */
commentTargetId?: string;
class?: string;
} = $props();
const t = useTranslate();
// ── Comment count fetch (only when commentPageId is set) ────────────────
let commentCount = $state<number | null>(null);
const cmsBase = (
env.PUBLIC_CMS_URL ||
import.meta.env.PUBLIC_CMS_URL ||
"http://localhost:3000"
).replace(/\/$/, "");
$effect(() => {
if (!commentPageId) {
commentCount = null;
return;
}
const fetchUrl = `${cmsBase}/api/comments/${encodeURIComponent(commentPageId)}/count${
commentEnvironment
? `?_environment=${encodeURIComponent(commentEnvironment)}`
: ""
}`;
let cancelled = false;
fetch(fetchUrl)
.then((r) => r.json())
.then((d) => {
if (cancelled) return;
commentCount = typeof d?.count === "number" ? d.count : 0;
})
.catch(() => {
if (!cancelled) commentCount = 0;
});
return () => {
cancelled = true;
};
});
function onCommentClick(e: MouseEvent) {
const el = document.getElementById(
commentTargetId,
) as HTMLDetailsElement | null;
if (!el) return;
e.preventDefault();
el.open = true;
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
let copied = $state(false);
let shared = $state(false);
let copyTimer: 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() {
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 nav = navigator as Navigator & {
share?: (data: ShareData) => Promise<void>;
};
if (typeof nav.share !== "function") return;
try {
await nav.share({ title, url });
flashShared();
} catch {
// user cancelled — no fallback, copy-link button covers that case
}
}
function print() {
window.print();
}
</script>
<!--
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}
<a
href="#{commentTargetId}"
onclick={onCommentClick}
aria-label={t(T.comments_jump_to)}
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-3.5" />
<span class="tabular-nums">{commentCount ?? "…"}</span>
</a>
{/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
type="button"
onclick={share}
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)}
title={t(T.post_action_share)}
>
<Icon
icon={shared ? "mdi:check" : "mdi:share-variant-outline"}
class="size-3.5"
/>
</button>
{/if}
<button
type="button"
onclick={copyLink}
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={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-3.5" />
</button>
<button
type="button"
onclick={print}
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)}
title={t(T.post_action_print)}
>
<Icon icon="mdi:printer-outline" class="size-3.5" />
</button>
</div>
{#if readingTimeMinutes != null && readingTimeMinutes > 0}
<span
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 })}
>
<Icon icon="mdi:clock-outline" class="size-3.5" />
<span class="tabular-nums"
>{t(T.post_action_reading_time, { minutes: readingTimeMinutes })}</span
>
</span>
{/if}
</div>
+5 -2
View File
@@ -6,6 +6,9 @@
import EventBadges from "./EventBadges.svelte";
import RustyImage from "./RustyImage.svelte";
import Card from "./Card.svelte";
import { useTranslate, T } from "$lib/translations";
const t = useTranslate();
type PostWithImage = PostEntry & {
_resolvedImageUrl?: string;
@@ -98,8 +101,8 @@
<div class="absolute top-2 left-2">
<span
class="inline-flex items-center gap-1 rounded-full bg-white/90 px-2 py-1 text-[.65rem] font-semibold tabular-nums text-zinc-700 shadow-sm"
aria-label="{commentCount} Kommentare"
title="{commentCount} Kommentare"
aria-label={t(T.comments_count_aria, { count: commentCount })}
title={t(T.comments_count_aria, { count: commentCount })}
>
<Icon icon="mdi:comment-outline" class="size-3" />
{commentCount}
+35
View File
@@ -133,6 +133,41 @@ const TRANSLATION_KEYS = [
"post_map_activate",
"post_map_open_osm",
"post_comments",
// Comment plugin (Comments.svelte / PostCard count pill / PostActions toolbar).
"comments_loading",
"comments_load_error_title",
"comments_retry",
"comments_empty",
"comments_edit",
"comments_delete",
"comments_save",
"comments_cancel",
"comments_confirm_delete",
"comments_reply",
"comments_reply_count_one",
"comments_reply_count_other",
"comments_reply_send",
"comments_send",
"comments_sending",
"comments_form_title",
"comments_name_placeholder",
"comments_body_placeholder",
"comments_reply_placeholder",
"comments_approval_hint",
"comments_edited",
"comments_pending_short",
"comments_pending_full",
"comments_self_marker",
"comments_pending_summary_one",
"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];
+41
View File
@@ -236,6 +236,45 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
jsonLd.push(eventLd);
}
// Reading-time estimate. ~200 words per minute for German non-fiction.
// Pulls text from headline + body markdown AND from referenced row blocks
// (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 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(/\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 +293,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,
+18 -19
View File
@@ -5,8 +5,8 @@
import EventBadges from "$lib/components/EventBadges.svelte";
import EventMap from "$lib/components/EventMap.svelte";
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";
@@ -24,10 +24,10 @@
{/if}
</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}
<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
src={data.rawPostImageUrl}
@@ -44,7 +44,7 @@
</div>
{:else if data.postImageUrl}
<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
src={data.postImageUrl}
@@ -57,32 +57,31 @@
/>
</div>
{/if}
<div class="mt-2 mb-4! md:mb-1 min-w-0 gap-2">
{#if data.postDate || data.commentPageId}
<div class="flex flex-nowrap gap-2 items-center shrink-0 mb-2">
<!-- Right column: stacked rows with consistent rhythm -->
<div class="mt-3 md:mt-0 min-w-0 flex flex-col gap-3">
{#if data.postDate}
<div class="flex flex-wrap items-center gap-2">
<Tag label={data.postDate} variant="date" />
{/if}
</div>
{/if}
<div
class="flex flex-nowrap gap-2 items-center shrink-0 p-1 pb-4 -ml-1 overflow-x-auto overflow-y-hidden"
>
{#if (data.post.postTag ?? []).length > 0}
<div class="flex flex-wrap items-center gap-1.5 -ml-0.5">
<Tags
tags={data.post.postTag ?? []}
variant="green"
tagBase="/posts/tag"
/>
</div>
</div>
</div>
<div>
{#if data.commentPageId}
<CommentCountBadge
pageId={data.commentPageId}
targetId="comments-section"
/>
{/if}
<PostActions
url={data.canonicalUrl}
title={data.seoTitle}
readingTimeMinutes={data.readingTimeMinutes}
commentPageId={data.commentPageId}
commentTargetId="comments-section"
/>
</div>
</div>
<article