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>
This commit is contained in:
Till JS 2026-05-09 16:04:40 +02:00
parent 92a1d5804f
commit 40861710bf
14 changed files with 2310 additions and 1 deletions

View file

@ -98,7 +98,7 @@ Vollständiger Plan: [`mana/docs/playbooks/CARDS_GREENFIELD.md`](../mana/docs/pl
| 9 | Polish (DSGVO-UI, Settings, Account, Statistik, i18n, A11y, Media, Image-Occlusion) | 🟡 weit | Card-Edit + Cloze-Editor + Inbox-Banner + Account/DSGVO + Statistik + Pre-Flight-Swap + i18n DE/EN + A11y-Pass + Cloze-Hint-Anzeige + Anki-Re-Import-Dedupe + MinIO-Media-Upload + Image-Occlusion durch (9a9l). Verbleibend: type-in, audio, multiple-choice (Schema vorbereitet) |
| 10 | Production-Deploy (Mac Mini, Cloudflare-Tunnel) | ✅ live 2026-05-08 | cardecky.mana.how + cardecky-api.mana.how, alte cards.* via nginx-301-Redirect |
| 11 | Decommission Cards-Modul aus mana-monorepo | ✅ 2026-05-08 | apps/cards, services/cards-server, packages/cards-core, mana-app cards-Modul + cross-refs entfernt (4 Commits, type-check 0 errors) |
| 12 | Marketplace-Restore (R0R6) | 🟡 R0+R1+R2+R3+R4 durch | Plan: [`docs/playbooks/MARKETPLACE_RESTORE.md`](docs/playbooks/MARKETPLACE_RESTORE.md). R0 (Doku): ✅. R1 (Schema): ✅. R2 (α+β Authors + Publish): ✅. R3 (γ+δ Discovery + Engagement + Subscribe + Smart-Merge mit FSRS-State-Erhalt): ✅. **R4 (ε Pull-Requests + Card-Discussions): ✅**`POST/GET /decks/:slug/pull-requests` (PR-Erstellung mit add/modify/remove-Diff, public List), `GET /pull-requests/:id`, `POST /pull-requests/:id/{close,reject,merge}` mit Lifecycle-Enforcement (open→merged|closed|rejected), Merge ist Owner-only und erzeugt atomar eine neue Version mit semver-minor-Bump (1.0.0→1.1.0 default), bumpt `latest_version_id`, schreibt PR-Resolution. Card-Discussions: `POST /decks/:slug/cards/:hash/discussions` (auth, Threads keyed auf `card_content_hash` damit sie Versions-Bumps überleben), `GET /cards/:hash/discussions` (public read, hidden filtered), `GET /decks/:slug/discussions/counts` (Bulk pro Karte), `POST /discussions/:id/hide` (Author oder Deck-Owner). 11 neue Semver-Unit-Tests, 89 gesamt grün. **E2E-Smoke**: Cardecky publisht v1.0.0 (Apatheia, Eudaimonia, Logos) → Till submitted PR (modify Eudaimonia-Back, remove Logos, add Tugendlehre) → Till's Merge-Versuch wird mit 403 abgelehnt (deck_owner_only) → Cardecky merged → v1.1.0 entsteht atomar mit korrektem Karten-Mix in Ord-Reihenfolge → re-merge wird mit 409 abgelehnt → Till postet Frage zur Apatheia-Karte → Cardecky antwortet mit parent_id (Threading) → Cross-Card-Parent wird mit 422 abgelehnt → Hide-Operation versteckt vom Read aus → Bulk-Counts korrekt → Smart-Merge-Pull gegen v1.0.0→v1.1.0 zeigt 2 changed (Eudaimonia + Logos↔Tugendlehre über ord-Heuristik), 0 cards_inserted weil bereits-private-via-Fork. Bug-Fix: `r.use('*', authMiddleware)` in fork.ts wäre an dem `/api/v1/marketplace`-Mount-Punkt nachfolgende Router-Mounts (PRs, Discussions) versehentlich gefangen — Refactor auf per-route Middleware. Verbleibend: R5 Frontend-Routes, R6 voller UI-E2E. |
| 12 | Marketplace-Restore (R0R6) | 🟡 R0+R1+R2+R3+R4+R5 durch | Plan: [`docs/playbooks/MARKETPLACE_RESTORE.md`](docs/playbooks/MARKETPLACE_RESTORE.md). R0R4 (Backend-Stack): ✅. **R5 (Frontend-Routes): ✅**`apps/web/src/lib/api/marketplace.ts` (~340 Z. Client mit Authors, Discovery, Engagement, Subscribe, Fork, PR, Discussions), Components in `lib/components/marketplace/` (AuthorBadge, DeckListGrid, PublishVersionModal, SuggestEditModal, DiscussionThread, PullRequestList — eigener Namespace ohne Konflikt zu Tills WIP-DeckGrid.svelte), Routes: `/explore` (Featured + Trending + Browse mit Suche + Sortierung + Pagination), `/d/[slug]` (Public-Detail mit Star/Subscribe/Fork-Buttons + Karten-Liste mit Discussion-Counts + Suggest-Edit-Modal pro Karte + PR-Liste mit Owner-Merge/Reject + Publish-Modal für Owner), `/u/[slug]` (Author-Profil + Verified-Badges + Follow-Button + eigene Decks), `/me/published` (Author-Profil-CRUD + eigene Veröffentlichungen), `/me/subscribed` (Subs mit update_available-Banner), `/me/forks` (geforkte Decks mit „Update ziehen"-Button → Smart-Merge-Pull). svelte-check: 4017 Files, **0 errors, 5 Svelte-5-rune-Warnings** (benign — Modals capturen Init-Values von Props, gewollt). SSR-Smoke: alle 4 Marketplace-URLs (`/explore`, `/d/r5-stoa-grundlagen`, `/u/cardecky`, `/me/published`) liefern 200. Test-Decks `r5-stoa-grundlagen` (Stoische Grundbegriffe, 4 Karten v1.0.0) + `r5-deutsche-historie` (2 Karten) bewusst in der lokalen `cards`-DB liegen gelassen für Browser-Spielwiese. Header-Nav-Link auf `/explore` **nicht** gesetzt — `Header.svelte` ist in Tills uncommitted WIP, Link wird beim Theming-WIP-Commit nachgezogen. Verbleibend: R6 voller UI-E2E + ggf. Polish (Modal-Warnings, Empty-States, Loading-Skeletons). |
Legende: ✅ erledigt + verifiziert · 🚧 blockiert · ⏸ noch nicht begonnen

View file

@ -0,0 +1,440 @@
/**
* Marketplace-API-Client. Konsumiert das `/api/v1/marketplace/*`-
* Backend (siehe `cards/apps/api/src/routes/marketplace/`).
*
* Pattern wie `decks.ts` dünne Wrapper um den `api()`-Helper aus
* `client.ts`, der JWT/X-User-Id-Auth und 401-Refresh bereits
* abhandelt.
*/
import { api } from './client.ts';
// ─── Types ───────────────────────────────────────────────────────────
export interface MarketplaceAuthor {
slug: string;
display_name: string;
bio: string | null;
avatar_url: string | null;
joined_at: string;
pseudonym: boolean;
verified_mana: boolean;
verified_community: boolean;
banned: boolean;
}
export interface MarketplaceDeck {
id: string;
slug: string;
title: string;
description: string | null;
language: string | null;
license: string;
price_credits: number;
owner_user_id: string;
latest_version_id: string | null;
is_featured: boolean;
is_takedown: boolean;
created_at: string;
}
export interface MarketplaceVersion {
id: string;
deck_id: string;
semver: string;
changelog: string | null;
content_hash: string;
card_count: number;
published_at: string;
deprecated_at?: string | null;
}
export interface MarketplaceVersionCard {
content_hash: string;
type: string;
fields: Record<string, string>;
ord: number;
}
export interface DeckListEntry {
slug: string;
title: string;
description: string | null;
language: string | null;
license: string;
price_credits: number;
card_count: number;
star_count: number;
subscriber_count: number;
is_featured: boolean;
created_at: string;
owner: {
slug: string;
display_name: string;
verified_mana: boolean;
verified_community: boolean;
};
}
export interface PullRequest {
id: string;
deck_id: string;
author_user_id: string;
status: 'open' | 'merged' | 'closed' | 'rejected';
title: string;
body: string | null;
diff: {
add: { type: string; fields: Record<string, string> }[];
modify: { contentHash: string; fields: Record<string, string> }[];
remove: { contentHash: string }[];
};
merged_into_version_id: string | null;
created_at: string;
resolved_at: string | null;
}
export interface Discussion {
id: string;
card_content_hash: string;
deck_id: string;
author_user_id: string;
parent_id: string | null;
body: string;
hidden: boolean;
created_at: string;
}
export interface DiffPayload {
from: { semver?: string; versionId?: string };
to: { semver: string; versionId: string };
added: MarketplaceVersionCard[];
changed: { previous: { contentHash: string }; next: MarketplaceVersionCard }[];
unchanged: { contentHash: string; ord: number }[];
removed: { contentHash: string }[];
}
export interface SubscriptionEntry {
deck_slug: string;
deck_title: string;
deck_description: string | null;
subscribed_at: string;
notify_updates: boolean;
current_version_id: string | null;
latest_version_id: string | null;
update_available: boolean;
}
// ─── Authors ─────────────────────────────────────────────────────────
export function getMyAuthorProfile() {
return api<MarketplaceAuthor | null>('/api/v1/marketplace/authors/me');
}
export function upsertMyAuthorProfile(input: {
slug: string;
displayName: string;
bio?: string;
avatarUrl?: string;
pseudonym?: boolean;
}) {
return api<MarketplaceAuthor>('/api/v1/marketplace/authors/me', {
method: 'POST',
body: input,
});
}
export function getAuthor(slug: string) {
return api<MarketplaceAuthor>(`/api/v1/marketplace/authors/${slug}`);
}
// ─── Discovery ───────────────────────────────────────────────────────
export function getExplore() {
return api<{ featured: DeckListEntry[]; trending: DeckListEntry[] }>(
'/api/v1/marketplace/explore'
);
}
export interface BrowseQuery {
q?: string;
tag?: string;
language?: string;
author?: string;
sort?: 'recent' | 'popular' | 'trending';
limit?: number;
offset?: number;
}
export function browseDecks(query: BrowseQuery = {}) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== '') {
params.set(key, String(value));
}
}
const qs = params.toString();
return api<{ items: DeckListEntry[]; total: number }>(
`/api/v1/marketplace/decks${qs ? '?' + qs : ''}`
);
}
export function getTags() {
return api<{
tags: {
id: string;
slug: string;
name: string;
parent_id: string | null;
description: string | null;
curated: boolean;
}[];
}>('/api/v1/marketplace/tags');
}
// ─── Deck (Public) ───────────────────────────────────────────────────
export function getMarketplaceDeck(slug: string) {
return api<{ deck: MarketplaceDeck; latest_version: MarketplaceVersion | null }>(
`/api/v1/marketplace/decks/${slug}`
);
}
export function getMarketplaceVersion(slug: string, semver: string) {
return api<{ version: MarketplaceVersion; cards: MarketplaceVersionCard[] }>(
`/api/v1/marketplace/decks/${slug}/versions/${semver}`
);
}
export function getDiff(slug: string, fromSemver: string) {
return api<DiffPayload>(
`/api/v1/marketplace/decks/${slug}/diff?from=${encodeURIComponent(fromSemver)}`
);
}
export function initMarketplaceDeck(input: {
slug: string;
title: string;
description?: string;
language?: string;
license?: string;
priceCredits?: number;
}) {
return api<MarketplaceDeck>('/api/v1/marketplace/decks', {
method: 'POST',
body: input,
});
}
export function patchMarketplaceDeck(
slug: string,
patch: Partial<{
title: string;
description: string;
language: string;
license: string;
priceCredits: number;
}>
) {
return api<MarketplaceDeck>(`/api/v1/marketplace/decks/${slug}`, {
method: 'PATCH',
body: patch,
});
}
export function publishMarketplaceVersion(
slug: string,
input: {
semver: string;
changelog?: string;
cards: { type: string; fields: Record<string, string> }[];
}
) {
return api<{
deck: MarketplaceDeck;
version: MarketplaceVersion;
moderation: { verdict: string; categories: string[]; model: string };
}>(`/api/v1/marketplace/decks/${slug}/publish`, {
method: 'POST',
body: input,
});
}
// ─── Engagement: Stars + Follows ─────────────────────────────────────
export function starDeck(slug: string) {
return api<{ starred: true }>(`/api/v1/marketplace/decks/${slug}/star`, {
method: 'POST',
});
}
export function unstarDeck(slug: string) {
return api<{ starred: false }>(`/api/v1/marketplace/decks/${slug}/star`, {
method: 'DELETE',
});
}
export function getStarState(slug: string) {
return api<{ starred: boolean }>(`/api/v1/marketplace/decks/${slug}/star`);
}
export function followAuthor(slug: string) {
return api<{ following: true }>(`/api/v1/marketplace/authors/${slug}/follow`, {
method: 'POST',
});
}
export function unfollowAuthor(slug: string) {
return api<{ following: false }>(`/api/v1/marketplace/authors/${slug}/follow`, {
method: 'DELETE',
});
}
export function getFollowState(slug: string) {
return api<{ following: boolean }>(`/api/v1/marketplace/authors/${slug}/follow`);
}
// ─── Subscriptions + Fork + Pull-Update ──────────────────────────────
export function subscribe(slug: string) {
return api<{ subscribed: true; deck_slug: string; current_version_id: string }>(
`/api/v1/marketplace/decks/${slug}/subscribe`,
{ method: 'POST' }
);
}
export function unsubscribe(slug: string) {
return api<{ subscribed: false }>(`/api/v1/marketplace/decks/${slug}/subscribe`, {
method: 'DELETE',
});
}
export function getSubscribeState(slug: string) {
return api<{ subscribed: boolean; current_version_id?: string }>(
`/api/v1/marketplace/decks/${slug}/subscribe`
);
}
export function getMySubscriptions() {
return api<{ subscriptions: SubscriptionEntry[] }>('/api/v1/marketplace/me/subscriptions');
}
export function forkDeck(slug: string, color?: string) {
return api<{
deck: {
id: string;
name: string;
description: string | null;
color: string | null;
forked_from_marketplace_deck_id: string;
forked_from_marketplace_version_id: string;
};
cards_created: number;
}>(`/api/v1/marketplace/decks/${slug}/fork`, {
method: 'POST',
body: color ? { color } : {},
});
}
export function pullUpdate(privateDeckId: string) {
return api<{
up_to_date: boolean;
from?: { semver: string; versionId: string };
to?: { semver: string; versionId: string };
added: number;
changed: number;
removed: number;
cards_inserted?: number;
}>(`/api/v1/marketplace/private/${privateDeckId}/pull-update`, {
method: 'POST',
});
}
// ─── Pull-Requests ───────────────────────────────────────────────────
export interface PrCreateInput {
title: string;
body?: string;
diff: {
add: { type: string; fields: Record<string, string> }[];
modify: { type: string; previousContentHash: string; fields: Record<string, string> }[];
remove: { contentHash: string }[];
};
}
export function listPullRequests(
slug: string,
status?: 'open' | 'merged' | 'closed' | 'rejected'
) {
const qs = status ? `?status=${status}` : '';
return api<{ pull_requests: PullRequest[]; total: number }>(
`/api/v1/marketplace/decks/${slug}/pull-requests${qs}`
);
}
export function getPullRequest(id: string) {
return api<PullRequest>(`/api/v1/marketplace/pull-requests/${id}`);
}
export function createPullRequest(slug: string, input: PrCreateInput) {
return api<PullRequest>(`/api/v1/marketplace/decks/${slug}/pull-requests`, {
method: 'POST',
body: input,
});
}
export function closePullRequest(id: string) {
return api<{ status: 'closed' }>(`/api/v1/marketplace/pull-requests/${id}/close`, {
method: 'POST',
});
}
export function rejectPullRequest(id: string) {
return api<{ status: 'rejected' }>(`/api/v1/marketplace/pull-requests/${id}/reject`, {
method: 'POST',
});
}
export function mergePullRequest(
id: string,
opts: { newSemver?: string; mergeNote?: string } = {}
) {
return api<{
pull_request: PullRequest;
version: MarketplaceVersion;
}>(`/api/v1/marketplace/pull-requests/${id}/merge`, {
method: 'POST',
body: opts,
});
}
// ─── Card-Discussions ────────────────────────────────────────────────
export function listDiscussions(cardContentHash: string) {
return api<{ discussions: Discussion[]; total: number }>(
`/api/v1/marketplace/cards/${cardContentHash}/discussions`
);
}
export function postDiscussion(
deckSlug: string,
cardContentHash: string,
body: string,
parentId?: string
) {
return api<Discussion>(
`/api/v1/marketplace/decks/${deckSlug}/cards/${cardContentHash}/discussions`,
{ method: 'POST', body: { body, parentId } }
);
}
export function getDiscussionCounts(slug: string) {
return api<{ counts: Record<string, number> }>(
`/api/v1/marketplace/decks/${slug}/discussions/counts`
);
}
export function hideDiscussion(id: string) {
return api<{ hidden: true }>(`/api/v1/marketplace/discussions/${id}/hide`, {
method: 'POST',
});
}

View file

@ -0,0 +1,37 @@
<script lang="ts">
interface Props {
slug: string;
displayName: string;
verifiedMana?: boolean;
verifiedCommunity?: boolean;
size?: 'sm' | 'md';
}
const { slug, displayName, verifiedMana = false, verifiedCommunity = false, size = 'md' }: Props =
$props();
const labelTitle = $derived(
[
verifiedMana ? 'Mana Verifiziert' : null,
verifiedCommunity ? 'Community Verifiziert' : null,
]
.filter(Boolean)
.join(' · ')
);
</script>
<a
href="/u/{slug}"
class="inline-flex items-center gap-1 hover:text-[hsl(var(--color-primary))] {size === 'sm'
? 'text-xs'
: 'text-sm'}"
title={labelTitle || displayName}
>
<span class="font-medium">{displayName}</span>
{#if verifiedMana}
<span aria-label="Mana Verifiziert" title="Mana Verifiziert">🛡️</span>
{/if}
{#if verifiedCommunity}
<span aria-label="Community Verifiziert" title="Community Verifiziert"></span>
{/if}
</a>

View file

@ -0,0 +1,79 @@
<script lang="ts">
import type { DeckListEntry } from '$lib/api/marketplace.ts';
import AuthorBadge from './AuthorBadge.svelte';
interface Props {
items: DeckListEntry[];
emptyMessage?: string;
}
const { items, emptyMessage = 'Noch keine Decks gefunden.' }: Props = $props();
function languageLabel(code: string | null): string {
if (!code) return '';
const map: Record<string, string> = { de: 'Deutsch', en: 'English', es: 'Español', fr: 'Français' };
return map[code] ?? code.toUpperCase();
}
</script>
{#if items.length === 0}
<p class="rounded-lg border border-dashed border-[hsl(var(--color-border))] p-8 text-center text-sm text-[hsl(var(--color-muted-foreground))]">
{emptyMessage}
</p>
{:else}
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each items as deck (deck.slug)}
<li
class="group relative rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4 transition-colors hover:border-[hsl(var(--color-primary))]"
>
<a href="/d/{deck.slug}" class="block">
<div class="flex items-start justify-between gap-2">
<h3 class="truncate font-medium">{deck.title}</h3>
{#if deck.is_featured}
<span
class="shrink-0 rounded-full bg-[hsl(var(--color-primary))]/15 px-2 py-0.5 text-[10px] font-medium text-[hsl(var(--color-primary))]"
title="Editorial Pick"
>
★ Featured
</span>
{/if}
</div>
{#if deck.description}
<p
class="mt-1 line-clamp-2 text-xs text-[hsl(var(--color-muted-foreground))]"
>
{deck.description}
</p>
{/if}
<div class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[hsl(var(--color-muted-foreground))]">
<AuthorBadge
slug={deck.owner.slug}
displayName={deck.owner.display_name}
verifiedMana={deck.owner.verified_mana}
verifiedCommunity={deck.owner.verified_community}
size="sm"
/>
<span>·</span>
<span title="Karten">{deck.card_count} 🃏</span>
<span>·</span>
<span title="Stars">{deck.star_count}</span>
{#if deck.subscriber_count > 0}
<span>·</span>
<span title="Subscribers">{deck.subscriber_count} ↩︎</span>
{/if}
{#if deck.language}
<span>·</span>
<span class="uppercase">{languageLabel(deck.language)}</span>
{/if}
{#if deck.price_credits > 0}
<span>·</span>
<span class="font-medium text-[hsl(var(--color-primary))]">
{deck.price_credits} Credits
</span>
{/if}
</div>
</a>
</li>
{/each}
</ul>
{/if}

View file

@ -0,0 +1,155 @@
<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>

View file

@ -0,0 +1,156 @@
<script lang="ts">
import { publishMarketplaceVersion } from '$lib/api/marketplace.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
interface Props {
slug: string;
latestSemver: string | null;
onPublished?: () => void;
onClose?: () => void;
}
const { slug, latestSemver, onPublished, onClose }: Props = $props();
function bumpMinor(semver: string): string {
const m = semver.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!m) return '1.0.0';
return `${m[1]}.${Number(m[2]) + 1}.0`;
}
let semver = $state(latestSemver ? bumpMinor(latestSemver) : '1.0.0');
let changelog = $state('');
let cardsJson = $state('[]');
let busy = $state(false);
let error = $state<string | null>(null);
async function onSubmit(event: SubmitEvent) {
event.preventDefault();
if (busy) return;
busy = true;
error = null;
let cards: { type: string; fields: Record<string, string> }[] = [];
try {
const parsed = JSON.parse(cardsJson);
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error('cards muss ein nicht-leeres Array sein');
}
for (const c of parsed) {
if (typeof c !== 'object' || !c.type || !c.fields) {
throw new Error('Jede Karte braucht type + fields');
}
}
cards = parsed;
} catch (e) {
error = `JSON-Parse-Fehler: ${(e as Error).message}`;
busy = false;
return;
}
try {
const result = await publishMarketplaceVersion(slug, {
semver,
changelog: changelog || undefined,
cards,
});
toasts.success(`Version ${result.version.semver} veröffentlicht (${result.version.card_count} Karten)`);
onPublished?.();
} catch (e) {
error = (e as Error).message;
busy = false;
}
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
>
<div class="w-full max-w-2xl rounded-lg bg-[hsl(var(--color-card))] p-6 shadow-2xl">
<div class="flex items-start justify-between">
<div>
<h2 class="text-xl font-semibold">Neue Version veröffentlichen</h2>
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))]">
{#if latestSemver}
Aktuell: v{latestSemver}. Default-Bump auf v{bumpMinor(latestSemver)}.
{:else}
Erste Version dieses Decks. Karten als JSON unten einfügen.
{/if}
</p>
</div>
<button
type="button"
onclick={onClose}
class="text-2xl text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
aria-label="Schließen"
>
×
</button>
</div>
<form class="mt-6 space-y-4" onsubmit={onSubmit}>
<label class="block">
<span class="text-sm font-medium">SemVer (X.Y.Z)</span>
<input
type="text"
bind:value={semver}
required
pattern="\d+\.\d+\.\d+"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm font-mono"
/>
</label>
<label class="block">
<span class="text-sm font-medium">Changelog (optional)</span>
<textarea
bind:value={changelog}
rows="2"
maxlength="2000"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
></textarea>
</label>
<label class="block">
<span class="text-sm font-medium">Karten (JSON-Array)</span>
<textarea
bind:value={cardsJson}
rows="10"
required
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-xs"
placeholder={'[\n {"type":"basic","fields":{"front":"…","back":"…"}}\n]'}
></textarea>
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">
Schema: <code>type</code>{`{basic, basic-reverse, cloze, …}`}, <code>fields</code> ist ein
Key-Value-Object.
</p>
</label>
{#if error}
<div
class="rounded border border-[hsl(var(--color-error))]/40 bg-[hsl(var(--color-error))]/10 p-3 text-sm text-[hsl(var(--color-error))]"
role="alert"
>
{error}
</div>
{/if}
<div class="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onclick={onClose}
class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
>
Abbrechen
</button>
<button
type="submit"
disabled={busy}
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
>
{busy ? 'Veröffentliche…' : 'Veröffentlichen'}
</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,189 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
closePullRequest,
listPullRequests,
mergePullRequest,
rejectPullRequest,
type PullRequest,
} from '$lib/api/marketplace.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
interface Props {
slug: string;
ownerUserId: string;
latestSemver: string | null;
}
const { slug, ownerUserId, latestSemver }: Props = $props();
let prs = $state<PullRequest[]>([]);
let loading = $state(true);
let statusFilter = $state<'open' | 'merged' | 'closed' | 'rejected'>('open');
let busyId = $state<string | null>(null);
const myUserId = $derived(devUser.id);
const isOwner = $derived(myUserId === ownerUserId);
onMount(() => {
void refresh();
});
async function refresh() {
loading = true;
try {
const result = await listPullRequests(slug, statusFilter);
prs = result.pull_requests;
} catch (e) {
toasts.error(`PRs laden fehlgeschlagen: ${(e as Error).message}`);
} finally {
loading = false;
}
}
function reload() {
void refresh();
}
async function onMerge(id: string) {
busyId = id;
try {
await mergePullRequest(id);
toasts.success('PR gemerged — neue Version live.');
await refresh();
} catch (e) {
toasts.error((e as Error).message);
} finally {
busyId = null;
}
}
async function onReject(id: string) {
if (!confirm('PR ablehnen?')) return;
busyId = id;
try {
await rejectPullRequest(id);
await refresh();
} catch (e) {
toasts.error((e as Error).message);
} finally {
busyId = null;
}
}
async function onClose(id: string) {
busyId = id;
try {
await closePullRequest(id);
await refresh();
} catch (e) {
toasts.error((e as Error).message);
} finally {
busyId = null;
}
}
function diffSummary(diff: PullRequest['diff']): string {
const a = diff.add?.length ?? 0;
const m = diff.modify?.length ?? 0;
const r = diff.remove?.length ?? 0;
return `+${a} ~${m} ${r}`;
}
</script>
<div class="space-y-4">
<div class="flex items-center justify-between gap-2">
<h3 class="text-lg font-semibold">Pull-Requests</h3>
<select
bind:value={statusFilter}
onchange={reload}
class="rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] px-2 py-1 text-xs"
>
<option value="open">Open</option>
<option value="merged">Merged</option>
<option value="closed">Closed</option>
<option value="rejected">Rejected</option>
</select>
</div>
{#if loading}
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
{:else if prs.length === 0}
<p
class="rounded border border-dashed border-[hsl(var(--color-border))] p-4 text-center text-sm text-[hsl(var(--color-muted-foreground))]"
>
Keine {statusFilter} PRs.
</p>
{:else}
<ul class="space-y-2">
{#each prs as pr (pr.id)}
<li class="rounded-lg border border-[hsl(var(--color-border))] p-3">
<div class="flex items-start justify-between gap-2">
<div class="flex-1">
<h4 class="font-medium">{pr.title}</h4>
{#if pr.body}
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))] whitespace-pre-wrap">
{pr.body}
</p>
{/if}
<p class="mt-2 text-xs text-[hsl(var(--color-muted-foreground))]">
<span class="font-mono">{diffSummary(pr.diff)}</span>
·
<span class="capitalize">{pr.status}</span>
·
<time>{new Date(pr.created_at).toLocaleDateString()}</time>
{#if pr.status === 'merged' && pr.merged_into_version_id}
·
<span class="text-[hsl(var(--color-primary))]">in einer neuen Version live</span>
{/if}
</p>
</div>
{#if pr.status === 'open'}
<div class="flex shrink-0 flex-col gap-1">
{#if isOwner}
<button
type="button"
class="rounded bg-[hsl(var(--color-primary))] px-3 py-1 text-xs text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
disabled={busyId === pr.id}
onclick={() => onMerge(pr.id)}
>
{busyId === pr.id ? '…' : 'Merge'}
</button>
<button
type="button"
class="rounded border border-[hsl(var(--color-error))] px-3 py-1 text-xs text-[hsl(var(--color-error))] disabled:opacity-50"
disabled={busyId === pr.id}
onclick={() => onReject(pr.id)}
>
Ablehnen
</button>
{:else if pr.author_user_id === myUserId}
<button
type="button"
class="rounded border border-[hsl(var(--color-border))] px-3 py-1 text-xs disabled:opacity-50"
disabled={busyId === pr.id}
onclick={() => onClose(pr.id)}
>
Schließen
</button>
{/if}
</div>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
{#if isOwner && statusFilter === 'open' && latestSemver}
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">
Tipp: Beim Merge wird automatisch auf v{latestSemver
.split('.')
.map((n, i) => (i === 1 ? Number(n) + 1 : i === 2 ? '0' : n))
.join('.')} gebumpt (semver-minor).
</p>
{/if}
</div>

View file

@ -0,0 +1,168 @@
<script lang="ts">
import { createPullRequest } from '$lib/api/marketplace.ts';
import type { MarketplaceVersionCard } from '$lib/api/marketplace.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
interface Props {
slug: string;
card: MarketplaceVersionCard;
onSubmitted?: () => void;
onClose?: () => void;
}
const { slug, card, onSubmitted, onClose }: Props = $props();
let mode = $state<'modify' | 'remove'>('modify');
let title = $state(`„${(card.fields.front ?? card.fields.text ?? '').slice(0, 60)}" verbessern`);
let body = $state('');
// Map des aktuellen field-Werts → editierbarer Wert.
let editedFields = $state<Record<string, string>>({ ...card.fields });
let busy = $state(false);
let error = $state<string | null>(null);
const fieldKeys = $derived(Object.keys(card.fields));
async function onSubmit(event: SubmitEvent) {
event.preventDefault();
if (busy) return;
busy = true;
error = null;
const diff =
mode === 'modify'
? {
add: [],
modify: [
{
type: card.type,
previousContentHash: card.content_hash,
fields: editedFields,
},
],
remove: [],
}
: {
add: [],
modify: [],
remove: [{ contentHash: card.content_hash }],
};
try {
await createPullRequest(slug, {
title,
body: body || undefined,
diff,
});
toasts.success('Pull-Request eingereicht — Author bekommt Bescheid.');
onSubmitted?.();
} catch (e) {
error = (e as Error).message;
busy = false;
}
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
>
<div class="w-full max-w-xl rounded-lg bg-[hsl(var(--color-card))] p-6 shadow-2xl">
<div class="flex items-start justify-between">
<h2 class="text-xl font-semibold">✏️ Karte verbessern</h2>
<button
type="button"
onclick={onClose}
class="text-2xl text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
aria-label="Schließen"
>
×
</button>
</div>
<form class="mt-4 space-y-4" onsubmit={onSubmit}>
<fieldset class="flex gap-2 text-sm">
<label class="flex items-center gap-1">
<input type="radio" bind:group={mode} value="modify" />
Felder ändern
</label>
<label class="flex items-center gap-1">
<input type="radio" bind:group={mode} value="remove" />
Karte entfernen
</label>
</fieldset>
<label class="block">
<span class="text-sm font-medium">Titel des PRs</span>
<input
type="text"
bind:value={title}
required
maxlength="140"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
/>
</label>
<label class="block">
<span class="text-sm font-medium">Begründung (optional)</span>
<textarea
bind:value={body}
rows="3"
maxlength="4000"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
></textarea>
</label>
{#if mode === 'modify'}
<div class="space-y-2 rounded border border-[hsl(var(--color-border))] p-3">
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">
Karten-Typ: <code>{card.type}</code>
</p>
{#each fieldKeys as key (key)}
<label class="block">
<span class="text-sm font-medium">{key}</span>
<textarea
bind:value={editedFields[key]}
rows="2"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
></textarea>
</label>
{/each}
</div>
{:else}
<p
class="rounded border border-[hsl(var(--color-error))]/40 bg-[hsl(var(--color-error))]/5 p-3 text-sm"
>
Karte wird nach Merge des PRs aus dem Deck entfernt. Subscriber, die die Karte schon
lokal haben, behalten sie (server-authoritative User-Choice).
</p>
{/if}
{#if error}
<div
class="rounded border border-[hsl(var(--color-error))]/40 bg-[hsl(var(--color-error))]/10 p-3 text-sm text-[hsl(var(--color-error))]"
role="alert"
>
{error}
</div>
{/if}
<div class="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onclick={onClose}
class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
>
Abbrechen
</button>
<button
type="submit"
disabled={busy}
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
>
{busy ? 'Reiche ein…' : 'PR einreichen'}
</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,366 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import {
forkDeck,
getMarketplaceDeck,
getMarketplaceVersion,
getDiscussionCounts,
getStarState,
getSubscribeState,
starDeck,
subscribe,
unstarDeck,
unsubscribe,
type MarketplaceDeck,
type MarketplaceVersion,
type MarketplaceVersionCard,
} from '$lib/api/marketplace.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import AuthorBadge from '$lib/components/marketplace/AuthorBadge.svelte';
import DiscussionThread from '$lib/components/marketplace/DiscussionThread.svelte';
import PublishVersionModal from '$lib/components/marketplace/PublishVersionModal.svelte';
import PullRequestList from '$lib/components/marketplace/PullRequestList.svelte';
import SuggestEditModal from '$lib/components/marketplace/SuggestEditModal.svelte';
import { toasts } from '$lib/stores/toasts.svelte.ts';
const slug = $derived(page.params.slug ?? '');
let deck = $state<MarketplaceDeck | null>(null);
let latestVersion = $state<MarketplaceVersion | null>(null);
let cards = $state<MarketplaceVersionCard[]>([]);
let discussionCounts = $state<Record<string, number>>({});
let starred = $state(false);
let subscribed = $state(false);
let loading = $state(true);
let busy = $state(false);
let publishOpen = $state(false);
let suggestOpen = $state(false);
let suggestCard = $state<MarketplaceVersionCard | null>(null);
let openDiscussionFor = $state<string | null>(null);
const myUserId = $derived(devUser.id);
const isOwner = $derived(deck !== null && myUserId === deck.owner_user_id);
onMount(() => {
void load();
});
async function load() {
loading = true;
try {
const detail = await getMarketplaceDeck(slug);
deck = detail.deck;
latestVersion = detail.latest_version;
const [stateChecks, version, counts] = await Promise.all([
myUserId
? Promise.all([getStarState(slug), getSubscribeState(slug)])
: Promise.resolve([{ starred: false }, { subscribed: false }] as const),
latestVersion
? getMarketplaceVersion(slug, latestVersion.semver).then((r) => r.cards)
: Promise.resolve([] as MarketplaceVersionCard[]),
getDiscussionCounts(slug).then((r) => r.counts).catch(() => ({})),
]);
starred = (stateChecks as [{ starred: boolean }, unknown])[0].starred;
subscribed = (stateChecks as [unknown, { subscribed: boolean }])[1].subscribed;
cards = version;
discussionCounts = counts;
} catch (e) {
toasts.error(`Deck laden fehlgeschlagen: ${(e as Error).message}`);
} finally {
loading = false;
}
}
async function toggleStar() {
if (!myUserId) {
toasts.error('Bitte einloggen.');
return;
}
busy = true;
try {
if (starred) {
await unstarDeck(slug);
starred = false;
} else {
await starDeck(slug);
starred = true;
}
} catch (e) {
toasts.error((e as Error).message);
} finally {
busy = false;
}
}
async function toggleSubscribe() {
if (!myUserId) {
toasts.error('Bitte einloggen.');
return;
}
busy = true;
try {
if (subscribed) {
await unsubscribe(slug);
subscribed = false;
toasts.success('Abo gekündigt.');
} else {
await subscribe(slug);
subscribed = true;
toasts.success('Abonniert. Update-Benachrichtigung an.');
}
} catch (e) {
toasts.error((e as Error).message);
} finally {
busy = false;
}
}
async function onFork() {
if (!myUserId) {
toasts.error('Bitte einloggen.');
return;
}
busy = true;
try {
const result = await forkDeck(slug);
toasts.success(`Deck geforkt — ${result.cards_created} Karten kopiert.`);
await goto(`/decks/${result.deck.id}`);
} catch (e) {
toasts.error((e as Error).message);
} finally {
busy = false;
}
}
function previewCard(card: MarketplaceVersionCard): string {
const text = card.fields.front ?? card.fields.text ?? Object.values(card.fields)[0] ?? '';
return text.length > 140 ? text.slice(0, 140) + '…' : text;
}
function onSuggestEdit(card: MarketplaceVersionCard) {
if (!myUserId) {
toasts.error('Bitte einloggen, um einen PR einzureichen.');
return;
}
suggestCard = card;
suggestOpen = true;
}
</script>
<svelte:head>
<title>{deck?.title ?? slug} · Cardecky</title>
</svelte:head>
{#if loading}
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
{:else if !deck}
<p>Deck 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-3">
<div class="flex items-start justify-between gap-3">
<h1 class="text-3xl font-semibold">{deck.title}</h1>
{#if deck.is_takedown}
<span
class="rounded bg-[hsl(var(--color-error))]/15 px-2 py-1 text-xs text-[hsl(var(--color-error))]"
>
Take-Down
</span>
{/if}
{#if deck.is_featured}
<span
class="rounded bg-[hsl(var(--color-primary))]/15 px-2 py-1 text-xs text-[hsl(var(--color-primary))]"
>
★ Featured
</span>
{/if}
</div>
{#if deck.description}
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">{deck.description}</p>
{/if}
<div class="flex flex-wrap items-center gap-3 text-xs text-[hsl(var(--color-muted-foreground))]">
<span class="rounded bg-[hsl(var(--color-border))]/30 px-2 py-0.5 font-mono">
{deck.license}
</span>
{#if deck.language}
<span class="uppercase">{deck.language}</span>
{/if}
{#if latestVersion}
<span>v{latestVersion.semver} · {latestVersion.card_count} Karten</span>
{#if latestVersion.changelog}
<span title={latestVersion.changelog}>
📝 „{latestVersion.changelog.slice(0, 60)}…"
</span>
{/if}
{:else}
<span class="italic">noch nicht published</span>
{/if}
</div>
</header>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="rounded border border-[hsl(var(--color-border))] px-3 py-2 text-sm hover:bg-[hsl(var(--color-card))]"
onclick={toggleStar}
disabled={busy}
aria-pressed={starred}
>
{starred ? '★ Gestarred' : '☆ Star'}
</button>
<button
type="button"
class="rounded border border-[hsl(var(--color-border))] px-3 py-2 text-sm hover:bg-[hsl(var(--color-card))]"
onclick={toggleSubscribe}
disabled={busy || !latestVersion}
aria-pressed={subscribed}
>
{subscribed ? '↩︎ Abonniert' : '↩︎ Abonnieren'}
</button>
<button
type="button"
class="rounded bg-[hsl(var(--color-primary))] px-3 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
onclick={onFork}
disabled={busy || !latestVersion || !myUserId}
title="Karten in eigenes privates Deck kopieren — eigener Lern-Stand"
>
🔱 Fork
</button>
{#if isOwner}
<button
type="button"
class="ml-auto rounded border border-[hsl(var(--color-primary))] px-3 py-2 text-sm text-[hsl(var(--color-primary))] hover:bg-[hsl(var(--color-primary))]/10"
onclick={() => (publishOpen = true)}
>
+ Neue Version
</button>
{/if}
</div>
{#if deck}
<section>
<header class="mb-3 flex items-center gap-2 text-sm">
<span>Author:</span>
<AuthorBadge
slug=""
displayName={deck.owner_user_id.slice(0, 12) + '…'}
size="sm"
/>
<span class="text-[hsl(var(--color-muted-foreground))]">
(Profil-Slug auf Author-Page sichtbar)
</span>
</header>
</section>
{/if}
<section>
<h2 class="mb-3 text-xl font-semibold">Karten</h2>
{#if cards.length === 0}
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Noch keine Karten.</p>
{:else}
<ul class="space-y-2">
{#each cards as card (card.content_hash)}
<li class="rounded-lg border border-[hsl(var(--color-border))] p-3">
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<p class="text-sm font-medium">
{previewCard(card)}
</p>
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">
<code>{card.type}</code>
· ord {card.ord}
· hash {card.content_hash.slice(0, 8)}
{#if discussionCounts[card.content_hash] > 0}
· 💬 {discussionCounts[card.content_hash]}
{/if}
</p>
</div>
<div class="flex shrink-0 flex-col gap-1">
<button
type="button"
class="rounded border border-[hsl(var(--color-border))] px-2 py-1 text-xs hover:bg-[hsl(var(--color-card))]"
onclick={() =>
(openDiscussionFor =
openDiscussionFor === card.content_hash ? null : card.content_hash)}
>
💬 {discussionCounts[card.content_hash] ?? 0}
</button>
{#if myUserId && !isOwner}
<button
type="button"
class="rounded border border-[hsl(var(--color-primary))] px-2 py-1 text-xs text-[hsl(var(--color-primary))] hover:bg-[hsl(var(--color-primary))]/10"
onclick={() => onSuggestEdit(card)}
>
✏️ Verbessern
</button>
{/if}
</div>
</div>
{#if openDiscussionFor === card.content_hash && deck}
<div class="mt-3 border-t border-[hsl(var(--color-border))] pt-3">
<DiscussionThread
deckSlug={slug}
cardContentHash={card.content_hash}
ownerUserId={deck.owner_user_id}
/>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{#if deck}
<section>
<PullRequestList
{slug}
ownerUserId={deck.owner_user_id}
latestSemver={latestVersion?.semver ?? null}
/>
</section>
{/if}
</div>
{#if publishOpen && deck}
<PublishVersionModal
{slug}
latestSemver={latestVersion?.semver ?? null}
onClose={() => (publishOpen = false)}
onPublished={async () => {
publishOpen = false;
await load();
}}
/>
{/if}
{#if suggestOpen && suggestCard}
<SuggestEditModal
{slug}
card={suggestCard}
onClose={() => {
suggestOpen = false;
suggestCard = null;
}}
onSubmitted={() => {
suggestOpen = false;
suggestCard = null;
}}
/>
{/if}
{/if}

View file

@ -0,0 +1,167 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
browseDecks,
getExplore,
type DeckListEntry,
} from '$lib/api/marketplace.ts';
import DeckListGrid from '$lib/components/marketplace/DeckListGrid.svelte';
import { toasts } from '$lib/stores/toasts.svelte.ts';
let featured = $state<DeckListEntry[]>([]);
let trending = $state<DeckListEntry[]>([]);
let browseResults = $state<DeckListEntry[]>([]);
let browseTotal = $state(0);
let loadingExplore = $state(true);
let loadingBrowse = $state(false);
let q = $state('');
let language = $state('');
let sort = $state<'recent' | 'popular' | 'trending'>('recent');
let offset = $state(0);
const limit = 12;
onMount(async () => {
try {
const explore = await getExplore();
featured = explore.featured;
trending = explore.trending;
} catch (e) {
toasts.error(`Explore-Liste laden fehlgeschlagen: ${(e as Error).message}`);
} finally {
loadingExplore = false;
}
await runBrowse(true);
});
async function runBrowse(reset = false) {
loadingBrowse = true;
if (reset) {
offset = 0;
browseResults = [];
}
try {
const result = await browseDecks({
q: q || undefined,
language: language || undefined,
sort,
limit,
offset,
});
if (reset) {
browseResults = result.items;
} else {
browseResults = [...browseResults, ...result.items];
}
browseTotal = result.total;
} catch (e) {
toasts.error(`Browse fehlgeschlagen: ${(e as Error).message}`);
} finally {
loadingBrowse = false;
}
}
function onSearch(event: SubmitEvent) {
event.preventDefault();
void runBrowse(true);
}
function onLoadMore() {
offset += limit;
void runBrowse(false);
}
</script>
<svelte:head>
<title>Explore · Cardecky</title>
</svelte:head>
<div class="mx-auto max-w-6xl space-y-10">
<header>
<h1 class="text-3xl font-semibold">Cardecky-Library</h1>
<p class="mt-2 text-sm text-[hsl(var(--color-muted-foreground))]">
Decks von der Verein-Community + KI-kuratierten Cardecky-Author. Subscribe für Live-Updates,
fork für eigenen Lern-Stand, ✏️ Verbesserungen via Pull-Request einreichen.
</p>
</header>
{#if loadingExplore}
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade Featured + Trending…</p>
{:else}
{#if featured.length > 0}
<section>
<h2 class="mb-3 text-xl font-semibold">★ Featured</h2>
<DeckListGrid items={featured} />
</section>
{/if}
{#if trending.length > 0}
<section>
<h2 class="mb-3 text-xl font-semibold">🔥 Trending</h2>
<DeckListGrid items={trending} />
</section>
{/if}
{/if}
<section>
<h2 class="mb-3 text-xl font-semibold">🔎 Stöbern</h2>
<form
class="mb-4 flex flex-wrap items-end gap-3"
onsubmit={onSearch}
>
<label class="flex-1 min-w-[14rem]">
<span class="block text-xs text-[hsl(var(--color-muted-foreground))]">Suche</span>
<input
type="text"
bind:value={q}
placeholder="Titel oder Beschreibung"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
/>
</label>
<label>
<span class="block text-xs text-[hsl(var(--color-muted-foreground))]">Sprache</span>
<select
bind:value={language}
class="mt-1 block rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
>
<option value="">Alle</option>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</label>
<label>
<span class="block text-xs text-[hsl(var(--color-muted-foreground))]">Sortierung</span>
<select
bind:value={sort}
class="mt-1 block rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
>
<option value="recent">Neueste</option>
<option value="popular">Beliebt</option>
<option value="trending">Trending</option>
</select>
</label>
<button
type="submit"
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
disabled={loadingBrowse}
>
Suchen
</button>
</form>
<DeckListGrid items={browseResults} emptyMessage="Keine Decks zu deinem Filter." />
{#if browseResults.length < browseTotal}
<button
type="button"
class="mt-4 w-full rounded border border-[hsl(var(--color-border))] py-2 text-sm hover:bg-[hsl(var(--color-card))]"
onclick={onLoadMore}
disabled={loadingBrowse}
>
{loadingBrowse ? 'Lade…' : `${browseResults.length} / ${browseTotal} mehr laden`}
</button>
{/if}
</section>
</div>

View file

@ -0,0 +1,122 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { listDecks } from '$lib/api/decks.ts';
import { pullUpdate } from '$lib/api/marketplace.ts';
import type { Deck } from '@cards/domain';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
let forks = $state<Deck[]>([]);
let loading = $state(true);
let busyId = $state<string | null>(null);
onMount(async () => {
if (!devUser.id) {
await goto('/');
return;
}
await refresh();
});
async function refresh() {
loading = true;
try {
const result = await listDecks();
// Greenfield's `cards.decks` hat `forked_from_marketplace_*`-
// Spalten, die @cards/domain Deck-Type nicht unbedingt
// exportiert. Wir filtern hier defensiv über runtime-Cast.
forks = result.decks.filter(
(d) => (d as Deck & { forked_from_marketplace_deck_id?: string | null })
.forked_from_marketplace_deck_id != null
);
} catch (e) {
toasts.error(`Forks laden fehlgeschlagen: ${(e as Error).message}`);
} finally {
loading = false;
}
}
async function onPullUpdate(deckId: string) {
busyId = deckId;
try {
const result = await pullUpdate(deckId);
if (result.up_to_date) {
toasts.success('Schon auf dem neuesten Stand.');
} else {
toasts.success(
`Update gezogen: +${result.added} neu, ~${result.changed} geändert, ${result.removed} entfernt. ${result.cards_inserted ?? 0} neue Karten lokal.`
);
await refresh();
}
} catch (e) {
toasts.error((e as Error).message);
} finally {
busyId = null;
}
}
</script>
<svelte:head>
<title>Meine Forks · Cardecky</title>
</svelte:head>
<div class="mx-auto max-w-4xl space-y-6">
<header>
<h1 class="text-2xl font-semibold">Meine Forks</h1>
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))]">
Marketplace-Decks, die du in deine private Lern-Welt kopiert hast. Smart-Merge: bei
„Update ziehen" werden neue/geänderte Karten ergänzt — unveränderte Karten samt FSRS-Stand
bleiben unangetastet.
</p>
</header>
{#if loading}
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
{:else if forks.length === 0}
<div
class="rounded-lg border border-dashed border-[hsl(var(--color-border))] p-8 text-center"
>
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Noch nichts geforkt.</p>
<a
href="/explore"
class="mt-2 inline-block text-sm text-[hsl(var(--color-primary))] hover:underline"
>
Library durchstöbern →
</a>
</div>
{:else}
<ul class="space-y-2">
{#each forks as fork (fork.id)}
<li class="rounded-lg border border-[hsl(var(--color-border))] p-4">
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<a
href="/decks/{fork.id}"
class="font-medium hover:text-[hsl(var(--color-primary))]"
>
{fork.name}
</a>
{#if fork.description}
<p class="mt-1 line-clamp-2 text-sm text-[hsl(var(--color-muted-foreground))]">
{fork.description}
</p>
{/if}
</div>
<button
type="button"
class="shrink-0 rounded border border-[hsl(var(--color-primary))] px-3 py-1 text-xs text-[hsl(var(--color-primary))] hover:bg-[hsl(var(--color-primary))]/10 disabled:opacity-50"
onclick={() => onPullUpdate(fork.id)}
disabled={busyId === fork.id}
title="Smart-Merge: nur neue/geänderte Karten dazu, FSRS bleibt"
>
{busyId === fork.id ? '…' : 'Update ziehen'}
</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>

View file

@ -0,0 +1,192 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import {
browseDecks,
getMyAuthorProfile,
upsertMyAuthorProfile,
type DeckListEntry,
type MarketplaceAuthor,
} from '$lib/api/marketplace.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
let author = $state<MarketplaceAuthor | null>(null);
let decks = $state<DeckListEntry[]>([]);
let loading = $state(true);
let formSlug = $state('');
let formDisplayName = $state('');
let formBio = $state('');
let formPseudonym = $state(false);
let saving = $state(false);
onMount(async () => {
if (!devUser.id) {
await goto('/');
return;
}
await load();
});
async function load() {
loading = true;
try {
author = await getMyAuthorProfile();
if (author) {
formSlug = author.slug;
formDisplayName = author.display_name;
formBio = author.bio ?? '';
formPseudonym = author.pseudonym;
const result = await browseDecks({
author: author.slug,
sort: 'recent',
limit: 50,
});
decks = result.items;
}
} catch (e) {
toasts.error(`Profil laden fehlgeschlagen: ${(e as Error).message}`);
} finally {
loading = false;
}
}
async function onSaveProfile(event: SubmitEvent) {
event.preventDefault();
if (!formSlug.trim() || !formDisplayName.trim()) return;
saving = true;
try {
author = await upsertMyAuthorProfile({
slug: formSlug.trim(),
displayName: formDisplayName.trim(),
bio: formBio || undefined,
pseudonym: formPseudonym,
});
toasts.success('Profil gespeichert.');
if (decks.length === 0) {
const result = await browseDecks({ author: formSlug.trim(), sort: 'recent', limit: 50 });
decks = result.items;
}
} catch (e) {
toasts.error((e as Error).message);
} finally {
saving = false;
}
}
</script>
<svelte:head>
<title>Meine Veröffentlichungen · Cardecky</title>
</svelte:head>
<div class="mx-auto max-w-4xl space-y-8">
<header>
<h1 class="text-2xl font-semibold">Meine Veröffentlichungen</h1>
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))]">
Decks, die du als Marketplace-Author published hast.
</p>
</header>
{#if loading}
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
{:else}
<section
class="rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
>
<h2 class="mb-3 text-lg font-semibold">
{author ? 'Author-Profil' : 'Author-Profil anlegen'}
</h2>
<form class="grid gap-3 sm:grid-cols-2" onsubmit={onSaveProfile}>
<label class="block">
<span class="text-sm font-medium">Slug (url-safe)</span>
<input
type="text"
bind:value={formSlug}
required
pattern="[a-z0-9](?:[a-z0-9-]+[a-z0-9])?"
maxlength="60"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm font-mono"
/>
</label>
<label class="block">
<span class="text-sm font-medium">Anzeige-Name</span>
<input
type="text"
bind:value={formDisplayName}
required
maxlength="80"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
/>
</label>
<label class="block sm:col-span-2">
<span class="text-sm font-medium">Bio (optional)</span>
<textarea
bind:value={formBio}
rows="2"
maxlength="500"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
></textarea>
</label>
<label class="flex items-center gap-2 sm:col-span-2">
<input type="checkbox" bind:checked={formPseudonym} />
<span class="text-sm">Pseudonym-Modus (Klarname versteckt)</span>
</label>
<div class="sm:col-span-2">
<button
type="submit"
disabled={saving || !formSlug || !formDisplayName}
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
>
{saving ? 'Speichere…' : author ? 'Profil aktualisieren' : 'Profil anlegen'}
</button>
</div>
</form>
</section>
{#if author}
<section>
<h2 class="mb-3 text-lg font-semibold">Decks ({decks.length})</h2>
{#if decks.length === 0}
<p
class="rounded-lg border border-dashed border-[hsl(var(--color-border))] p-6 text-center text-sm text-[hsl(var(--color-muted-foreground))]"
>
Noch nichts veröffentlicht. Decks werden über die Marketplace-API initialisiert
(z.B. via Cardecky-Skill); danach erscheinen sie hier.
</p>
{:else}
<ul class="space-y-2">
{#each decks as deck (deck.slug)}
<li class="rounded-lg border border-[hsl(var(--color-border))] p-3">
<div class="flex items-start justify-between gap-3">
<div>
<a
href="/d/{deck.slug}"
class="font-medium hover:text-[hsl(var(--color-primary))]"
>
{deck.title}
</a>
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">
{deck.card_count} Karten · {deck.star_count} ★ · {deck.subscriber_count} ↩︎ ·
{deck.license}
</p>
</div>
{#if deck.is_featured}
<span
class="shrink-0 rounded bg-[hsl(var(--color-primary))]/15 px-2 py-1 text-xs text-[hsl(var(--color-primary))]"
>
★ Featured
</span>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</section>
{/if}
{/if}
</div>

View file

@ -0,0 +1,92 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { getMySubscriptions, type SubscriptionEntry } from '$lib/api/marketplace.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
let items = $state<SubscriptionEntry[]>([]);
let loading = $state(true);
onMount(async () => {
if (!devUser.id) {
await goto('/');
return;
}
try {
const result = await getMySubscriptions();
items = result.subscriptions;
} catch (e) {
toasts.error(`Subs laden fehlgeschlagen: ${(e as Error).message}`);
} finally {
loading = false;
}
});
</script>
<svelte:head>
<title>Meine Abos · Cardecky</title>
</svelte:head>
<div class="mx-auto max-w-4xl space-y-6">
<header>
<h1 class="text-2xl font-semibold">Meine Abonnements</h1>
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))]">
Decks, die du beobachtest. Wenn der Author eine neue Version publisht, siehst du hier den
Update-Indikator.
</p>
</header>
{#if loading}
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
{:else if items.length === 0}
<div
class="rounded-lg border border-dashed border-[hsl(var(--color-border))] p-8 text-center"
>
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">
Noch keine Abos.
</p>
<a
href="/explore"
class="mt-2 inline-block text-sm text-[hsl(var(--color-primary))] hover:underline"
>
Library durchstöbern →
</a>
</div>
{:else}
<ul class="space-y-2">
{#each items as sub (sub.deck_slug)}
<li class="rounded-lg border border-[hsl(var(--color-border))] p-4">
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<a
href="/d/{sub.deck_slug}"
class="font-medium hover:text-[hsl(var(--color-primary))]"
>
{sub.deck_title}
</a>
{#if sub.deck_description}
<p class="mt-1 line-clamp-2 text-sm text-[hsl(var(--color-muted-foreground))]">
{sub.deck_description}
</p>
{/if}
<p class="mt-2 text-xs text-[hsl(var(--color-muted-foreground))]">
Abonniert seit {new Date(sub.subscribed_at).toLocaleDateString()}
</p>
</div>
{#if sub.update_available}
<a
href="/d/{sub.deck_slug}"
class="shrink-0 rounded bg-[hsl(var(--color-primary))] px-3 py-1 text-xs text-[hsl(var(--color-primary-foreground))]"
>
Update verfügbar
</a>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</div>

View file

@ -0,0 +1,146 @@
<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}