cards/apps/web/src/routes/u/[slug]/+page.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

146 lines
3.6 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import {
browseDecks,
followAuthor,
getAuthor,
getFollowState,
unfollowAuthor,
type DeckListEntry,
type MarketplaceAuthor,
} from '$lib/api/marketplace.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import DeckListGrid from '$lib/components/marketplace/DeckListGrid.svelte';
import { toasts } from '$lib/stores/toasts.svelte.ts';
const slug = $derived(page.params.slug ?? '');
let author = $state<MarketplaceAuthor | null>(null);
let decks = $state<DeckListEntry[]>([]);
let following = $state(false);
let loading = $state(true);
let busy = $state(false);
const myUserId = $derived(devUser.id);
onMount(() => {
void load();
});
async function load() {
loading = true;
try {
const [a, d, follow] = await Promise.all([
getAuthor(slug),
browseDecks({ author: slug, sort: 'recent', limit: 50 }),
myUserId
? getFollowState(slug).catch(() => ({ following: false }))
: Promise.resolve({ following: false }),
]);
author = a;
decks = d.items;
following = follow.following;
} catch (e) {
toasts.error(`Author laden fehlgeschlagen: ${(e as Error).message}`);
} finally {
loading = false;
}
}
async function toggleFollow() {
if (!myUserId) {
toasts.error('Bitte einloggen.');
return;
}
busy = true;
try {
if (following) {
await unfollowAuthor(slug);
following = false;
} else {
await followAuthor(slug);
following = true;
}
} catch (e) {
toasts.error((e as Error).message);
} finally {
busy = false;
}
}
</script>
<svelte:head>
<title>{author?.display_name ?? slug} · Cardecky</title>
</svelte:head>
{#if loading}
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
{:else if !author}
<p>Author nicht gefunden.</p>
{:else}
<div class="mx-auto max-w-4xl space-y-8">
<a href="/explore" class="text-xs text-[hsl(var(--color-muted-foreground))] hover:underline">
← zurück zur Library
</a>
<header class="space-y-2">
<div class="flex flex-wrap items-center gap-3">
<h1 class="text-3xl font-semibold">{author.display_name}</h1>
{#if author.verified_mana}
<span
class="rounded bg-[hsl(var(--color-primary))]/15 px-2 py-1 text-xs text-[hsl(var(--color-primary))]"
title="Vom Verein verifiziert"
>
🛡️ Mana Verifiziert
</span>
{/if}
{#if author.verified_community}
<span
class="rounded bg-[hsl(var(--color-primary))]/10 px-2 py-1 text-xs"
title="Aus der Community heraus verifiziert"
>
⭐ Community Verifiziert
</span>
{/if}
{#if author.banned}
<span
class="rounded bg-[hsl(var(--color-error))]/15 px-2 py-1 text-xs text-[hsl(var(--color-error))]"
>
Gesperrt
</span>
{/if}
</div>
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">
@{author.slug}
· seit {new Date(author.joined_at).toLocaleDateString()}
{#if author.pseudonym}
· Pseudonym
{/if}
</p>
{#if author.bio}
<p class="text-sm">{author.bio}</p>
{/if}
{#if myUserId}
<button
type="button"
class="rounded border border-[hsl(var(--color-border))] px-3 py-1 text-sm hover:bg-[hsl(var(--color-card))]"
onclick={toggleFollow}
disabled={busy}
>
{following ? '✓ Folge' : '+ Folgen'}
</button>
{/if}
</header>
<section>
<h2 class="mb-3 text-xl font-semibold">Decks ({decks.length})</h2>
<DeckListGrid items={decks} emptyMessage="Noch keine Decks veröffentlicht." />
</section>
</div>
{/if}