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