feat(blog): comment count badge on post overview
PostCard now shows a small comment-count pill in the top-left of the image when count > 0. BlogOverview bulk-fetches counts for the visible posts via the new `/api/comments/counts?ids=…` endpoint and passes them down. One request per page render, regardless of card count. - New helper `getCommentCounts()` in `cms.ts` - `BlogOverview.svelte` derives slugs from posts, fires bulk fetch in `$effect`, hands the result map to `PostCard` - `PostCard.svelte` accepts `commentCount?: number | null`, renders pill only when value is a positive number Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -693,3 +693,34 @@ export async function getCalendarItems(
|
|||||||
return data.items ?? [];
|
return data.items ?? [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-fetches approved comment counts for many page_ids in one request.
|
||||||
|
* Hits the comment plugin's `/api/comments/counts` endpoint. Returns a map
|
||||||
|
* `{ pageId → count }` with `0` for any id missing in the response.
|
||||||
|
*
|
||||||
|
* The comment-plugin endpoints live behind a different env (`PUBLIC_CMS_URL`)
|
||||||
|
* and may be on a different origin in dev. SSR-side calls always hit it
|
||||||
|
* directly; the cookie sync of the visitor session does NOT happen here
|
||||||
|
* (counts are public anyway).
|
||||||
|
*/
|
||||||
|
export async function getCommentCounts(
|
||||||
|
pageIds: string[],
|
||||||
|
options?: { environment?: string },
|
||||||
|
): Promise<Record<string, number>> {
|
||||||
|
if (pageIds.length === 0) return {};
|
||||||
|
const base = getBaseUrl();
|
||||||
|
const url = new URL(`${base}/api/comments/counts`);
|
||||||
|
url.searchParams.set("ids", pageIds.join(","));
|
||||||
|
if (options?.environment) {
|
||||||
|
url.searchParams.set("_environment", options.environment);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
if (!res.ok) return {};
|
||||||
|
const data = (await res.json()) as { counts?: Record<string, number> };
|
||||||
|
return data.counts ?? {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +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";
|
||||||
|
|
||||||
interface TagEntry {
|
interface TagEntry {
|
||||||
_slug?: string;
|
_slug?: string;
|
||||||
@@ -113,6 +114,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalCount = $derived(totalPosts);
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 = {};
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="blog-overview">
|
<div class="blog-overview">
|
||||||
@@ -258,6 +298,7 @@
|
|||||||
post={post}
|
post={post}
|
||||||
href={postHref(post)}
|
href={postHref(post)}
|
||||||
tagBase={`${basePath}/tag`}
|
tagBase={`${basePath}/tag`}
|
||||||
|
commentCount={commentCounts[postSlug(post)] ?? null}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
import "$lib/iconify-offline";
|
||||||
import type { PostEntry } from "$lib/cms";
|
import type { PostEntry } from "$lib/cms";
|
||||||
import PostMeta from "./PostMeta.svelte";
|
import PostMeta from "./PostMeta.svelte";
|
||||||
import EventBadges from "./EventBadges.svelte";
|
import EventBadges from "./EventBadges.svelte";
|
||||||
@@ -15,10 +17,13 @@
|
|||||||
post,
|
post,
|
||||||
href,
|
href,
|
||||||
tagBase = "/posts/tag",
|
tagBase = "/posts/tag",
|
||||||
|
commentCount = null,
|
||||||
}: {
|
}: {
|
||||||
post: PostWithImage;
|
post: PostWithImage;
|
||||||
href: string;
|
href: string;
|
||||||
tagBase?: string;
|
tagBase?: string;
|
||||||
|
/** Approved comment count. `null` = unknown/loading, hide badge. `0` = also hide. */
|
||||||
|
commentCount?: number | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const rawImg = $derived(post._rawImageUrl);
|
const rawImg = $derived(post._rawImageUrl);
|
||||||
@@ -89,6 +94,18 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if commentCount != null && commentCount > 0}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:comment-outline" class="size-3" />
|
||||||
|
{commentCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 flex flex-col justify-start p-4">
|
<div class="flex-1 flex flex-col justify-start p-4">
|
||||||
<PostMeta
|
<PostMeta
|
||||||
|
|||||||
Reference in New Issue
Block a user