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:
parent
333581c5ef
commit
578a0a41f7
12 changed files with 856 additions and 178 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue