feat: print-friendly layout + comment count on every post overview
Deploy / verify (push) Successful in 42s
Deploy / deploy (push) Successful in 1m4s

- Hides Header / HeaderOverlay / Footer / TopBanner / Breadcrumbs and
  the comment accordion when printing. Action toolbar already had
  `print:hidden`.
- Adds @media print rules in app.css: 1.5cm page margins, 11pt body,
  forced black-on-white, expand link URLs after anchor text inside
  articles, keep images and headings together at page breaks.
- PostCard now renders the count pill whenever a value is known (incl.
  zero); previously hidden for `count === 0`, so posts whose comments
  are still pending always looked empty.
- Extracts the bulk-fetch helper to `lib/comment-counts.ts`
  (`fetchCommentCounts` + `postSlugForComments`) and uses it from both
  `BlogOverview` and `PostOverviewBlock`. Homepage / any page with a
  post_overview block now shows the badges too — previously only the
  paginated /posts/* routes did.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-04-28 15:29:15 +02:00
parent 6f14e4f150
commit 3d343822e9
7 changed files with 162 additions and 70 deletions
+39
View File
@@ -506,3 +506,42 @@ footer a:hover,
border-left: 1px solid var(--color-stein-200);
}
}
/* ── Print rules ────────────────────────────────────────────────────────────
Goal: print only the article. `print:hidden` on chrome (header/footer/
banner/breadcrumbs/comments) handles structural removal. The rules below
set page margins, force black-on-white, expand links, and keep images
inside the page break. */
@media print {
@page {
margin: 1.5cm;
}
html,
body {
background: white \!important;
color: black \!important;
font-size: 11pt;
}
/* Show the URL after each anchor inside the article so printed pages stay
useful when the screen-only links are gone. */
article a[href]:not([href^="#"])::after {
content: " (" attr(href) ")";
font-size: 0.85em;
color: #555;
word-break: break-all;
}
/* Don't break inside images, keep figures together with their captions. */
img,
figure {
break-inside: avoid;
max-width: 100% \!important;
height: auto \!important;
}
/* Keep headings with the next paragraph. */
h1,
h2,
h3,
h4 {
break-after: avoid;
}
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Helper for fetching comment counts in bulk and keying them by post slug.
* Used by overview components (BlogOverview, PostOverviewBlock) so each
* render fires a single `/api/comments/counts?ids=…` request instead of
* one request per card.
*/
import { env } from "$env/dynamic/public";
import type { PostEntry } from "$lib/cms";
export type CommentCountsMap = Record<string, number>;
/** Strip the leading slash so it matches `params.slug` (= comment page_id). */
export function postSlugForComments(p: PostEntry): string {
const raw = (p as { slug?: string; _slug?: string }).slug ?? p._slug ?? "";
return raw.replace(/^\//, "").trim();
}
/**
* Bulk-fetch approved comment counts for many posts. Returns a map
* `{ slug: count }` (slug as in `postSlugForComments`). Missing or failed
* responses resolve to an empty object — callers can then `?? null` per slug.
*/
export async function fetchCommentCounts(
slugs: string[],
options?: { environment?: string; signal?: AbortSignal },
): Promise<CommentCountsMap> {
const cleaned = slugs.filter(Boolean);
if (cleaned.length === 0) return {};
const cmsBase = (
env.PUBLIC_CMS_URL ||
import.meta.env.PUBLIC_CMS_URL ||
""
).replace(/\/$/, "");
if (!cmsBase) return {};
const url = new URL(`${cmsBase}/api/comments/counts`);
url.searchParams.set("ids", cleaned.join(","));
if (options?.environment) {
url.searchParams.set("_environment", options.environment);
}
try {
const r = await fetch(url.toString(), { signal: options?.signal });
if (!r.ok) return {};
const d = (await r.json()) as { counts?: CommentCountsMap };
return d?.counts ?? {};
} catch {
return {};
}
}
+9 -37
View File
@@ -8,7 +8,7 @@
import { postHref } from "$lib/blog-utils";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
import { env } from "$env/dynamic/public";
import { fetchCommentCounts, postSlugForComments, type CommentCountsMap } from "$lib/comment-counts";
interface TagEntry {
_slug?: string;
@@ -115,43 +115,15 @@
const totalCount = $derived(totalPosts);
// Bulk-fetch comment counts for the visible posts. Uses a single
// `/api/comments/counts` request keyed on the post slug. Failures keep the
// map empty so cards just don't render the badge.
let commentCounts = $state<Record<string, number>>({});
function postSlug(p: PostEntry): string {
const raw = (p as { slug?: string; _slug?: string }).slug ?? p._slug ?? "";
return raw.replace(/^\//, "").trim();
}
// Bulk-fetch comment counts for the visible posts (single request per render).
let commentCounts = $state<CommentCountsMap>({});
$effect(() => {
const slugs = posts.map(postSlug).filter(Boolean);
if (slugs.length === 0) {
commentCounts = {};
return;
}
const cmsBase = (
env.PUBLIC_CMS_URL ||
import.meta.env.PUBLIC_CMS_URL ||
""
).replace(/\/$/, "");
if (!cmsBase) return;
const url = new URL(`${cmsBase}/api/comments/counts`);
url.searchParams.set("ids", slugs.join(","));
let cancelled = false;
fetch(url.toString())
.then((r) => (r.ok ? r.json() : { counts: {} }))
.then((d) => {
if (cancelled) return;
commentCounts = (d?.counts ?? {}) as Record<string, number>;
})
.catch(() => {
if (!cancelled) commentCounts = {};
const ctrl = new AbortController();
fetchCommentCounts(posts.map(postSlugForComments), { signal: ctrl.signal })
.then((map) => {
commentCounts = map;
});
return () => {
cancelled = true;
};
return () => ctrl.abort();
});
</script>
@@ -298,7 +270,7 @@
post={post}
href={postHref(post)}
tagBase={`${basePath}/tag`}
commentCount={commentCounts[postSlug(post)] ?? null}
commentCount={commentCounts[postSlugForComments(post)] ?? null}
/>
{/each}
</div>
+1 -1
View File
@@ -97,7 +97,7 @@
</span>
</div>
{/if}
{#if commentCount != null && commentCount > 0}
{#if commentCount != null}
<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"
@@ -6,6 +6,7 @@
import type { PostEntry } from "$lib/cms";
import { postHref } from "$lib/blog-utils";
import { useTranslate, T } from "$lib/translations";
import { fetchCommentCounts, postSlugForComments, type CommentCountsMap } from "$lib/comment-counts";
let { block, class: className = "" }: { block: PostOverviewBlockData; class?: string } = $props();
const t = useTranslate();
@@ -24,6 +25,17 @@
// posts and no intro text — otherwise an empty section with just a heading
// sits on the page.
const hasContent = $derived(posts.length > 0 || textHtml.length > 0);
// Bulk-fetch comment counts for the visible posts (single request per render).
let commentCounts = $state<CommentCountsMap>({});
$effect(() => {
const ctrl = new AbortController();
fetchCommentCounts(posts.map(postSlugForComments), { signal: ctrl.signal })
.then((map) => {
commentCounts = map;
});
return () => ctrl.abort();
});
</script>
{#if hasContent}
@@ -42,13 +54,23 @@
{#if design === "list" && posts.length > 0}
<div class="flex flex-col gap-4">
{#each posts as post}
<PostCard post={post} href={postHref(post)} tagBase={tagBase} />
<PostCard
post={post}
href={postHref(post)}
tagBase={tagBase}
commentCount={commentCounts[postSlugForComments(post)] ?? null}
/>
{/each}
</div>
{:else if design === "cards" && posts.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each posts as post}
<PostCard post={post} href={postHref(post)} tagBase={tagBase} />
<PostCard
post={post}
href={postHref(post)}
tagBase={tagBase}
commentCount={commentCounts[postSlugForComments(post)] ?? null}
/>
{/each}
</div>
{/if}
+28 -20
View File
@@ -269,35 +269,43 @@
</div>
</div>
{/if}
<Header
links={data.headerLinks}
socialLinks={data.socialLinks}
logoUrl={data.logoUrl}
logoSvgHtml={data.logoSvgHtml}
translations={data.translations}
/>
<HeaderOverlay />
<div class="print:hidden">
<Header
links={data.headerLinks}
socialLinks={data.socialLinks}
logoUrl={data.logoUrl}
logoSvgHtml={data.logoSvgHtml}
translations={data.translations}
/>
<HeaderOverlay />
</div>
{#if topBannerData}
<TopBanner
banner={topBannerData.banner as import('$lib/cms').FullwidthBannerEntry | null}
resolvedImages={topBannerData.resolvedImages}
responsive={topBannerData.responsive ?? null}
focalCss={topBannerData.focalCss ?? null}
headline={topBannerData.headline}
subheadline={topBannerData.subheadline}
/>
<div class="print:hidden">
<TopBanner
banner={topBannerData.banner as import('$lib/cms').FullwidthBannerEntry | null}
resolvedImages={topBannerData.resolvedImages}
responsive={topBannerData.responsive ?? null}
focalCss={topBannerData.focalCss ?? null}
headline={topBannerData.headline}
subheadline={topBannerData.subheadline}
/>
</div>
{/if}
<main id="main-content" tabindex="-1" class="flex-1 min-h-[60em]">
<div class="container-custom pb-12">
<main id="main-content" tabindex="-1" class="flex-1 min-h-[60em] print:min-h-0">
<div class="container-custom pb-12 print:pb-0">
{#if breadcrumbItems.length > 0}
<Breadcrumbs items={breadcrumbItems} />
<div class="print:hidden">
<Breadcrumbs items={breadcrumbItems} />
</div>
{/if}
{@render children()}
</div>
</main>
<Footer footer={data.footerData} translations={data.translations} />
<div class="print:hidden">
<Footer footer={data.footerData} translations={data.translations} />
</div>
</div>
</TranslationProvider>
+12 -10
View File
@@ -165,14 +165,16 @@
{/if}
{#if data.commentPageId}
<Accordion
id="comments-section"
label={t(data.translations, T.post_comments)}
lazy
class="mt-6"
>
<div class="p-8 border border-zinc-200 rounded-lg">
<Comments pageId={data.commentPageId} />
</div>
</Accordion>
<div class="print:hidden">
<Accordion
id="comments-section"
label={t(data.translations, T.post_comments)}
lazy
class="mt-6"
>
<div class="p-8 border border-zinc-200 rounded-lg">
<Comments pageId={data.commentPageId} />
</div>
</Accordion>
</div>
{/if}