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>
146 lines
3.6 KiB
Svelte
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}
|