feat: print-friendly layout + comment count on every post overview
- 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:
+39
@@ -506,3 +506,42 @@ footer a:hover,
|
|||||||
border-left: 1px solid var(--color-stein-200);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
import { postHref } from "$lib/blog-utils";
|
import { postHref } from "$lib/blog-utils";
|
||||||
import { t as tStatic, T } from "$lib/translations";
|
import { t as tStatic, T } from "$lib/translations";
|
||||||
import type { Translations } 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 {
|
interface TagEntry {
|
||||||
_slug?: string;
|
_slug?: string;
|
||||||
@@ -115,43 +115,15 @@
|
|||||||
|
|
||||||
const totalCount = $derived(totalPosts);
|
const totalCount = $derived(totalPosts);
|
||||||
|
|
||||||
// Bulk-fetch comment counts for the visible posts. Uses a single
|
// Bulk-fetch comment counts for the visible posts (single request per render).
|
||||||
// `/api/comments/counts` request keyed on the post slug. Failures keep the
|
let commentCounts = $state<CommentCountsMap>({});
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const slugs = posts.map(postSlug).filter(Boolean);
|
const ctrl = new AbortController();
|
||||||
if (slugs.length === 0) {
|
fetchCommentCounts(posts.map(postSlugForComments), { signal: ctrl.signal })
|
||||||
commentCounts = {};
|
.then((map) => {
|
||||||
return;
|
commentCounts = map;
|
||||||
}
|
|
||||||
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 = {};
|
|
||||||
});
|
});
|
||||||
return () => {
|
return () => ctrl.abort();
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -298,7 +270,7 @@
|
|||||||
post={post}
|
post={post}
|
||||||
href={postHref(post)}
|
href={postHref(post)}
|
||||||
tagBase={`${basePath}/tag`}
|
tagBase={`${basePath}/tag`}
|
||||||
commentCount={commentCounts[postSlug(post)] ?? null}
|
commentCount={commentCounts[postSlugForComments(post)] ?? null}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if commentCount != null && commentCount > 0}
|
{#if commentCount != null}
|
||||||
<div class="absolute top-2 left-2">
|
<div class="absolute top-2 left-2">
|
||||||
<span
|
<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"
|
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 type { PostEntry } from "$lib/cms";
|
||||||
import { postHref } from "$lib/blog-utils";
|
import { postHref } from "$lib/blog-utils";
|
||||||
import { useTranslate, T } from "$lib/translations";
|
import { useTranslate, T } from "$lib/translations";
|
||||||
|
import { fetchCommentCounts, postSlugForComments, type CommentCountsMap } from "$lib/comment-counts";
|
||||||
|
|
||||||
let { block, class: className = "" }: { block: PostOverviewBlockData; class?: string } = $props();
|
let { block, class: className = "" }: { block: PostOverviewBlockData; class?: string } = $props();
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
@@ -24,6 +25,17 @@
|
|||||||
// posts and no intro text — otherwise an empty section with just a heading
|
// posts and no intro text — otherwise an empty section with just a heading
|
||||||
// sits on the page.
|
// sits on the page.
|
||||||
const hasContent = $derived(posts.length > 0 || textHtml.length > 0);
|
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>
|
</script>
|
||||||
|
|
||||||
{#if hasContent}
|
{#if hasContent}
|
||||||
@@ -42,13 +54,23 @@
|
|||||||
{#if design === "list" && posts.length > 0}
|
{#if design === "list" && posts.length > 0}
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
{#each posts as post}
|
{#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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if design === "cards" && posts.length > 0}
|
{:else if design === "cards" && posts.length > 0}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{#each posts as post}
|
{#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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -269,6 +269,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="print:hidden">
|
||||||
<Header
|
<Header
|
||||||
links={data.headerLinks}
|
links={data.headerLinks}
|
||||||
socialLinks={data.socialLinks}
|
socialLinks={data.socialLinks}
|
||||||
@@ -277,8 +278,10 @@
|
|||||||
translations={data.translations}
|
translations={data.translations}
|
||||||
/>
|
/>
|
||||||
<HeaderOverlay />
|
<HeaderOverlay />
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if topBannerData}
|
{#if topBannerData}
|
||||||
|
<div class="print:hidden">
|
||||||
<TopBanner
|
<TopBanner
|
||||||
banner={topBannerData.banner as import('$lib/cms').FullwidthBannerEntry | null}
|
banner={topBannerData.banner as import('$lib/cms').FullwidthBannerEntry | null}
|
||||||
resolvedImages={topBannerData.resolvedImages}
|
resolvedImages={topBannerData.resolvedImages}
|
||||||
@@ -287,17 +290,22 @@
|
|||||||
headline={topBannerData.headline}
|
headline={topBannerData.headline}
|
||||||
subheadline={topBannerData.subheadline}
|
subheadline={topBannerData.subheadline}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<main id="main-content" tabindex="-1" class="flex-1 min-h-[60em]">
|
<main id="main-content" tabindex="-1" class="flex-1 min-h-[60em] print:min-h-0">
|
||||||
<div class="container-custom pb-12">
|
<div class="container-custom pb-12 print:pb-0">
|
||||||
{#if breadcrumbItems.length > 0}
|
{#if breadcrumbItems.length > 0}
|
||||||
|
<div class="print:hidden">
|
||||||
<Breadcrumbs items={breadcrumbItems} />
|
<Breadcrumbs items={breadcrumbItems} />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div class="print:hidden">
|
||||||
<Footer footer={data.footerData} translations={data.translations} />
|
<Footer footer={data.footerData} translations={data.translations} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</TranslationProvider>
|
</TranslationProvider>
|
||||||
|
|||||||
@@ -165,6 +165,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.commentPageId}
|
{#if data.commentPageId}
|
||||||
|
<div class="print:hidden">
|
||||||
<Accordion
|
<Accordion
|
||||||
id="comments-section"
|
id="comments-section"
|
||||||
label={t(data.translations, T.post_comments)}
|
label={t(data.translations, T.post_comments)}
|
||||||
@@ -175,4 +176,5 @@
|
|||||||
<Comments pageId={data.commentPageId} />
|
<Comments pageId={data.commentPageId} />
|
||||||
</div>
|
</div>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user