feat(cards): Phase ε.3 — PR notifications + Card-Discussions UI

- mana-notify integration in cards-server: PR-create notifies the
  deck owner, merge/reject notifies the PR author. Fire-and-forget
  via lib/notify.ts so a notify-service outage never rolls back a
  domain action. ExternalIDs are deterministic (cards.pr.{event}.{id})
  so retries dedupe.
- <CardDiscussions> on the learn page: collapsed by default, opens
  via "💬 Diskussion" alongside the "✏️ Verbessern" trigger. Resets
  whenever the current card changes so the panel doesn't bleed
  between flashcards.
- MARKETPLACE_PLAN.md §13a — known limitations: PR-merge is
  stale-blind (no rebase yet), diff-preview flat, threading 1-level.
This commit is contained in:
Till JS 2026-05-07 22:24:45 +02:00
parent 61fc16e8e9
commit a8ddb6dea4
6 changed files with 269 additions and 3 deletions

View file

@ -0,0 +1,128 @@
<script lang="ts">
import { cardsApi, CardsApiError, type CardDiscussion } from '$lib/api/cards-api';
import { authStore } from '$lib/stores/auth.svelte';
interface Props {
contentHash: string;
deckSlug: string;
}
let { contentHash, deckSlug }: Props = $props();
let comments = $state<CardDiscussion[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let draft = $state('');
let posting = $state(false);
$effect(() => {
// Re-load whenever the card under review changes.
void contentHash;
comments = [];
load();
});
async function load() {
loading = true;
error = null;
try {
comments = await cardsApi.discussions.listForCard(contentHash);
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
loading = false;
}
}
async function post() {
const body = draft.trim();
if (!body) return;
posting = true;
error = null;
try {
const row = await cardsApi.discussions.post(contentHash, { deckSlug, body });
comments = [...comments, row];
draft = '';
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
posting = false;
}
}
async function hide(c: CardDiscussion) {
if (!confirm('Kommentar ausblenden?')) return;
try {
await cardsApi.discussions.hide(c.id);
comments = comments.filter((x) => x.id !== c.id);
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
}
}
</script>
<aside class="mt-4 rounded-xl border border-neutral-800 bg-neutral-950 p-4">
<header class="mb-2 flex items-center justify-between">
<h3 class="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Diskussion {comments.length > 0 ? `(${comments.length})` : ''}
</h3>
{#if loading}
<span class="text-xs text-neutral-600">Lädt…</span>
{/if}
</header>
{#if error}
<p class="mb-2 text-xs text-red-400">{error}</p>
{/if}
{#if comments.length === 0 && !loading}
<p class="text-xs text-neutral-600">Noch keine Kommentare zu dieser Karte.</p>
{:else}
<ul class="space-y-2">
{#each comments as c (c.id)}
<li class="rounded-lg border border-neutral-800 bg-neutral-900 p-2 text-sm">
<div class="flex items-start justify-between gap-2">
<p class="whitespace-pre-line text-neutral-200">{c.body}</p>
{#if authStore.user?.id === c.authorUserId}
<button
class="shrink-0 text-xs text-neutral-600 hover:text-red-400"
onclick={() => hide(c)}
title="Ausblenden"
aria-label="Ausblenden"
>
</button>
{/if}
</div>
<p class="mt-1 text-xs text-neutral-600">
{new Date(c.createdAt).toLocaleString('de-DE')}
</p>
</li>
{/each}
</ul>
{/if}
{#if authStore.isAuthenticated}
<form
class="mt-3 flex gap-2"
onsubmit={(e) => {
e.preventDefault();
post();
}}
>
<input
class="flex-1 rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-1.5 text-sm"
placeholder="Kommentar zur Karte…"
bind:value={draft}
disabled={posting}
/>
<button
class="rounded-lg bg-indigo-500 px-3 py-1.5 text-xs text-white hover:bg-indigo-400 disabled:opacity-50"
type="submit"
disabled={posting || !draft.trim()}
>
{posting ? 'Sende…' : 'Senden'}
</button>
</form>
{/if}
</aside>

View file

@ -7,6 +7,7 @@
import { studyBlockStore } from '$lib/stores/study-blocks.svelte';
import CardFace from '$lib/components/CardFace.svelte';
import SuggestEditModal from '$lib/components/SuggestEditModal.svelte';
import CardDiscussions from '$lib/components/CardDiscussions.svelte';
import type { Card, CardReview, ReviewGrade } from '@mana/cards-core';
const deckId = $derived(page.params.deckId as string);
@ -26,6 +27,14 @@
const subscribedSlug = $derived($deckQuery?.subscribedFromSlug);
const canSuggest = $derived(!!subscribedSlug && !!current?.card.serverContentHash);
let suggestOpen = $state(false);
let discussionsOpen = $state(false);
$effect(() => {
// Collapse the discussion panel whenever the card changes so the
// learner isn't visually overloaded between cards.
void current?.card.id;
discussionsOpen = false;
});
$effect(() => {
const snap = $dueQuery;
@ -140,7 +149,14 @@
/>
{#if canSuggest}
<div class="mt-3 text-right">
<div class="mt-3 flex justify-end gap-3">
<button
class="text-xs text-neutral-500 hover:text-neutral-200"
onclick={() => (discussionsOpen = !discussionsOpen)}
title="Kommentare zur Karte"
>
💬 {discussionsOpen ? 'Diskussion ausblenden' : 'Diskussion'}
</button>
<button
class="text-xs text-neutral-500 hover:text-indigo-300"
onclick={() => (suggestOpen = true)}
@ -149,6 +165,10 @@
✏️ Verbessern
</button>
</div>
{#if discussionsOpen && subscribedSlug && current?.card.serverContentHash}
<CardDiscussions contentHash={current.card.serverContentHash} deckSlug={subscribedSlug} />
{/if}
{/if}
{#if !showBack}

View file

@ -581,6 +581,12 @@ Marktplatz ohne Decks ist nutzlos. Drei parallele Hebel:
- **Keine Pflicht-Klarnamen**. Pseudonyme bleiben gleichberechtigt. Verifizierung ist Bonus, nicht Pflicht.
- **Kein Marketplace-Cut über 30 %**. Apple-App-Store-Hass ist real, wir bleiben fair.
## 13a. Bekannte Limitierungen / „macht später"
- **PR-Merge-Heuristik ist stale-blind** (Phase ε.1): `merge()` baut die neue Version aus `currentCards` zusammen, indem es Removes anwendet, dann Modifies-by-Hash, dann Adds. Wenn der Author zwischen PR-Open und Merge selbst eine Karte geändert hat, deren `previousContentHash` der PR matched, gewinnt **stumm** der PR — kein Konflikt-Hinweis. Akzeptabel solange wir wenige PRs/Tag haben; irgendwann brauchen wir entweder (a) ein „PR-rebase" Konzept (PR rerunst auf neuesten Cards, bei Konflikt → status=`stale`), oder (b) optimistic locking via `baseVersionId` auf der PR-Row mit Reject bei Mismatch.
- **Keine Multi-Card-Diff-Visualisierung** (Phase ε.2): PR-Diff-Preview zeigt jeden Block (`add`/`modify`/`remove`) flach. Bei großen PRs mit 50+ Karten wird das unübersichtlich — vermutlich pre-Phase-ζ noch nicht relevant, danach ggf. Side-by-side-Vergleich pro modify.
- **Discussion-Threading ist 1-Level** (Phase ε.2): Server speichert schon `parent_id`, aber das UI rendert flach. Bei Bedarf später ein Antworten-Button + visuelle Einrückung — kein Schema-Change nötig.
## 14. Offene Punkte die später entschieden werden müssen
- **Mobile-Push-Notifications** für Subscribe-Updates: native PWA-Push reicht aktuell, aber Browser-API ist hin- und her — könnte Phase ι in einen eigenen Push-Service auslagern müssen.