feat(cards): Phase ε.4 — Card list + discussions on /d/<slug>

- 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.
- <DeckCardList> on /d/<slug>: lists the latest version's cards,
  renders a one-line preview + "💬 N" badge, and expands the
  inline <CardDiscussions> on click. Anonymous visitors see counts;
  posting requires auth (CardDiscussions already gates that).
This commit is contained in:
Till JS 2026-05-07 22:46:47 +02:00
parent a8ddb6dea4
commit 46fefd5cc4
5 changed files with 145 additions and 1 deletions

View file

@ -241,6 +241,11 @@ export const cardsApi = {
request<{ ok: true }>(`/v1/pull-requests/${id}/reject`, { method: 'POST' }),
},
discussions: {
countsForDeck: (deckSlug: string) =>
request<Record<string, number>>(
`/v1/decks/${encodeURIComponent(deckSlug)}/discussion-counts`,
{ auth: 'optional' }
),
listForCard: (contentHash: string) =>
request<CardDiscussion[]>(`/v1/cards/${encodeURIComponent(contentHash)}/discussions`, {
auth: 'optional',

View file

@ -0,0 +1,104 @@
<script lang="ts">
import { onMount } from 'svelte';
import { cardsApi, CardsApiError, type ServerCard } from '$lib/api/cards-api';
import CardDiscussions from './CardDiscussions.svelte';
interface Props {
deckSlug: string;
semver: string;
}
let { deckSlug, semver }: Props = $props();
let cards = $state<ServerCard[]>([]);
let counts = $state<Record<string, number>>({});
let loading = $state(true);
let error = $state<string | null>(null);
let openHash = $state<string | null>(null);
onMount(load);
async function load() {
loading = true;
error = null;
try {
const [version, c] = await Promise.all([
cardsApi.subscriptions.version(deckSlug, semver),
cardsApi.discussions.countsForDeck(deckSlug),
]);
cards = version.cards;
counts = c;
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
loading = false;
}
}
function preview(card: ServerCard): string {
// Best-effort one-liner: prefer "front" field, then any first non-empty.
const front = card.fields.front ?? card.fields.text ?? '';
if (front) return stripTags(front).slice(0, 140);
const first = Object.values(card.fields).find((v) => v && v.trim());
return first ? stripTags(first).slice(0, 140) : `(${card.type})`;
}
function stripTags(s: string): string {
return s
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
</script>
<section class="mt-10">
<header class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wide text-neutral-400">
Karten {cards.length > 0 ? `(${cards.length})` : ''}
</h2>
{#if loading}
<span class="text-xs text-neutral-600">Lädt…</span>
{/if}
</header>
{#if error}
<p class="mb-3 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">
{error}
</p>
{:else if cards.length === 0 && !loading}
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-4 text-sm text-neutral-500">
Diese Version enthält keine Karten.
</p>
{:else}
<ul class="space-y-2">
{#each cards as c (c.contentHash)}
{@const n = counts[c.contentHash] ?? 0}
{@const isOpen = openHash === c.contentHash}
<li class="rounded-xl border border-neutral-800 bg-neutral-900 p-3">
<button
class="flex w-full items-center justify-between gap-3 text-left"
onclick={() => (openHash = isOpen ? null : c.contentHash)}
>
<div class="min-w-0 flex-1">
<div class="text-xs uppercase tracking-wide text-neutral-500">
#{c.ord + 1} · {c.type}
</div>
<div class="mt-1 truncate text-sm text-neutral-200">{preview(c)}</div>
</div>
<div class="shrink-0 text-xs text-neutral-500">
{#if n > 0}
💬 {n}
{:else}
💬
{/if}
</div>
</button>
{#if isOpen}
<CardDiscussions contentHash={c.contentHash} {deckSlug} />
{/if}
</li>
{/each}
</ul>
{/if}
</section>

View file

@ -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')}
</p>
{#if version}
<DeckCardList deckSlug={deck.slug} semver={version.semver} />
{/if}
<PullRequestsSection deckSlug={deck.slug} ownerUserId={deck.ownerUserId} onMerged={load} />
</article>
{/if}