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>
155 lines
4.1 KiB
Svelte
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>
|