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

@ -1,4 +1,4 @@
import { and, eq } from 'drizzle-orm';
import { and, eq, isNotNull } from 'drizzle-orm';
import { Hono } from 'hono';
import { DeckCreateSchema, DeckUpdateSchema } from '@cards/domain';
@ -48,7 +48,15 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
r.get('/', async (c) => {
const userId = c.get('userId');
const rows = await dbOf().select().from(decks).where(eq(decks.userId, userId));
const forkedFromMarketplace = c.req.query('forked_from_marketplace');
const conditions = [eq(decks.userId, userId)];
if (forkedFromMarketplace === 'true') {
conditions.push(isNotNull(decks.forkedFromMarketplaceDeckId));
}
const rows = await dbOf()
.select()
.from(decks)
.where(and(...conditions));
return c.json({ decks: rows.map(toDeckDto), total: rows.length });
});
@ -117,6 +125,8 @@ function toDeckDto(row: typeof decks.$inferSelect) {
visibility: row.visibility,
fsrs_settings: row.fsrsSettings,
content_hash: row.contentHash,
forked_from_marketplace_deck_id: row.forkedFromMarketplaceDeckId,
forked_from_marketplace_version_id: row.forkedFromMarketplaceVersionId,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};

View file

@ -12,6 +12,7 @@ import {
publicDeckVersions,
publicDecks,
} from '../../db/schema/index.ts';
import type { AuthorRow } from '../../db/schema/marketplace/index.ts';
import { authMiddleware, type AuthVars } from '../../middleware/auth.ts';
import { optionalAuthMiddleware } from '../../middleware/marketplace/optional-auth.ts';
import { moderateDeckContent } from '../../lib/marketplace/ai-moderation.ts';
@ -113,6 +114,16 @@ function toDeckDto(row: typeof publicDecks.$inferSelect) {
};
}
function toOwnerDto(row: AuthorRow) {
return {
slug: row.slug,
display_name: row.displayName,
verified_mana: row.verifiedMana,
verified_community: row.verifiedCommunity,
pseudonym: row.pseudonym,
};
}
function toVersionDto(row: typeof publicDeckVersions.$inferSelect) {
return {
id: row.id,
@ -140,19 +151,27 @@ export function marketplaceDecksRouter(
const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1);
if (!deck) return c.json({ error: 'not_found' }, 404);
let version: typeof publicDeckVersions.$inferSelect | null = null;
if (deck.latestVersionId) {
const [v] = await db
.select()
.from(publicDeckVersions)
.where(eq(publicDeckVersions.id, deck.latestVersionId))
.limit(1);
version = v ?? null;
}
const [versionAndOwner] = await Promise.all([
deck.latestVersionId
? db
.select()
.from(publicDeckVersions)
.where(eq(publicDeckVersions.id, deck.latestVersionId))
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
]);
const [ownerRow] = await db
.select()
.from(authors)
.where(eq(authors.userId, deck.ownerUserId))
.limit(1);
return c.json({
deck: toDeckDto(deck),
latest_version: version ? toVersionDto(version) : null,
latest_version: versionAndOwner ? toVersionDto(versionAndOwner) : null,
owner: ownerRow ? toOwnerDto(ownerRow) : null,
});
});

View file

@ -1,8 +1,9 @@
import type { Deck, DeckCreate, DeckUpdate } from '@cards/domain';
import { api } from './client.ts';
export function listDecks() {
return api<{ decks: Deck[]; total: number }>('/api/v1/decks');
export function listDecks(opts: { forkedFromMarketplace?: boolean } = {}) {
const qs = opts.forkedFromMarketplace ? '?forked_from_marketplace=true' : '';
return api<{ decks: Deck[]; total: number }>(`/api/v1/decks${qs}`);
}
export function getDeck(id: string) {

View file

@ -194,9 +194,17 @@ export function getTags() {
// ─── Deck (Public) ───────────────────────────────────────────────────
export function getMarketplaceDeck(slug: string) {
return api<{ deck: MarketplaceDeck; latest_version: MarketplaceVersion | null }>(
`/api/v1/marketplace/decks/${slug}`
);
return api<{
deck: MarketplaceDeck;
latest_version: MarketplaceVersion | null;
owner: {
slug: string;
display_name: string;
verified_mana: boolean;
verified_community: boolean;
pseudonym: boolean;
} | null;
}>(`/api/v1/marketplace/decks/${slug}`);
}
export function getMarketplaceVersion(slug: string, semver: string) {

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);

View file

@ -31,6 +31,13 @@
let deck = $state<MarketplaceDeck | null>(null);
let latestVersion = $state<MarketplaceVersion | null>(null);
let owner = $state<{
slug: string;
display_name: string;
verified_mana: boolean;
verified_community: boolean;
pseudonym: boolean;
} | null>(null);
let cards = $state<MarketplaceVersionCard[]>([]);
let discussionCounts = $state<Record<string, number>>({});
let starred = $state(false);
@ -56,6 +63,7 @@
const detail = await getMarketplaceDeck(slug);
deck = detail.deck;
latestVersion = detail.latest_version;
owner = detail.owner;
const [stateChecks, version, counts] = await Promise.all([
myUserId
@ -251,18 +259,17 @@
{/if}
</div>
{#if deck}
{#if owner}
<section>
<header class="mb-3 flex items-center gap-2 text-sm">
<span>Author:</span>
<header class="flex items-center gap-2 text-sm">
<span class="text-[hsl(var(--color-muted-foreground))]">Author:</span>
<AuthorBadge
slug=""
displayName={deck.owner_user_id.slice(0, 12) + '…'}
size="sm"
slug={owner.slug}
displayName={owner.display_name}
verifiedMana={owner.verified_mana}
verifiedCommunity={owner.verified_community}
size="md"
/>
<span class="text-[hsl(var(--color-muted-foreground))]">
(Profil-Slug auf Author-Page sichtbar)
</span>
</header>
</section>
{/if}

View file

@ -7,6 +7,8 @@
type DeckListEntry,
} from '$lib/api/marketplace.ts';
import DeckListGrid from '$lib/components/marketplace/DeckListGrid.svelte';
import EmptyState from '$lib/components/marketplace/EmptyState.svelte';
import SkeletonGrid from '$lib/components/marketplace/SkeletonGrid.svelte';
import { toasts } from '$lib/stores/toasts.svelte.ts';
let featured = $state<DeckListEntry[]>([]);
@ -87,7 +89,10 @@
</header>
{#if loadingExplore}
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade Featured + Trending…</p>
<section>
<h2 class="mb-3 text-xl font-semibold">🔥 Trending</h2>
<SkeletonGrid count={6} />
</section>
{:else}
{#if featured.length > 0}
<section>
@ -101,6 +106,14 @@
<h2 class="mb-3 text-xl font-semibold">🔥 Trending</h2>
<DeckListGrid items={trending} />
</section>
{:else if !loadingExplore && featured.length === 0}
<EmptyState
icon="📚"
title="Library ist noch leer"
description="Bald gibt's hier viele Decks. Wenn du selbst publizieren willst: leg dir ein Author-Profil an."
ctaHref="/me/published"
ctaLabel="Author-Profil anlegen"
/>
{/if}
{/if}
@ -151,7 +164,11 @@
</button>
</form>
<DeckListGrid items={browseResults} emptyMessage="Keine Decks zu deinem Filter." />
{#if loadingBrowse && browseResults.length === 0}
<SkeletonGrid count={6} />
{:else}
<DeckListGrid items={browseResults} emptyMessage="Keine Decks zu deinem Filter." />
{/if}
{#if browseResults.length < browseTotal}
<button

View file

@ -7,6 +7,7 @@
import { pullUpdate } from '$lib/api/marketplace.ts';
import type { Deck } from '@cards/domain';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import EmptyState from '$lib/components/marketplace/EmptyState.svelte';
import { toasts } from '$lib/stores/toasts.svelte.ts';
let forks = $state<Deck[]>([]);
@ -24,14 +25,8 @@
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
);
const result = await listDecks({ forkedFromMarketplace: true });
forks = result.decks;
} catch (e) {
toasts.error(`Forks laden fehlgeschlagen: ${(e as Error).message}`);
} finally {
@ -76,17 +71,13 @@
{#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>
<EmptyState
icon="🔱"
title="Noch nichts geforkt"
description={'Forks geben dir eine eigene Lern-Kopie eines Marketplace-Decks. FSRS-Reviews bleiben bei dir; Updates des Original-Authors kannst du via „Update ziehen“ einspielen.'}
ctaHref="/explore"
ctaLabel="Library durchstöbern"
/>
{:else}
<ul class="space-y-2">
{#each forks as fork (fork.id)}

View file

@ -5,6 +5,7 @@
import { getMySubscriptions, type SubscriptionEntry } from '$lib/api/marketplace.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import EmptyState from '$lib/components/marketplace/EmptyState.svelte';
import { toasts } from '$lib/stores/toasts.svelte.ts';
let items = $state<SubscriptionEntry[]>([]);
@ -42,19 +43,13 @@
{#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>
<EmptyState
icon="↩︎"
title="Noch keine Abos"
description="Subscribe einen Deck im Explore, dann siehst du hier Updates des Authors."
ctaHref="/explore"
ctaLabel="Library durchstöbern"
/>
{:else}
<ul class="space-y-2">
{#each items as sub (sub.deck_slug)}