diff --git a/STATUS.md b/STATUS.md index 0145d98..79f8679 100644 --- a/STATUS.md +++ b/STATUS.md @@ -99,6 +99,7 @@ VollstΓ€ndiger Plan: [`mana/docs/playbooks/CARDS_GREENFIELD.md`](../mana/docs/pl | 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–R5 + Polish + 3 Cardecky-Decks live | Plan: [`docs/playbooks/MARKETPLACE_RESTORE.md`](docs/playbooks/MARKETPLACE_RESTORE.md). Stack komplett: R0–R5 + G1-G4. Cardecky-Skill (`~/.claude/skills/cards-deck/SKILL.md`) auf Marketplace-Target + Bulk-Mode. **3 Cardecky-Decks live** (Audit-Trail unter `docs/marketplace/seed/`): `/d/geografie-welt-top30` (30, CC0), `/d/english-a2-grundwortschatz` (500, CC-BY-4.0), `/d/periodensystem-elemente` (118, CC0). 648 Karten gesamt, 1296 FSRS-Reviews, alle <1s atomic publish. CONTENT_PLAN Β§8 Phase-1-Seed-Liste 3/20 done. Verbleibend: 17 weitere Tier-A-Decks, R6 UI-E2E, Anki-Importβ†’Marketplace-Hook. | +| 12-UX | Marketplace-UX-Polish (Subscribe=Fork, Deck-Settings) | βœ… 2026-05-11 | Subscribe forkt automatisch (`forkDeckForUser` extrahiert, von POST /subscribe gerufen). Kein separater Fork-Button mehr. Abonnierte Decks auf der Homepage navigieren zu `/study/{id}` wenn ein Fork existiert. `/decks/[id]/edit` zu vollstΓ€ndiger Settings-Page ausgebaut: Allgemein (Name/Desc/Farbe/Kategorie/Sichtbarkeit), Marketplace (Link + Update-Pull), Danger-Zone (Duplizieren + LΓΆschen). Neue Backend-Endpoints: `GET /decks/:id/marketplace-source`, `POST /decks/:id/duplicate`. | Legende: βœ… erledigt + verifiziert Β· 🚧 blockiert Β· ⏸ noch nicht begonnen @@ -260,6 +261,7 @@ Volle Konventionen: [`CLAUDE.md`](CLAUDE.md) ## Git-Historie ``` +(aktuell) Marketplace-UX-Polish: Subscribe=Fork+Track, Deck-Settings-Page 39b1791 Phase 9l: Image-Occlusion als 4. MVP-CardType c9eb0a6 Phase 9k: Media-Upload via MinIO-Container e7ae93d docs: STATUS.md auf Phase-9-Welle-2-Stand diff --git a/apps/api/src/routes/decks.ts b/apps/api/src/routes/decks.ts index eaa9f13..b348277 100644 --- a/apps/api/src/routes/decks.ts +++ b/apps/api/src/routes/decks.ts @@ -5,7 +5,7 @@ import { Hono } from 'hono'; import { DeckCreateSchema, DeckUpdateSchema } from '@cards/domain'; import { getDb, type CardsDb } from '../db/connection.ts'; -import { cards, decks } from '../db/schema/index.ts'; +import { cards, decks, publicDecks } from '../db/schema/index.ts'; import { authMiddleware, type AuthVars } from '../middleware/auth.ts'; import { toDeckDto } from '../lib/dto.ts'; import { ulid } from '../lib/ulid.ts'; @@ -116,6 +116,75 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }> return c.json({ deleted: id }); }); + /** Gibt den Marketplace-Slug zurΓΌck, aus dem dieses Deck geforkt wurde (oder null). */ + r.get('/:id/marketplace-source', async (c) => { + const userId = c.get('userId'); + const id = c.req.param('id'); + const [row] = await dbOf() + .select({ forkedId: decks.forkedFromMarketplaceDeckId }) + .from(decks) + .where(and(eq(decks.id, id), eq(decks.userId, userId))) + .limit(1); + if (!row) return c.json({ error: 'not_found' }, 404); + if (!row.forkedId) return c.json(null); + const [mp] = await dbOf() + .select({ slug: publicDecks.slug }) + .from(publicDecks) + .where(eq(publicDecks.id, row.forkedId)) + .limit(1); + return c.json(mp ? { slug: mp.slug } : null); + }); + + /** Dupliziert ein Deck (neue IDs, kein FSRS-Verlauf, kein Marketplace-Pointer). */ + r.post('/:id/duplicate', async (c) => { + const userId = c.get('userId'); + const id = c.req.param('id'); + const db = dbOf(); + const [source] = await db + .select() + .from(decks) + .where(and(eq(decks.id, id), eq(decks.userId, userId))) + .limit(1); + if (!source) return c.json({ error: 'not_found' }, 404); + const sourceCards = await db + .select() + .from(cards) + .where(and(eq(cards.deckId, id), eq(cards.userId, userId))); + const newId = ulid(); + const now = new Date(); + const [newDeck] = await db + .insert(decks) + .values({ + id: newId, + userId, + name: `${source.name} (Kopie)`, + description: source.description, + color: source.color, + category: source.category, + visibility: 'private', + fsrsSettings: source.fsrsSettings, + createdAt: now, + updatedAt: now, + }) + .returning(); + if (sourceCards.length > 0) { + await db.insert(cards).values( + sourceCards.map((card) => ({ + id: ulid(), + deckId: newId, + userId, + type: card.type, + fields: card.fields as Record, + mediaRefs: card.mediaRefs, + contentHash: card.contentHash, + createdAt: now, + updatedAt: now, + })), + ); + } + return c.json(toDeckDto(newDeck), 201); + }); + /** * Liefert N zufΓ€llige Feldwerte aus anderen Karten desselben Decks β€” * als Distractors fΓΌr multiple-choice-Karten. diff --git a/apps/api/src/routes/marketplace/fork.ts b/apps/api/src/routes/marketplace/fork.ts index bb4dfb9..513cba4 100644 --- a/apps/api/src/routes/marketplace/fork.ts +++ b/apps/api/src/routes/marketplace/fork.ts @@ -69,6 +69,82 @@ async function loadVersionCards(db: CardsDb, versionId: string) { .orderBy(asc(publicDeckCards.ord)); } +export interface ForkResult { + deck: typeof decks.$inferSelect; + cards_created: number; +} + +/** + * Reusable fork-core: creates a private deck + cards + initial FSRS reviews + * from a public marketplace deck. Idempotency check (existing fork) is the + * caller's responsibility. Returns the new private deck row + card count. + */ +export async function forkDeckForUser( + db: CardsDb, + params: { + userId: string; + sourceDeck: { + id: string; + title: string; + description: string | null; + latestVersionId: string | null; + }; + color?: string; + }, +): Promise { + const { userId, sourceDeck, color } = params; + if (!sourceDeck.latestVersionId) throw new Error('no_published_version'); + const sourceCards = await loadVersionCards(db, sourceDeck.latestVersionId); + const newDeckId = ulid(); + const now = new Date(); + + const newDeck = await db.transaction(async (tx) => { + const [deck] = await tx + .insert(decks) + .values({ + id: newDeckId, + userId, + name: sourceDeck.title, + description: sourceDeck.description, + color: color ?? '#0ea5e9', + visibility: 'private', + fsrsSettings: {}, + forkedFromMarketplaceDeckId: sourceDeck.id, + forkedFromMarketplaceVersionId: sourceDeck.latestVersionId, + createdAt: now, + updatedAt: now, + }) + .returning(); + + for (const sourceCard of sourceCards) { + const cardId = ulid(); + await tx.insert(cards).values({ + id: cardId, + deckId: newDeckId, + userId, + type: sourceCard.type, + fields: sourceCard.fields, + mediaRefs: [], + contentHash: sourceCard.contentHash, + createdAt: now, + updatedAt: now, + }); + const subIndexes = subIndexCountFor( + sourceCard.type, + sourceCard.fields as Record, + ); + const initialReviews = buildInitialReviews(userId, cardId, subIndexes, now); + if (initialReviews.length > 0) { + await tx.insert(reviews).values(initialReviews); + } + } + + return deck; + }); + + return { deck: newDeck, cards_created: sourceCards.length }; +} + function subIndexCountFor(type: string, fields: Record): number { if (type === 'cloze') return subIndexCountForCloze(fields.text ?? ''); if (type === 'image-occlusion') { @@ -122,7 +198,7 @@ export function forkRouter(deps: MarketplaceForkDeps = {}): Hono<{ Variables: Au if (!parsed.success) { return c.json( { error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) }, - 422 + 422, ); } @@ -132,55 +208,14 @@ export function forkRouter(deps: MarketplaceForkDeps = {}): Hono<{ Variables: Au if (sourceDeck.isTakedown) return c.json({ error: 'takedown_active' }, 403); if (!sourceDeck.latestVersionId) return c.json({ error: 'no_published_version' }, 409); - const sourceCards = await loadVersionCards(db, sourceDeck.latestVersionId); - if (sourceCards.length === 0) return c.json({ error: 'empty_version' }, 409); + // Empty-Version-Check before committing + const preCheck = await loadVersionCards(db, sourceDeck.latestVersionId); + if (preCheck.length === 0) return c.json({ error: 'empty_version' }, 409); - const newDeckId = ulid(); - const now = new Date(); - - const newDeck = await db.transaction(async (tx) => { - const [deck] = await tx - .insert(decks) - .values({ - id: newDeckId, - userId, - name: sourceDeck.title, - description: sourceDeck.description, - color: parsed.data.color ?? '#0ea5e9', // sky-500 β€” Fork-Default - visibility: 'private', - fsrsSettings: {}, - forkedFromMarketplaceDeckId: sourceDeck.id, - forkedFromMarketplaceVersionId: sourceDeck.latestVersionId, - createdAt: now, - updatedAt: now, - }) - .returning(); - - for (const sourceCard of sourceCards) { - const cardId = ulid(); - await tx.insert(cards).values({ - id: cardId, - deckId: newDeckId, - userId, - type: sourceCard.type, - fields: sourceCard.fields, - mediaRefs: [], - contentHash: sourceCard.contentHash, - createdAt: now, - updatedAt: now, - }); - - const subIndexes = subIndexCountFor( - sourceCard.type, - sourceCard.fields as Record - ); - const initialReviews = buildInitialReviews(userId, cardId, subIndexes, now); - if (initialReviews.length > 0) { - await tx.insert(reviews).values(initialReviews); - } - } - - return deck; + const { deck: newDeck, cards_created } = await forkDeckForUser(db, { + userId, + sourceDeck, + color: parsed.data.color, }); return c.json( @@ -193,9 +228,9 @@ export function forkRouter(deps: MarketplaceForkDeps = {}): Hono<{ Variables: Au forked_from_marketplace_deck_id: newDeck.forkedFromMarketplaceDeckId, forked_from_marketplace_version_id: newDeck.forkedFromMarketplaceVersionId, }, - cards_created: sourceCards.length, + cards_created, }, - 201 + 201, ); }); diff --git a/apps/api/src/routes/marketplace/subscriptions.ts b/apps/api/src/routes/marketplace/subscriptions.ts index 489695c..1cc7cae 100644 --- a/apps/api/src/routes/marketplace/subscriptions.ts +++ b/apps/api/src/routes/marketplace/subscriptions.ts @@ -3,6 +3,7 @@ import { Hono } from 'hono'; import { getDb, type CardsDb } from '../../db/connection.ts'; import { + decks, deckSubscriptions, publicDeckCards, publicDeckVersions, @@ -11,6 +12,7 @@ import { import { authMiddleware, type AuthVars } from '../../middleware/auth.ts'; import { optionalAuthMiddleware } from '../../middleware/marketplace/optional-auth.ts'; import { computeDiff } from '../../lib/marketplace/diff.ts'; +import { forkDeckForUser } from './fork.ts'; /** * Subscriptions + Version-Read + Smart-Merge-Diff. @@ -90,13 +92,30 @@ export function subscriptionsRouter( set: { currentVersionId: deck.latestVersionId }, }); + // Auto-fork: falls noch kein privates Deck fΓΌr diesen Nutzer+Marketplace-Deck + // existiert, wird eines erstellt. Abonnieren = Bibliothek hinzufΓΌgen. + const [existingFork] = await db + .select({ id: decks.id }) + .from(decks) + .where(and(eq(decks.userId, userId), eq(decks.forkedFromMarketplaceDeckId, deck.id))) + .limit(1); + + let privateDeckId: string; + if (existingFork) { + privateDeckId = existingFork.id; + } else { + const { deck: forked } = await forkDeckForUser(db, { userId, sourceDeck: deck }); + privateDeckId = forked.id; + } + return c.json( { subscribed: true, deck_slug: slug, current_version_id: deck.latestVersionId, + private_deck_id: privateDeckId, }, - 201 + 201, ); }); @@ -149,13 +168,30 @@ export function subscriptionsRouter( const slug = c.req.param('slug'); const db = dbOf(); const rows = await db - .select({ id: deckSubscriptions.deckId, currentVersionId: deckSubscriptions.currentVersionId }) + .select({ + marketplaceDeckId: deckSubscriptions.deckId, + currentVersionId: deckSubscriptions.currentVersionId, + }) .from(deckSubscriptions) .innerJoin(publicDecks, eq(publicDecks.id, deckSubscriptions.deckId)) .where(and(eq(deckSubscriptions.userId, userId), eq(publicDecks.slug, slug))) .limit(1); - if (rows.length === 0) return c.json({ subscribed: false }); - return c.json({ subscribed: true, current_version_id: rows[0].currentVersionId }); + if (rows.length === 0) return c.json({ subscribed: false, private_deck_id: null }); + const [fork] = await db + .select({ id: decks.id }) + .from(decks) + .where( + and( + eq(decks.userId, userId), + eq(decks.forkedFromMarketplaceDeckId, rows[0].marketplaceDeckId), + ), + ) + .limit(1); + return c.json({ + subscribed: true, + current_version_id: rows[0].currentVersionId, + private_deck_id: fork?.id ?? null, + }); }); // ─── Public-Read: Version + Diff ───────────────────────────────── diff --git a/apps/web/src/lib/api/decks.ts b/apps/web/src/lib/api/decks.ts index 1117ab5..cffa065 100644 --- a/apps/web/src/lib/api/decks.ts +++ b/apps/web/src/lib/api/decks.ts @@ -22,6 +22,14 @@ export function deleteDeck(id: string) { return api<{ deleted: string }>(`/api/v1/decks/${id}`, { method: 'DELETE' }); } +export function duplicateDeck(id: string) { + return api(`/api/v1/decks/${id}/duplicate`, { method: 'POST' }); +} + +export function getMarketplaceSource(id: string) { + return api<{ slug: string } | null>(`/api/v1/decks/${id}/marketplace-source`); +} + export function generateDeck(input: { prompt: string; language?: 'de' | 'en'; count?: number; url?: string }) { return api<{ deck: Deck; cards_created: number }>('/api/v1/decks/generate', { method: 'POST', diff --git a/apps/web/src/lib/api/marketplace.ts b/apps/web/src/lib/api/marketplace.ts index ba06f79..7675e52 100644 --- a/apps/web/src/lib/api/marketplace.ts +++ b/apps/web/src/lib/api/marketplace.ts @@ -306,10 +306,12 @@ export function getFollowState(slug: string) { // ─── 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' } - ); + return api<{ + subscribed: true; + deck_slug: string; + current_version_id: string; + private_deck_id: string; + }>(`/api/v1/marketplace/decks/${slug}/subscribe`, { method: 'POST' }); } export function unsubscribe(slug: string) { @@ -319,9 +321,11 @@ export function unsubscribe(slug: string) { } export function getSubscribeState(slug: string) { - return api<{ subscribed: boolean; current_version_id?: string }>( - `/api/v1/marketplace/decks/${slug}/subscribe` - ); + return api<{ + subscribed: boolean; + current_version_id?: string; + private_deck_id?: string | null; + }>(`/api/v1/marketplace/decks/${slug}/subscribe`); } export function getMySubscriptions() { diff --git a/apps/web/src/lib/components/marketplace/DeckListGrid.svelte b/apps/web/src/lib/components/marketplace/DeckListGrid.svelte index 9cbf0d0..56be6ef 100644 --- a/apps/web/src/lib/components/marketplace/DeckListGrid.svelte +++ b/apps/web/src/lib/components/marketplace/DeckListGrid.svelte @@ -5,9 +5,10 @@ interface Props { items: DeckListEntry[]; emptyMessage?: string; + getHref?: (slug: string) => string | undefined; } - const { items, emptyMessage = 'Noch keine Decks gefunden.' }: Props = $props(); + const { items, emptyMessage = 'Noch keine Decks gefunden.', getHref }: Props = $props(); {#if items.length === 0} @@ -18,7 +19,7 @@
    {#each items as deck (deck.slug)}
  • - +
  • {/each}
diff --git a/apps/web/src/lib/components/marketplace/MarketplaceDeckStack.svelte b/apps/web/src/lib/components/marketplace/MarketplaceDeckStack.svelte index f34e738..e191dcc 100644 --- a/apps/web/src/lib/components/marketplace/MarketplaceDeckStack.svelte +++ b/apps/web/src/lib/components/marketplace/MarketplaceDeckStack.svelte @@ -9,9 +9,10 @@ interface Props { deck: DeckListEntry; + href?: string; } - let { deck }: Props = $props(); + let { deck, href }: Props = $props(); const layers = $derived(stackLayers(deck.slug, 3)); @@ -55,7 +56,7 @@ >({}); let starred = $state(false); let subscribed = $state(false); + let privateDeckId = $state(null); let loading = $state(true); let busy = $state(false); @@ -77,7 +77,9 @@ ]); starred = (stateChecks as [{ starred: boolean }, unknown])[0].starred; - subscribed = (stateChecks as [unknown, { subscribed: boolean }])[1].subscribed; + const subState = (stateChecks as [unknown, { subscribed: boolean; private_deck_id?: string | null }])[1]; + subscribed = subState.subscribed; + privateDeckId = subState.private_deck_id ?? null; cards = version; discussionCounts = counts; } catch (e) { @@ -108,39 +110,30 @@ } } - async function toggleSubscribe() { + async function onSubscribe() { 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.'); - } + const result = await subscribe(slug); + subscribed = true; + privateDeckId = result.private_deck_id; + await goto(`/decks/${result.private_deck_id}`); } catch (e) { toasts.error(apiErrorMessage(e)); - } finally { busy = false; } } - async function onFork() { - if (!myUserId) { - toasts.error('Bitte einloggen.'); - return; - } + async function onUnsubscribe() { + if (!myUserId) return; busy = true; try { - const result = await forkDeck(slug); - toasts.success(`Deck geforkt β€” ${result.cards_created} Karten kopiert.`); - await goto(`/decks/${result.deck.id}`); + await unsubscribe(slug); + subscribed = false; + toasts.success('Abo gekΓΌndigt. Deine Lernkopie bleibt erhalten.'); } catch (e) { toasts.error(apiErrorMessage(e)); } finally { @@ -230,24 +223,38 @@ > {starred ? 'β˜… Gestarred' : 'β˜† Star'} - - + + {#if subscribed} + {#if privateDeckId} + + βœ“ In meiner Bibliothek β†’ + + {:else} + + βœ“ Abonniert + + {/if} + + {:else} + + {/if} {#if isOwner} + {#if categoryOpen} +
+ {#each DECK_CATEGORY_IDS as id} + + {/each} +
+ {/if} + + + +
+ Sichtbarkeit + +
+ +
+ + Abbrechen +
+ + + + + {#if isForked} +
+

Marketplace

+
+ {#if marketplaceLoading} +

Lade Marketplace-Info…

+ {:else if marketplaceSlug} + + + Original im Marketplace ansehen + + + {#if updateAvailable} +
+ + Update verfΓΌgbar + +
+ {:else} +

βœ“ Auf dem neuesten Stand

+ {/if} + {:else} +

Marketplace-Quelle nicht mehr verfΓΌgbar.

+ {/if} +
+
+ {/if} + + +
+

Weitere Aktionen

+
+
+
+ Duplizieren + Erstellt eine unabhΓ€ngige Kopie ohne Lernfortschritt. +
+ +
+ +
+ +
+
+ Deck lΓΆschen + LΓΆscht alle Karten und Lernfortschritte unwiderruflich. +
+
- -
- - {t('deck_edit.cancel')} -
- - + {/if} diff --git a/packages/cards-domain/src/schemas/deck.ts b/packages/cards-domain/src/schemas/deck.ts index eb492ba..3cfaafc 100644 --- a/packages/cards-domain/src/schemas/deck.ts +++ b/packages/cards-domain/src/schemas/deck.ts @@ -50,6 +50,8 @@ export const DeckSchema = z visibility: VisibilitySchema.default('private'), fsrs_settings: FsrsSettingsSchema.default({}), content_hash: z.string().optional().nullable(), + forked_from_marketplace_deck_id: z.string().optional().nullable(), + forked_from_marketplace_version_id: z.string().optional().nullable(), created_at: z.string().datetime(), updated_at: z.string().datetime(), })