feat(cards): Phase ε — Pull-Requests + Card-Discussions

Server (cards-server):
- PullRequestService: create / list / get / merge / close / reject.
  Merge applies the PR's {add, modify, remove} diff to the latest
  version's cards in a single transaction, writes a new
  deck_version + deck_cards, bumps latest_version_id, and stamps
  the PR with mergedIntoVersionId.
- DiscussionService: post / listForCard / hide. Threads are keyed
  by card_content_hash so they survive version bumps.
- Routes mounted under /v1: POST/GET /decks/:slug/pull-requests,
  GET /pull-requests/:id, POST /pull-requests/:id/{merge,close,reject},
  GET/POST /cards/:contentHash/discussions, POST /discussions/:id/hide.

Frontend (cards-web):
- cardsApi.pullRequests + cardsApi.discussions client surface.
- <PullRequestsSection> on /d/:slug — lists PRs with diff preview;
  owner sees Merge/Reject/Close buttons.
- <SuggestEditModal> + "✏️ Verbessern" button on /learn/:deckId for
  cards from a subscribed deck — submits a one-card modify (or
  remove) PR using the card's serverContentHash as the previous
  hash.
- Deck/Card DTOs gain subscribedFromSlug + serverContentHash so the
  learn page can decide whether to show the suggest-edit affordance.
This commit is contained in:
Till JS 2026-05-07 21:56:20 +02:00
parent c84742005b
commit 61fc16e8e9
12 changed files with 1045 additions and 0 deletions

View file

@ -209,6 +209,49 @@ export const cardsApi = {
{ auth: 'optional' }
),
},
pullRequests: {
create: (
deckSlug: string,
input: {
title: string;
body?: string;
diff: PullRequestDiffInput;
}
) =>
request<PullRequest>(`/v1/decks/${encodeURIComponent(deckSlug)}/pull-requests`, {
method: 'POST',
body: input,
}),
list: (deckSlug: string, status?: 'open' | 'merged' | 'closed' | 'rejected') => {
const qs = status ? `?status=${status}` : '';
return request<PullRequest[]>(
`/v1/decks/${encodeURIComponent(deckSlug)}/pull-requests${qs}`,
{ auth: 'optional' }
);
},
get: (id: string) => request<PullRequest>(`/v1/pull-requests/${id}`, { auth: 'optional' }),
merge: (id: string, opts: { newSemver?: string; mergeNote?: string } = {}) =>
request<{ pullRequest: PullRequest; version: PublicDeckVersion }>(
`/v1/pull-requests/${id}/merge`,
{ method: 'POST', body: opts }
),
close: (id: string) =>
request<{ ok: true }>(`/v1/pull-requests/${id}/close`, { method: 'POST' }),
reject: (id: string) =>
request<{ ok: true }>(`/v1/pull-requests/${id}/reject`, { method: 'POST' }),
},
discussions: {
listForCard: (contentHash: string) =>
request<CardDiscussion[]>(`/v1/cards/${encodeURIComponent(contentHash)}/discussions`, {
auth: 'optional',
}),
post: (contentHash: string, input: { deckSlug: string; body: string; parentId?: string }) =>
request<CardDiscussion>(`/v1/cards/${encodeURIComponent(contentHash)}/discussions`, {
method: 'POST',
body: input,
}),
hide: (id: string) => request<{ ok: true }>(`/v1/discussions/${id}/hide`, { method: 'POST' }),
},
};
// Override author lookup to send token opportunistically — public reads.
@ -312,3 +355,39 @@ export interface DiffPayload {
unchanged: { contentHash: string; ord: number }[];
removed: { contentHash: string }[];
}
export interface PullRequestDiffInput {
add: { type: string; fields: Record<string, string> }[];
modify: { previousContentHash: string; type: string; fields: Record<string, string> }[];
remove: { contentHash: string }[];
}
export type PullRequestStatus = 'open' | 'merged' | 'closed' | 'rejected';
export interface PullRequest {
id: string;
deckId: string;
authorUserId: string;
status: PullRequestStatus;
title: string;
body: string | null;
diff: {
add: { type: string; fields: Record<string, string> }[];
modify: { contentHash: string; fields: Record<string, string> }[];
remove: { contentHash: string }[];
};
mergedIntoVersionId: string | null;
createdAt: string;
resolvedAt: string | null;
}
export interface CardDiscussion {
id: string;
cardContentHash: string;
deckId: string;
authorUserId: string;
parentId: string | null;
body: string;
hidden: boolean;
createdAt: string;
}

View file

@ -0,0 +1,233 @@
<script lang="ts">
import { cardsApi, CardsApiError, type PullRequest } from '$lib/api/cards-api';
import { authStore } from '$lib/stores/auth.svelte';
interface Props {
deckSlug: string;
ownerUserId: string;
onMerged?: () => void;
}
let { deckSlug, ownerUserId, onMerged }: Props = $props();
let prs = $state<PullRequest[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let actionBusy = $state<string | null>(null);
let expanded = $state<Record<string, boolean>>({});
const viewerIsOwner = $derived(authStore.user?.id === ownerUserId);
$effect(() => {
void deckSlug;
load();
});
async function load() {
loading = true;
error = null;
try {
prs = await cardsApi.pullRequests.list(deckSlug);
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
loading = false;
}
}
async function merge(pr: PullRequest) {
if (!confirm(`PR „${pr.title}" mergen? Erstellt eine neue Version.`)) return;
actionBusy = pr.id;
error = null;
try {
await cardsApi.pullRequests.merge(pr.id);
await load();
onMerged?.();
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
actionBusy = null;
}
}
async function close(pr: PullRequest) {
actionBusy = pr.id;
error = null;
try {
await cardsApi.pullRequests.close(pr.id);
await load();
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
actionBusy = null;
}
}
async function reject(pr: PullRequest) {
actionBusy = pr.id;
error = null;
try {
await cardsApi.pullRequests.reject(pr.id);
await load();
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
actionBusy = null;
}
}
function statusBadgeClass(s: PullRequest['status']) {
if (s === 'open') return 'bg-emerald-500/15 text-emerald-300';
if (s === 'merged') return 'bg-violet-500/15 text-violet-300';
if (s === 'rejected') return 'bg-red-500/15 text-red-300';
return 'bg-neutral-800 text-neutral-400';
}
function diffSummary(pr: PullRequest) {
return `+${pr.diff.add.length} · ~${pr.diff.modify.length} · ${pr.diff.remove.length}`;
}
</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">
Pull Requests {prs.length > 0 ? `(${prs.length})` : ''}
</h2>
<button
class="text-xs text-neutral-500 hover:text-neutral-300"
onclick={load}
disabled={loading}
>
{loading ? 'Lädt…' : 'Aktualisieren'}
</button>
</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>
{/if}
{#if loading && prs.length === 0}
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-4 text-sm text-neutral-500">
Lädt…
</p>
{:else if prs.length === 0}
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-4 text-sm text-neutral-500">
Noch keine Pull Requests. Abonnenten können Verbesserungen vorschlagen.
</p>
{:else}
<ul class="space-y-2">
{#each prs as pr (pr.id)}
<li class="rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-xs {statusBadgeClass(pr.status)}">
{pr.status}
</span>
<h3 class="truncate font-medium text-neutral-100">{pr.title}</h3>
</div>
<p class="mt-1 text-xs text-neutral-500">
{diffSummary(pr)} · {new Date(pr.createdAt).toLocaleDateString('de-DE')}
</p>
</div>
<button
class="shrink-0 text-xs text-neutral-500 hover:text-neutral-300"
onclick={() => (expanded[pr.id] = !expanded[pr.id])}
>
{expanded[pr.id] ? 'Einklappen' : 'Details'}
</button>
</div>
{#if expanded[pr.id]}
{#if pr.body}
<p class="mt-3 whitespace-pre-line text-sm text-neutral-300">{pr.body}</p>
{/if}
{#if pr.diff.modify.length > 0}
<div class="mt-3">
<div class="mb-1 text-xs uppercase text-neutral-500">Geändert</div>
<ul class="space-y-2">
{#each pr.diff.modify as m (m.contentHash)}
<li class="rounded-lg border border-neutral-800 bg-neutral-950 p-2 text-xs">
<div class="text-neutral-500">
{m.contentHash.slice(0, 12)}
</div>
{#each Object.entries(m.fields) as [k, v]}
<div class="mt-1">
<span class="text-neutral-500">{k}:</span>
<span class="text-neutral-200">{v}</span>
</div>
{/each}
</li>
{/each}
</ul>
</div>
{/if}
{#if pr.diff.add.length > 0}
<div class="mt-3">
<div class="mb-1 text-xs uppercase text-neutral-500">
Neu (+{pr.diff.add.length})
</div>
<ul class="space-y-2">
{#each pr.diff.add as a, i (i)}
<li class="rounded-lg border border-neutral-800 bg-neutral-950 p-2 text-xs">
<div class="text-neutral-500">{a.type}</div>
{#each Object.entries(a.fields) as [k, v]}
<div class="mt-1">
<span class="text-neutral-500">{k}:</span>
<span class="text-neutral-200">{v}</span>
</div>
{/each}
</li>
{/each}
</ul>
</div>
{/if}
{#if pr.diff.remove.length > 0}
<div class="mt-3">
<div class="mb-1 text-xs uppercase text-neutral-500">
Entfernt ({pr.diff.remove.length})
</div>
<ul class="space-y-1 text-xs text-neutral-400">
{#each pr.diff.remove as r (r.contentHash)}
<li>· {r.contentHash.slice(0, 12)}</li>
{/each}
</ul>
</div>
{/if}
{#if pr.status === 'open' && viewerIsOwner}
<div class="mt-4 flex gap-2">
<button
class="rounded-lg bg-violet-500 px-3 py-1.5 text-xs text-white hover:bg-violet-400 disabled:opacity-50"
onclick={() => merge(pr)}
disabled={actionBusy === pr.id}
>
{actionBusy === pr.id ? 'Mergt…' : 'Mergen'}
</button>
<button
class="rounded-lg border border-red-500/40 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10 disabled:opacity-50"
onclick={() => reject(pr)}
disabled={actionBusy === pr.id}
>
Ablehnen
</button>
<button
class="rounded-lg border border-neutral-700 px-3 py-1.5 text-xs hover:bg-neutral-800 disabled:opacity-50"
onclick={() => close(pr)}
disabled={actionBusy === pr.id}
>
Schließen
</button>
</div>
{/if}
{/if}
</li>
{/each}
</ul>
{/if}
</section>

View file

@ -0,0 +1,190 @@
<script lang="ts">
import { cardsApi, CardsApiError } from '$lib/api/cards-api';
import type { Card } from '@mana/cards-core';
type Mode = 'modify' | 'remove';
interface Props {
card: Card;
deckSlug: string;
open: boolean;
onClose: () => void;
onSubmitted?: () => void;
}
let { card, deckSlug, open, onClose, onSubmitted }: Props = $props();
let mode = $state<Mode>('modify');
let title = $state('');
let body = $state('');
let editedFields = $state<Record<string, string>>({});
let busy = $state(false);
let error = $state<string | null>(null);
let success = $state(false);
$effect(() => {
if (open) {
editedFields = { ...card.fields };
title = `Verbesserung: Karte ${card.order + 1}`;
body = '';
mode = 'modify';
error = null;
success = false;
}
});
const fieldKeys = $derived(Object.keys(editedFields));
const hasChanges = $derived.by(() => {
if (mode === 'remove') return true;
return fieldKeys.some((k) => editedFields[k] !== card.fields[k]);
});
async function submit() {
if (!card.serverContentHash) {
error = 'Diese Karte stammt nicht aus einem abonnierten Deck.';
return;
}
if (!hasChanges) {
error = 'Keine Änderungen zu vorschlagen.';
return;
}
if (!title.trim()) {
error = 'Titel fehlt.';
return;
}
busy = true;
error = null;
try {
const diff =
mode === 'remove'
? {
add: [],
modify: [],
remove: [{ contentHash: card.serverContentHash }],
}
: {
add: [],
modify: [
{
previousContentHash: card.serverContentHash,
type: card.type,
fields: editedFields,
},
],
remove: [],
};
await cardsApi.pullRequests.create(deckSlug, {
title: title.trim(),
body: body.trim() || undefined,
diff,
});
success = true;
onSubmitted?.();
setTimeout(() => onClose(), 1200);
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
busy = false;
}
}
</script>
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4"
role="dialog"
aria-modal="true"
>
<div class="w-full max-w-xl rounded-xl border border-neutral-800 bg-neutral-950 p-6">
<header class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">Verbesserung vorschlagen</h2>
<button
class="text-neutral-400 hover:text-neutral-100"
onclick={onClose}
aria-label="Schließen">✕</button
>
</header>
{#if success}
<p
class="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-sm text-emerald-300"
>
Pull Request gesendet — der Author wird benachrichtigt.
</p>
{:else}
<div class="mb-4 inline-flex rounded-lg border border-neutral-800 p-1">
<button
class="rounded px-3 py-1 text-xs"
class:bg-neutral-800={mode === 'modify'}
onclick={() => (mode = 'modify')}>Inhalt ändern</button
>
<button
class="rounded px-3 py-1 text-xs"
class:bg-neutral-800={mode === 'remove'}
onclick={() => (mode = 'remove')}>Karte entfernen</button
>
</div>
<label class="mb-3 block">
<span class="mb-1 block text-xs text-neutral-400">Titel</span>
<input
class="w-full rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm"
bind:value={title}
placeholder="Kurzbeschreibung der Verbesserung"
/>
</label>
{#if mode === 'modify'}
<div class="mb-3 space-y-2">
{#each fieldKeys as key (key)}
<label class="block">
<span class="mb-1 block text-xs text-neutral-400">{key}</span>
<textarea
class="w-full rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm"
rows="2"
bind:value={editedFields[key]}
></textarea>
</label>
{/each}
</div>
{:else}
<p
class="mb-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-200"
>
Diese Karte wird beim Merge aus dem Deck entfernt.
</p>
{/if}
<label class="mb-4 block">
<span class="mb-1 block text-xs text-neutral-400">Begründung (optional)</span>
<textarea
class="w-full rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm"
rows="3"
bind:value={body}
placeholder="Warum ist diese Änderung sinnvoll?"
></textarea>
</label>
{#if error}
<p class="mb-3 text-sm text-red-400">{error}</p>
{/if}
<div class="flex items-center justify-end gap-2">
<button
class="rounded-lg border border-neutral-800 px-4 py-2 text-sm hover:border-neutral-700"
onclick={onClose}
disabled={busy}>Abbrechen</button
>
<button
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
onclick={submit}
disabled={busy || !hasChanges}
>
{busy ? 'Sende…' : 'PR senden'}
</button>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -39,6 +39,8 @@ export function toDeck(local: LocalDeck): Deck {
cardCount: local.cardCount,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? local.createdAt ?? new Date().toISOString(),
subscribedFromSlug: local.subscribedFromSlug,
subscribedAtVersion: local.subscribedAtVersion,
};
}
@ -70,6 +72,7 @@ export function toCard(local: LocalCard): Card {
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? local.createdAt ?? new Date().toISOString(),
serverContentHash: local.serverContentHash,
};
}

View file

@ -10,6 +10,7 @@
} from '$lib/api/cards-api';
import { isSubscribedLocally, subscribeAndPull, unsubscribe } from '$lib/services/subscribe';
import { cardDeckTable } from '$lib/data/database';
import PullRequestsSection from '$lib/components/PullRequestsSection.svelte';
const slug = $derived(page.params.slug as string);
@ -202,6 +203,8 @@
<p class="mt-10 text-xs text-neutral-500">
Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')}
</p>
<PullRequestsSection deckSlug={deck.slug} ownerUserId={deck.ownerUserId} onMerged={load} />
</article>
{/if}

View file

@ -6,6 +6,7 @@
import { reviewStore } from '$lib/stores/reviews.svelte';
import { studyBlockStore } from '$lib/stores/study-blocks.svelte';
import CardFace from '$lib/components/CardFace.svelte';
import SuggestEditModal from '$lib/components/SuggestEditModal.svelte';
import type { Card, CardReview, ReviewGrade } from '@mana/cards-core';
const deckId = $derived(page.params.deckId as string);
@ -22,6 +23,9 @@
const current = $derived(queue[currentIndex]);
const deckTitle = $derived($deckQuery?.title ?? 'Deck');
const subscribedSlug = $derived($deckQuery?.subscribedFromSlug);
const canSuggest = $derived(!!subscribedSlug && !!current?.card.serverContentHash);
let suggestOpen = $state(false);
$effect(() => {
const snap = $dueQuery;
@ -135,6 +139,18 @@
onTypedAnswer={(v) => (typedAnswer = v)}
/>
{#if canSuggest}
<div class="mt-3 text-right">
<button
class="text-xs text-neutral-500 hover:text-indigo-300"
onclick={() => (suggestOpen = true)}
title="Verbesserung dieser Karte vorschlagen"
>
✏️ Verbessern
</button>
</div>
{/if}
{#if !showBack}
<button
class="mt-6 w-full rounded-lg bg-indigo-500 py-3 text-base text-white hover:bg-indigo-400"
@ -178,3 +194,12 @@
<div class="text-center text-sm text-neutral-400">Lade…</div>
{/if}
</div>
{#if subscribedSlug && current}
<SuggestEditModal
card={current.card}
deckSlug={subscribedSlug}
open={suggestOpen}
onClose={() => (suggestOpen = false)}
/>
{/if}