From 46fefd5cc452481652eb2c901c97e2c058fd939d Mon Sep 17 00:00:00 2001
From: Till JS
Date: Thu, 7 May 2026 22:46:47 +0200
Subject: [PATCH] =?UTF-8?q?feat(cards):=20Phase=20=CE=B5.4=20=E2=80=94=20C?=
=?UTF-8?q?ard=20list=20+=20discussions=20on=20/d/?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- DiscussionService.countsForDeck: bulk count (visible) comments per
card-content-hash for one deck. Mounted at GET
/v1/decks/:slug/discussion-counts so the public deck page can
render comment badges without N+1 fetches.
- on /d/: lists the latest version's cards,
renders a one-line preview + "💬 N" badge, and expands the
inline on click. Anonymous visitors see counts;
posting requires auth (CardDiscussions already gates that).
---
apps/cards/apps/web/src/lib/api/cards-api.ts | 5 +
.../src/lib/components/DeckCardList.svelte | 104 ++++++++++++++++++
.../apps/web/src/routes/d/[slug]/+page.svelte | 5 +
.../cards-server/src/routes/discussions.ts | 5 +
.../cards-server/src/services/discussions.ts | 27 ++++-
5 files changed, 145 insertions(+), 1 deletion(-)
create mode 100644 apps/cards/apps/web/src/lib/components/DeckCardList.svelte
diff --git a/apps/cards/apps/web/src/lib/api/cards-api.ts b/apps/cards/apps/web/src/lib/api/cards-api.ts
index 3bcb9d28e..134942c41 100644
--- a/apps/cards/apps/web/src/lib/api/cards-api.ts
+++ b/apps/cards/apps/web/src/lib/api/cards-api.ts
@@ -241,6 +241,11 @@ export const cardsApi = {
request<{ ok: true }>(`/v1/pull-requests/${id}/reject`, { method: 'POST' }),
},
discussions: {
+ countsForDeck: (deckSlug: string) =>
+ request>(
+ `/v1/decks/${encodeURIComponent(deckSlug)}/discussion-counts`,
+ { auth: 'optional' }
+ ),
listForCard: (contentHash: string) =>
request(`/v1/cards/${encodeURIComponent(contentHash)}/discussions`, {
auth: 'optional',
diff --git a/apps/cards/apps/web/src/lib/components/DeckCardList.svelte b/apps/cards/apps/web/src/lib/components/DeckCardList.svelte
new file mode 100644
index 000000000..7ec2374b5
--- /dev/null
+++ b/apps/cards/apps/web/src/lib/components/DeckCardList.svelte
@@ -0,0 +1,104 @@
+
+
+
+
+
+ Karten {cards.length > 0 ? `(${cards.length})` : ''}
+
+ {#if loading}
+ Lädt…
+ {/if}
+
+
+ {#if error}
+
+ {error}
+
+ {:else if cards.length === 0 && !loading}
+
+ Diese Version enthält keine Karten.
+
+ {:else}
+
+ {#each cards as c (c.contentHash)}
+ {@const n = counts[c.contentHash] ?? 0}
+ {@const isOpen = openHash === c.contentHash}
+ -
+
+
+ {#if isOpen}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
diff --git a/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte b/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte
index 995d7a6a9..bc1d39579 100644
--- a/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte
+++ b/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte
@@ -11,6 +11,7 @@
import { isSubscribedLocally, subscribeAndPull, unsubscribe } from '$lib/services/subscribe';
import { cardDeckTable } from '$lib/data/database';
import PullRequestsSection from '$lib/components/PullRequestsSection.svelte';
+ import DeckCardList from '$lib/components/DeckCardList.svelte';
const slug = $derived(page.params.slug as string);
@@ -204,6 +205,10 @@
Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')}
+ {#if version}
+
+ {/if}
+
{/if}
diff --git a/services/cards-server/src/routes/discussions.ts b/services/cards-server/src/routes/discussions.ts
index 8a663d548..67370ee9e 100644
--- a/services/cards-server/src/routes/discussions.ts
+++ b/services/cards-server/src/routes/discussions.ts
@@ -23,6 +23,11 @@ export function createDiscussionRoutes(service: DiscussionService) {
return c.json(list);
});
+ router.get('/decks/:slug/discussion-counts', async (c) => {
+ const counts = await service.countsForDeck(c.req.param('slug'));
+ return c.json(counts);
+ });
+
router.post('/cards/:contentHash/discussions', async (c) => {
const user = requireUser(c.get('user'));
const parsed = postSchema.safeParse(await c.req.json().catch(() => ({})));
diff --git a/services/cards-server/src/services/discussions.ts b/services/cards-server/src/services/discussions.ts
index 2c4ad2a32..f7521b780 100644
--- a/services/cards-server/src/services/discussions.ts
+++ b/services/cards-server/src/services/discussions.ts
@@ -9,7 +9,7 @@
* if we want full nesting later it's already there.
*/
-import { and, asc, eq } from 'drizzle-orm';
+import { and, asc, eq, sql } from 'drizzle-orm';
import type { Database } from '../db/connection';
import { cardDiscussions, publicDecks } from '../db/schema';
import { ForbiddenError, NotFoundError } from '../lib/errors';
@@ -52,6 +52,31 @@ export class DiscussionService {
return row;
}
+ /**
+ * Bulk count of (visible) comments per card-content-hash for one
+ * deck — powers the "Karten" overview on the public deck page so
+ * we don't fan out one request per card.
+ */
+ async countsForDeck(deckSlug: string): Promise> {
+ const deck = await this.db.query.publicDecks.findFirst({
+ where: eq(publicDecks.slug, deckSlug),
+ });
+ if (!deck) throw new NotFoundError('Deck not found');
+
+ const rows = await this.db
+ .select({
+ contentHash: cardDiscussions.cardContentHash,
+ count: sql`count(*)::int`.as('count'),
+ })
+ .from(cardDiscussions)
+ .where(and(eq(cardDiscussions.deckId, deck.id), eq(cardDiscussions.hidden, false)))
+ .groupBy(cardDiscussions.cardContentHash);
+
+ const out: Record = {};
+ for (const r of rows) out[r.contentHash] = r.count;
+ return out;
+ }
+
async listForCard(cardContentHash: string) {
const rows = await this.db
.select()