cards/apps/web/src/lib/components/marketplace/DiscussionThread.svelte
Till JS 40861710bf Phase 12 R5: Marketplace-Frontend — /explore + /d + /u + /me/{published,subscribed,forks}
Routes:
- /explore — Featured + Trending side-by-side, Browse mit Suche
  (Title/Description ILIKE), Sprachfilter, Sort (recent/popular/
  trending), load-more-Pagination
- /d/[slug] — Public-Deck-Detail mit Star/Subscribe/Fork-Buttons
  (Star + Subscribe sind toggle, Fork erstellt private cards.decks-
  Kopie und navigiert dorthin), Karten-Liste mit Discussion-Counts +
  Click-to-expand-Thread + Suggest-Edit-Modal, PR-Liste mit
  Owner-Merge/Reject + PR-Author-Close, Publish-Modal für Owner
- /u/[slug] — Author-Profil mit Verified-Badges (Mana/Community),
  Follow-Button, Decks-Liste
- /me/published — Author-Profil-CRUD (Slug + Display-Name + Bio +
  Pseudonym-Toggle), Liste eigener veröffentlichter Decks
- /me/subscribed — Abos mit prominentem update_available-Banner
- /me/forks — Geforkte Decks mit „Update ziehen"-Button →
  Smart-Merge-Pull (FSRS-State unveränderter Karten bleibt erhalten)

Components (apps/web/src/lib/components/marketplace/, eigener
Namespace ohne Konflikt zu Tills WIP-DeckGrid.svelte/DeckFan/
DeckStack):
- AuthorBadge — Display-Name + Verified-Symbole + Link aufs Profil
- DeckListGrid — 3-spalt Grid mit Author-Badge, Karten-/Star-/
  Subscriber-Counts, Sprache, Featured-Tag
- PublishVersionModal — SemVer-Eingabe (Default-Bump 1.0.0→1.1.0),
  Changelog, Karten als JSON-Array
- SuggestEditModal — Modify- oder Remove-Mode pro Karte, ergibt
  einen Pull-Request via /api/v1/marketplace/.../pull-requests
- DiscussionThread — Liste sichtbarer Comments inkl. Reply-Threading
  (parent_id), Hide-Button für Author oder Deck-Owner, Post-Form
- PullRequestList — Status-Filter, Diff-Summary +N ~M −R, per-PR
  Merge/Reject/Close-Buttons je nach Owner/Author-Permission

API-Client (apps/web/src/lib/api/marketplace.ts, ~440 Z.):
- Authors (CRUD + public lookup)
- Discovery (explore + browse + tags)
- Public Deck-Read + Init/Publish/Patch
- Engagement (Stars + Follows mit own-state-Endpoints)
- Subscribe + Fork + Pull-Update
- Pull-Requests (Lifecycle + List + Detail)
- Card-Discussions (Post + List + Counts + Hide)

Verifikation:

- svelte-check: 4017 Files, 0 errors, 5 Svelte-5-rune-Warnings
  (benigne — Modals capturen Init-Values von Props bewusst, weil sie
  pro Klick frisch gemountet werden; nicht-reactive ist gewollt)
- SSR-Smoke: /explore, /d/r5-stoa-grundlagen, /u/cardecky,
  /me/published liefern alle 200 — Routes mounten, Pages rendern
  initial mit Titles + Containern; API-Calls laufen client-side
  beim Mount
- Live-Daten: Test-Decks r5-stoa-grundlagen (Stoische Grundbegriffe,
  4 Karten v1.0.0) + r5-deutsche-historie (2 Karten) bewusst in
  lokaler cards-DB liegen gelassen, damit Browser sofort Inhalt hat

Bewusst nicht angefasst:

- Header.svelte ist in Tills uncommitted WIP — Header-Nav-Link auf
  /explore wird beim Theming-WIP-Commit nachgezogen. Marketplace-
  URLs sind aktuell direkt erreichbar via URL-Bar.
- type-check-Warnings nicht silencet — die 5 sind benign und das
  Refactoren auf $derived würde keine Verhaltens-Änderung bringen.

Verbleibend: R6 voller UI-E2E gegen das ganze System (Cardecky-
Deck-Publish + Till-Subscribe + Till-Fork + Till-Suggest-PR +
Cardecky-Merge + Till-Pull-Update — alles im Browser, manuell oder
Playwright). Polish (Empty-States, Loading-Skeletons, Pagination-
Edge-Cases) sammelt sich auf für eine separate Welle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:04:40 +02:00

155 lines
4.1 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import {
hideDiscussion,
listDiscussions,
postDiscussion,
type Discussion,
} from '$lib/api/marketplace.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
interface Props {
deckSlug: string;
cardContentHash: string;
ownerUserId?: string;
}
const { deckSlug, cardContentHash, ownerUserId }: Props = $props();
let comments = $state<Discussion[]>([]);
let loading = $state(true);
let newBody = $state('');
let replyToId = $state<string | null>(null);
let posting = $state(false);
const myUserId = $derived(devUser.id);
const canHideAny = $derived(myUserId === ownerUserId);
onMount(() => {
void refresh();
});
async function refresh() {
loading = true;
try {
const result = await listDiscussions(cardContentHash);
comments = result.discussions;
} catch (e) {
toasts.error(`Discussions laden fehlgeschlagen: ${(e as Error).message}`);
} finally {
loading = false;
}
}
async function onPost(event: SubmitEvent) {
event.preventDefault();
if (!newBody.trim() || posting) return;
posting = true;
try {
await postDiscussion(deckSlug, cardContentHash, newBody.trim(), replyToId ?? undefined);
newBody = '';
replyToId = null;
await refresh();
} catch (e) {
toasts.error((e as Error).message);
} finally {
posting = false;
}
}
async function onHide(id: string) {
if (!confirm('Comment verstecken?')) return;
try {
await hideDiscussion(id);
await refresh();
} catch (e) {
toasts.error((e as Error).message);
}
}
function canHide(comment: Discussion): boolean {
return canHideAny || comment.author_user_id === myUserId;
}
</script>
<div class="space-y-3">
<h4 class="text-sm font-medium">💬 Diskussion zur Karte</h4>
{#if loading}
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">Lade…</p>
{:else if comments.length === 0}
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">Noch keine Kommentare.</p>
{:else}
<ul class="space-y-2">
{#each comments as comment (comment.id)}
<li
class="rounded border border-[hsl(var(--color-border))] p-3 text-sm"
class:ml-6={comment.parent_id !== null}
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 whitespace-pre-wrap">{comment.body}</div>
{#if canHide(comment)}
<button
type="button"
class="text-xs text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-error))]"
onclick={() => onHide(comment.id)}
title="Verstecken"
>
🙈
</button>
{/if}
</div>
<div class="mt-2 flex items-center gap-2 text-xs text-[hsl(var(--color-muted-foreground))]">
<span>{new Date(comment.created_at).toLocaleString()}</span>
{#if myUserId && replyToId !== comment.id}
<button
type="button"
class="hover:text-[hsl(var(--color-primary))]"
onclick={() => (replyToId = comment.id)}
>
Antworten
</button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
{#if myUserId}
<form class="space-y-2" onsubmit={onPost}>
{#if replyToId}
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">
↪ Antwort auf einen Kommentar.
<button
type="button"
class="hover:underline"
onclick={() => (replyToId = null)}
>
Abbrechen
</button>
</p>
{/if}
<textarea
bind:value={newBody}
rows="2"
maxlength="2000"
placeholder="Kommentar / Frage / Korrekturhinweis"
class="block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
></textarea>
<button
type="submit"
disabled={posting || !newBody.trim()}
class="rounded bg-[hsl(var(--color-primary))] px-3 py-1 text-xs text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
>
{posting ? 'Posten…' : 'Posten'}
</button>
</form>
{:else}
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">
<a href="/" class="text-[hsl(var(--color-primary))] hover:underline">Anmelden</a>, um zu kommentieren.
</p>
{/if}
</div>