Phase 12 G1-G4: Marketplace-Polish — svelte-ignore + Skeleton/Empty-State + Server-Filter + Owner-Info

G1 — svelte-ignore für 5 benigne Init-Capture-Warnings:
- PublishVersionModal: state(latestSemver ? bumpMinor(latestSemver) : '1.0.0')
  ist intentional, weil das Modal pro Click frisch gemountet wird
- SuggestEditModal: state(card.fields.front…) + state({ ...card.fields })
  gleicher Lebenszyklus
Kein Refactor auf $derived, weil das die Bind-Semantik kaputtmachen
würde — Direktive plus ein Kommentar reicht.

G2 — Loading + Empty-States:
- Neue Components SkeletonGrid + EmptyState in lib/components/marketplace/
- /explore: SkeletonGrid statt „Lade Featured + Trending…"-String,
  EmptyState wenn weder Featured noch Trending da
- /me/subscribed + /me/forks: EmptyState statt inline-Box
- Konsistentes Vereins-Vokabular (icon + Title + Description + CTA)

G3 — Server-side Fork-Filter:
- GET /api/v1/decks akzeptiert ?forked_from_marketplace=true
- Drizzle isNotNull-Filter auf decks.forked_from_marketplace_deck_id
- toDeckDto exposed jetzt forked_from_marketplace_{deck,version}_id
  (vorher schwiegen die Spalten, mussten client-side via Cast
  rausgefischt werden)
- /me/forks ruft listDecks({ forkedFromMarketplace: true }) statt
  listDecks() + client-side Filter

G4 — Owner-Author-Info im Deck-Detail-Endpoint:
- GET /api/v1/marketplace/decks/:slug returned jetzt zusätzlich
  owner: { slug, display_name, verified_mana, verified_community,
  pseudonym } — gejoint aus marketplace.authors via deck.owner_user_id
- toOwnerDto-Helper, identisches Shape wie in /authors/:slug
- /d/[slug] verbraucht den neuen owner-Block für AuthorBadge mit
  echtem Profil-Link statt user_id-Slice (vorher: kaputter Link
  /u/<empty-slug> + nur „SEAiKLkPZ…" als Display-Name)

Verifikation:
- API: type-check + 89 Tests grün
- Web: svelte-check 0 errors, 0 warnings (von 5 → 0)
- Live-Smoke: GET /marketplace/decks/r5-stoa-grundlagen liefert
  owner={slug:'cardecky', display_name:'Cardecky', verified_*:false}
- ?forked_from_marketplace=true Filter mit Till's JWT liefert 0
  (weil Till keine Forks hat) — 401 ohne JWT bestätigt

Bewusst nicht angefasst: Header-Nav-Link (WIP-Konflikt), Image-
Occlusion in Marketplace (Player-Side komplex), Auth-Guard im
+layout.svelte (page-level guards reichen), Anki-Import→Marketplace-
Publish-Hook (eigene Welle).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-09 16:14:21 +02:00
parent 40861710bf
commit 17871ba2a4
13 changed files with 174 additions and 63 deletions

View file

@ -0,0 +1,33 @@
<script lang="ts">
interface Props {
icon?: string;
title: string;
description?: string;
ctaHref?: string;
ctaLabel?: string;
}
const { icon, title, description, ctaHref, ctaLabel }: Props = $props();
</script>
<div
class="rounded-lg border border-dashed border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))]/40 p-10 text-center"
>
{#if icon}
<div class="mx-auto mb-3 text-4xl" aria-hidden="true">{icon}</div>
{/if}
<h3 class="text-lg font-medium">{title}</h3>
{#if description}
<p class="mx-auto mt-1 max-w-md text-sm text-[hsl(var(--color-muted-foreground))]">
{description}
</p>
{/if}
{#if ctaHref && ctaLabel}
<a
href={ctaHref}
class="mt-4 inline-block text-sm text-[hsl(var(--color-primary))] hover:underline"
>
{ctaLabel}
</a>
{/if}
</div>

View file

@ -17,6 +17,9 @@
return `${m[1]}.${Number(m[2]) + 1}.0`;
}
// Modal-Lebenszyklus: pro Klick frisch gemountet, Props sind invariant.
// Init-Capture aus `latestSemver` ist daher gewollt — keine Re-Reactivity.
// svelte-ignore state_referenced_locally
let semver = $state(latestSemver ? bumpMinor(latestSemver) : '1.0.0');
let changelog = $state('');
let cardsJson = $state('[]');

View file

@ -0,0 +1,24 @@
<script lang="ts">
interface Props {
count?: number;
}
const { count = 6 }: Props = $props();
</script>
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3" aria-busy="true" aria-label="Lade Decks">
{#each Array.from({ length: count }) as _, i (i)}
<li
class="animate-pulse rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
>
<div class="h-4 w-3/4 rounded bg-[hsl(var(--color-border))]"></div>
<div class="mt-2 h-3 w-full rounded bg-[hsl(var(--color-border))]/60"></div>
<div class="mt-1 h-3 w-5/6 rounded bg-[hsl(var(--color-border))]/60"></div>
<div class="mt-3 flex gap-2">
<div class="h-2 w-16 rounded bg-[hsl(var(--color-border))]/40"></div>
<div class="h-2 w-12 rounded bg-[hsl(var(--color-border))]/40"></div>
<div class="h-2 w-20 rounded bg-[hsl(var(--color-border))]/40"></div>
</div>
</li>
{/each}
</ul>

View file

@ -13,9 +13,12 @@
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.
// Modal-Lebenszyklus: pro Klick frisch gemountet, `card`-Prop ist
// invariant. Init-Capture aus card.fields ist daher gewollt.
// svelte-ignore state_referenced_locally
let title = $state(`„${(card.fields.front ?? card.fields.text ?? '').slice(0, 60)}" verbessern`);
// svelte-ignore state_referenced_locally
let editedFields = $state<Record<string, string>>({ ...card.fields });
let busy = $state(false);
let error = $state<string | null>(null);