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()