Marketplace-UX: Subscribe=Fork, Deck-Settings-Page, Duplicate/Delete

**Subscribe vereinfacht zu einer Aktion:**
- POST /marketplace/decks/:slug/subscribe forkt automatisch wenn noch
  kein privater Fork für diesen User+Deck existiert (forkDeckForUser
  aus fork.ts extrahiert und von subscriptions.ts importiert).
- GET /subscribe gibt jetzt auch private_deck_id zurück.
- Fork-Button auf /d/[slug] entfernt; stattdessen:
  - Nicht abonniert: "Zu meiner Bibliothek hinzufügen" → subscribe+fork+navigate
  - Abonniert: "✓ In meiner Bibliothek →" Link + "Abo kündigen" Button
- Abonnierte Decks auf /decks (Homepage) navigieren zu /study/{id}
  wenn ein Fork existiert (slug→studyHref via $derived cross-reference).

**Deck-Settings-Page (/decks/[id]/edit) komplett neu:**
- Allgemein: Name, Beschreibung, Farbe, Kategorie-Picker, Sichtbarkeit
- Marketplace (nur für Forks): Link zum Original, Update-ziehen-Banner
- Gefahrenzone: Duplizieren (neue Kopie ohne FSRS-Verlauf) + Löschen

**Neue Backend-Endpoints (apps/api/src/routes/decks.ts):**
- GET /decks/:id/marketplace-source → { slug } des Marketplace-Originals
- POST /decks/:id/duplicate → kopiert Deck + Karten, neues visibility=private

**Domain-Schema:**
- Deck-Schema um forked_from_marketplace_deck_id/_version_id erweitert
  (Backend sendet sie bereits, waren untyped im Frontend).

**Komponenten:**
- MarketplaceDeckStack: optionaler href-Prop überschreibt /d/{slug}
- DeckListGrid: optionaler getHref-Prop gibt href per Slug zurück

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-11 14:03:49 +02:00
parent 333581c5ef
commit 578a0a41f7
12 changed files with 856 additions and 178 deletions

View file

@ -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<string, string>,
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.

View file

@ -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<ForkResult> {
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<string, string>,
);
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<string, string>): 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<string, string>
);
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,
);
});

View file

@ -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 ─────────────────────────────────