diff --git a/STATUS.md b/STATUS.md index a95a24a..819b71f 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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 (9a–9l). 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 (R0–R6) | 🟡 R0+R1+R2+R3+R4+R5 durch | Plan: [`docs/playbooks/MARKETPLACE_RESTORE.md`](docs/playbooks/MARKETPLACE_RESTORE.md). R0–R4 (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). | +| 12 | Marketplace-Restore (R0–R6) | 🟡 R0+R1+R2+R3+R4+R5+G1-G4 durch | Plan: [`docs/playbooks/MARKETPLACE_RESTORE.md`](docs/playbooks/MARKETPLACE_RESTORE.md). R0–R4 (Backend): ✅. R5 (Frontend-Routes): ✅. **G1-G4 (Polish-Pass): ✅** — G1 svelte-ignore für 5 benigne Modal-Init-Capture-Warnings (Modals werden pro Click gemountet, nicht-reactive ist gewollt), G2 Loading-Skeleton + EmptyState als Shared Components in /explore und /me/{subscribed,forks} (statt nackter „Lade…"-Strings), G3 Server-side Filter `GET /api/v1/decks?forked_from_marketplace=true` (vorher client-side filtering — funktional bei <100 Decks egal, jetzt sauber), G4 Owner-Author-Info im Deck-Detail-Endpoint (`GET /api/v1/marketplace/decks/:slug` returned jetzt owner.{slug, display_name, verified_mana, verified_community, pseudonym}, /d/[slug] zeigt korrekt verlinkten AuthorBadge statt user-id-prefix). svelte-check: 4019 Files, 0 errors, 0 warnings. 89 API-Tests grün. Bewusst nicht angefasst: Header-Nav-Link auf `/explore` (Header.svelte ist in Tills uncommitted WIP), Image-Occlusion/Audio in Marketplace (Image-Occlusion-Schema ja, Player-Side später), Auth-Guard im +layout.svelte (page-level guards in /me/*-Pages reichen). Verbleibend: R6 voller UI-E2E im Browser (Cardecky-Publish + Till-Subscribe + Till-Fork + Till-PR + Cardecky-Merge + Till-Pull-Update mit FSRS-Erhalt-Verifikation), Anki-Import→Marketplace-Publish-Hook (eigene Welle). | Legende: ✅ erledigt + verifiziert · 🚧 blockiert · ⏸ noch nicht begonnen diff --git a/apps/api/src/routes/decks.ts b/apps/api/src/routes/decks.ts index ad83023..10d684e 100644 --- a/apps/api/src/routes/decks.ts +++ b/apps/api/src/routes/decks.ts @@ -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(), }; diff --git a/apps/api/src/routes/marketplace/decks.ts b/apps/api/src/routes/marketplace/decks.ts index 6659301..c66aeb4 100644 --- a/apps/api/src/routes/marketplace/decks.ts +++ b/apps/api/src/routes/marketplace/decks.ts @@ -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, }); }); diff --git a/apps/web/src/lib/api/decks.ts b/apps/web/src/lib/api/decks.ts index ecea53f..b84e52b 100644 --- a/apps/web/src/lib/api/decks.ts +++ b/apps/web/src/lib/api/decks.ts @@ -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) { diff --git a/apps/web/src/lib/api/marketplace.ts b/apps/web/src/lib/api/marketplace.ts index 40d01c1..4c2f5fb 100644 --- a/apps/web/src/lib/api/marketplace.ts +++ b/apps/web/src/lib/api/marketplace.ts @@ -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) { diff --git a/apps/web/src/lib/components/marketplace/EmptyState.svelte b/apps/web/src/lib/components/marketplace/EmptyState.svelte new file mode 100644 index 0000000..7beed32 --- /dev/null +++ b/apps/web/src/lib/components/marketplace/EmptyState.svelte @@ -0,0 +1,33 @@ + + +
+ {description} +
+ {/if} + {#if ctaHref && ctaLabel} + + {ctaLabel} → + + {/if} +Lade Featured + Trending…
+