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

@ -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 (R0R6) | 🟢 R0R5 + Polish + 3 Cardecky-Decks live | Plan: [`docs/playbooks/MARKETPLACE_RESTORE.md`](docs/playbooks/MARKETPLACE_RESTORE.md). Stack komplett: R0R5 + 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-ImportMarketplace-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

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,
const { deck: newDeck, cards_created } = await forkDeckForUser(db, {
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;
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 ─────────────────────────────────

View file

@ -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<Deck>(`/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',

View file

@ -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() {

View file

@ -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();
</script>
{#if items.length === 0}
@ -18,7 +19,7 @@
<ul class="deck-row" aria-label="Decks">
{#each items as deck (deck.slug)}
<li class="deck-item">
<MarketplaceDeckStack {deck} />
<MarketplaceDeckStack {deck} href={getHref?.(deck.slug)} />
</li>
{/each}
</ul>

View file

@ -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 @@
<CardSurface
size="md"
as="a"
href="/d/{deck.slug}"
href={href ?? `/d/${deck.slug}`}
ariaLabel="{deck.title} · {deck.card_count} Karten"
colorAccent={accentColor()}
class="cover"

View file

@ -5,7 +5,6 @@
import { goto } from '$app/navigation';
import {
forkDeck,
getMarketplaceDeck,
getMarketplaceVersion,
getDiscussionCounts,
@ -43,6 +42,7 @@
let discussionCounts = $state<Record<string, number>>({});
let starred = $state(false);
let subscribed = $state(false);
let privateDeckId = $state<string | null>(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);
const result = await subscribe(slug);
subscribed = true;
toasts.success('Abonniert. Update-Benachrichtigung an.');
}
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'}
</button>
{#if subscribed}
{#if privateDeckId}
<a
href="/decks/{privateDeckId}"
class="rounded bg-[hsl(var(--color-primary))] px-3 py-2 text-sm text-[hsl(var(--color-primary-foreground))]"
>
✓ In meiner Bibliothek →
</a>
{:else}
<span class="rounded border border-[hsl(var(--color-border))] px-3 py-2 text-sm opacity-60">
✓ Abonniert
</span>
{/if}
<button
type="button"
class="rounded border border-[hsl(var(--color-border))] px-3 py-2 text-sm hover:bg-[hsl(var(--color-card))]"
onclick={toggleSubscribe}
disabled={busy || !latestVersion}
aria-pressed={subscribed}
class="rounded border border-[hsl(var(--color-border))] px-3 py-2 text-sm hover:bg-[hsl(var(--color-card))] disabled:opacity-50"
onclick={onUnsubscribe}
disabled={busy}
>
{subscribed ? '↩︎ Abonniert' : '↩︎ Abonnieren'}
Abo kündigen
</button>
{:else}
<button
type="button"
class="rounded bg-[hsl(var(--color-primary))] px-3 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
onclick={onFork}
onclick={onSubscribe}
disabled={busy || !latestVersion || !myUserId}
title="Karten in eigenes privates Deck kopieren — eigener Lern-Stand"
>
🔱 Fork
{busy ? 'Wird hinzugefügt…' : 'Zu meiner Bibliothek hinzufügen'}
</button>
{/if}
{#if isOwner}
<button

View file

@ -25,12 +25,28 @@
dueCount: number;
}
interface SubscriptionItem {
entry: DeckListEntry;
marketplaceId: string;
}
let decks = $state<DeckWithCounts[]>([]);
let subscriptions = $state<DeckListEntry[]>([]);
let subscriptions = $state<SubscriptionItem[]>([]);
let loadingOwn = $state(true);
let loadingSubs = $state(true);
let selectedId = $state<string | null>(null);
// For each subscribed deck that the user has also forked, point directly to study mode.
const subscriptionStudyHrefs = $derived(
subscriptions.reduce((map, sub) => {
const forked = decks.find(
(d) => d.deck.forked_from_marketplace_deck_id === sub.marketplaceId,
);
if (forked) map.set(sub.entry.slug, `/study/${forked.deck.id}`);
return map;
}, new Map<string, string>()),
);
onMount(async () => {
if (!devUser.id) {
goto('/');
@ -65,7 +81,7 @@
async function loadSubscriptions() {
try {
const { subscriptions: subs } = await getMySubscriptions();
const entries = await Promise.all(
const items = await Promise.all(
subs.map(async (sub) => {
try {
const { deck, latest_version, owner } = await getMarketplaceDeck(sub.deck_slug);
@ -96,13 +112,13 @@
verified_community: false as boolean,
},
};
return entry;
return { entry, marketplaceId: deck.id } satisfies SubscriptionItem;
} catch {
return null;
}
}),
);
subscriptions = entries.filter((e): e is DeckListEntry => e !== null);
subscriptions = items.filter((e): e is SubscriptionItem => e !== null);
} finally {
loadingSubs = false;
}
@ -177,7 +193,10 @@
{#if loadingSubs}
<SkeletonGrid count={4} />
{:else}
<DeckListGrid items={subscriptions} />
<DeckListGrid
items={subscriptions.map((s) => s.entry)}
getHref={(slug) => subscriptionStudyHrefs.get(slug)}
/>
{/if}
</section>
{/if}

View file

@ -2,27 +2,61 @@
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { getDeck, updateDeck } from '$lib/api/decks.ts';
import type { Deck } from '@cards/domain';
import { type DeckCategoryId, DECK_CATEGORY_IDS, DECK_CATEGORY_LABELS } from '@cards/domain';
import {
getDeck,
updateDeck,
deleteDeck,
duplicateDeck,
getMarketplaceSource,
} from '$lib/api/decks.ts';
import { pullUpdate, getMarketplaceDeck } from '$lib/api/marketplace.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
import DeckCategoryIcon from '$lib/components/DeckCategoryIcon.svelte';
import { ArrowSquareOut, CaretRight, Copy, Trash } from '@mana/shared-icons';
const deckId = $derived(page.params.id ?? '');
let deck = $state<Deck | null>(null);
let name = $state('');
let description = $state('');
let color = $state('#6366f1');
let category = $state<DeckCategoryId | null>(null);
let visibility = $state<'private' | 'space' | 'public'>('private');
let loading = $state(true);
let saving = $state(false);
let deleting = $state(false);
let duplicating = $state(false);
let pullingUpdate = $state(false);
let categoryOpen = $state(false);
let marketplaceSlug = $state<string | null>(null);
let marketplaceLoading = $state(false);
let updateAvailable = $state(false);
const canSave = $derived(!saving && name.trim().length > 0);
const isForked = $derived(!!deck?.forked_from_marketplace_deck_id);
onMount(async () => {
if (!devUser.id) { goto('/'); return; }
if (!devUser.id) {
goto('/');
return;
}
try {
const deck = await getDeck(deckId);
name = deck.name;
description = deck.description ?? '';
color = deck.color ?? '#6366f1';
const d = await getDeck(deckId);
deck = d;
name = d.name;
description = d.description ?? '';
color = d.color ?? '#6366f1';
category = (d.category as DeckCategoryId | null) ?? null;
visibility = (d.visibility as 'private' | 'space' | 'public') ?? 'private';
if (d.forked_from_marketplace_deck_id) {
void loadMarketplaceInfo(d);
}
} catch (e) {
toasts.error(apiErrorMessage(e));
goto(`/decks/${deckId}`);
@ -31,9 +65,29 @@
}
});
const canSave = $derived(!saving && name.trim().length > 0);
async function loadMarketplaceInfo(d: Deck) {
marketplaceLoading = true;
try {
const source = await getMarketplaceSource(deckId);
if (!source) return;
marketplaceSlug = source.slug;
const { latest_version } = await getMarketplaceDeck(source.slug);
updateAvailable =
!!latest_version &&
latest_version.id !== d.forked_from_marketplace_version_id;
} catch {
// marketplace info is best-effort
} finally {
marketplaceLoading = false;
}
}
async function onSubmit(e: SubmitEvent) {
function onSetCategory(id: DeckCategoryId) {
category = category === id ? null : id;
categoryOpen = false;
}
async function onSave(e: SubmitEvent) {
e.preventDefault();
if (!canSave) return;
saving = true;
@ -42,25 +96,80 @@
name: name.trim(),
description: description.trim() || undefined,
color,
category: category ?? undefined,
visibility,
});
toasts.success(t('deck_edit.saved'));
toasts.success('Gespeichert.');
goto(`/decks/${deckId}`);
} catch (e) {
toasts.error(t('deck_edit.save_failed', { msg: apiErrorMessage(e) }));
toasts.error(apiErrorMessage(e));
saving = false;
}
}
async function onPullUpdate() {
pullingUpdate = true;
try {
const result = await pullUpdate(deckId);
if (result.up_to_date) {
toasts.success('Schon auf dem neuesten Stand.');
updateAvailable = false;
} else {
toasts.success(
`Update eingespielt: +${result.added} neu, ~${result.changed} geändert, ${result.removed} entfernt.`,
);
updateAvailable = false;
deck = await getDeck(deckId);
}
} catch (e) {
toasts.error(apiErrorMessage(e));
} finally {
pullingUpdate = false;
}
}
async function onDuplicate() {
duplicating = true;
try {
const newDeck = await duplicateDeck(deckId);
toasts.success('Deck dupliziert.');
goto(`/decks/${newDeck.id}`);
} catch (e) {
toasts.error(apiErrorMessage(e));
duplicating = false;
}
}
async function onDelete() {
if (
!confirm(
`Deck „${deck?.name}" wirklich löschen?\n\nAlle Karten und Lernfortschritte werden unwiderruflich gelöscht.`,
)
)
return;
deleting = true;
try {
await deleteDeck(deckId);
toasts.success('Deck gelöscht.');
goto('/decks');
} catch (e) {
toasts.error(apiErrorMessage(e));
deleting = false;
}
}
</script>
{#if !loading}
{#if !loading && deck}
<div class="page-shell">
<a href="/decks/{deckId}" class="back-link">{t('deck_edit.back')}</a>
<h1 class="page-title">{t('deck_edit.title')}</h1>
<a href="/decks/{deckId}" class="back-link">← Zurück zum Deck</a>
<h1 class="page-title">Einstellungen</h1>
<form class="edit-form" onsubmit={onSubmit}>
<section class="form-section">
<!-- ── 1. Allgemein ────────────────────────────────────────────── -->
<section class="settings-section">
<h2 class="section-title">Allgemein</h2>
<form class="section-body" onsubmit={onSave}>
<label class="field">
<span class="field-label">{t('deck_edit.name_label')}</span>
<span class="field-label">Name</span>
<input
type="text"
bind:value={name}
@ -72,7 +181,7 @@
</label>
<label class="field">
<span class="field-label">{t('deck_edit.description_label')}</span>
<span class="field-label">Beschreibung</span>
<textarea
bind:value={description}
rows="3"
@ -82,22 +191,141 @@
</label>
<div class="field">
<span class="field-label">{t('deck_edit.color_label')}</span>
<span class="field-label">Farbe</span>
<div class="color-row">
<input type="color" bind:value={color} class="color-input" />
<span class="color-value">{color}</span>
<span class="color-preview" style="background:{color}" aria-hidden="true"></span>
<span class="color-dot" style="background:{color}" aria-hidden="true"></span>
</div>
</div>
<div class="field">
<span class="field-label">Kategorie</span>
<div class="category-picker">
<button
type="button"
class="cat-trigger"
class:has-category={!!category}
onclick={() => (categoryOpen = !categoryOpen)}
aria-expanded={categoryOpen}
>
{#if category}
<DeckCategoryIcon {category} size={16} color={color} weight="duotone" />
<span>{DECK_CATEGORY_LABELS[category]}</span>
{:else}
<span class="cat-placeholder">Keine Kategorie</span>
{/if}
<span class="cat-chevron" class:open={categoryOpen}></span>
</button>
{#if categoryOpen}
<div class="category-grid">
{#each DECK_CATEGORY_IDS as id}
<button
type="button"
class="category-btn"
class:selected={category === id}
onclick={() => onSetCategory(id)}
title={DECK_CATEGORY_LABELS[id]}
>
<DeckCategoryIcon
category={id}
size={18}
color={category === id ? (color ?? null) : null}
weight={category === id ? 'fill' : 'regular'}
/>
<span class="category-label">{DECK_CATEGORY_LABELS[id]}</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="field">
<span class="field-label">Sichtbarkeit</span>
<select bind:value={visibility} class="input select">
<option value="private">Privat — nur ich</option>
<option value="space">Space — mein Team</option>
<option value="public">Öffentlich</option>
</select>
</div>
<div class="form-actions">
<button type="submit" disabled={!canSave} class="btn-primary">
{saving ? 'Speichern…' : 'Speichern'}
</button>
<a href="/decks/{deckId}" class="btn-ghost">Abbrechen</a>
</div>
</form>
</section>
<!-- ── 2. Marketplace (nur für Forks) ─────────────────────────── -->
{#if isForked}
<section class="settings-section">
<h2 class="section-title">Marketplace</h2>
<div class="section-body marketplace-body">
{#if marketplaceLoading}
<p class="meta-text">Lade Marketplace-Info…</p>
{:else if marketplaceSlug}
<a href="/d/{marketplaceSlug}" class="marketplace-link">
<ArrowSquareOut size={15} weight="duotone" />
Original im Marketplace ansehen
</a>
{#if updateAvailable}
<div class="update-banner">
<span class="update-dot" aria-hidden="true"></span>
<span class="update-text">Update verfügbar</span>
<button
type="button"
class="btn-secondary"
onclick={onPullUpdate}
disabled={pullingUpdate}
>
{pullingUpdate ? 'Ziehe Update…' : 'Update ziehen'}
</button>
</div>
{:else}
<p class="meta-text up-to-date">✓ Auf dem neuesten Stand</p>
{/if}
{:else}
<p class="meta-text">Marketplace-Quelle nicht mehr verfügbar.</p>
{/if}
</div>
</section>
{/if}
<!-- ── 3. Gefahrenzone ────────────────────────────────────────── -->
<section class="settings-section danger-section">
<h2 class="section-title">Weitere Aktionen</h2>
<div class="section-body danger-body">
<div class="danger-row">
<div class="danger-info">
<span class="danger-label">Duplizieren</span>
<span class="danger-desc">Erstellt eine unabhängige Kopie ohne Lernfortschritt.</span>
</div>
<button type="button" class="btn-secondary" onclick={onDuplicate} disabled={duplicating}>
<Copy size={15} />
{duplicating ? 'Kopiere…' : 'Duplizieren'}
</button>
</div>
<div class="danger-divider"></div>
<div class="danger-row">
<div class="danger-info">
<span class="danger-label delete-label">Deck löschen</span>
<span class="danger-desc"
>Löscht alle Karten und Lernfortschritte unwiderruflich.</span
>
</div>
<button type="button" class="btn-danger" onclick={onDelete} disabled={deleting}>
<Trash size={15} />
{deleting ? 'Lösche…' : 'Löschen'}
</button>
</div>
</div>
</section>
<div class="actions">
<button type="submit" disabled={!canSave} class="btn-primary">
{saving ? t('deck_edit.saving') : t('deck_edit.save')}
</button>
<a href="/decks/{deckId}" class="btn-ghost">{t('deck_edit.cancel')}</a>
</div>
</form>
</div>
{/if}
@ -105,6 +333,7 @@
.page-shell {
max-width: 36rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.back-link {
@ -114,22 +343,34 @@
text-decoration: none;
transition: color 0.12s;
}
.back-link:hover { color: hsl(var(--color-foreground)); }
.back-link:hover {
color: hsl(var(--color-foreground));
}
.page-title {
margin-top: 0.5rem;
margin-bottom: 1.5rem;
margin-bottom: 1.75rem;
font-size: 1.5rem;
font-weight: 600;
}
.edit-form {
display: flex;
flex-direction: column;
gap: 1rem;
/* ── Sections ──────────────────────────────────────────────────── */
.settings-section {
margin-bottom: 1.5rem;
}
.form-section {
.section-title {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: hsl(var(--color-muted-foreground));
margin-bottom: 0.5rem;
padding-left: 0.125rem;
}
.section-body {
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
@ -139,6 +380,8 @@
gap: 1.125rem;
}
/* ── Form fields ───────────────────────────────────────────────── */
.field {
display: flex;
flex-direction: column;
@ -170,6 +413,12 @@
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
}
.select {
resize: none;
cursor: pointer;
appearance: auto;
}
.color-row {
display: flex;
align-items: center;
@ -192,24 +441,211 @@
color: hsl(var(--color-muted-foreground));
}
.color-preview {
width: 1.5rem;
height: 1.5rem;
.color-dot {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
border: 1px solid hsl(var(--color-border));
flex-shrink: 0;
}
.actions {
/* ── Category picker ───────────────────────────────────────────── */
.category-picker {
position: relative;
}
.cat-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.875rem;
cursor: pointer;
transition: border-color 0.12s;
width: 100%;
text-align: left;
}
.cat-trigger:hover {
border-color: hsl(var(--color-primary) / 0.5);
}
.cat-placeholder {
color: hsl(var(--color-muted-foreground));
}
.cat-chevron {
margin-left: auto;
font-size: 1rem;
line-height: 1;
transition: transform 0.15s;
color: hsl(var(--color-muted-foreground));
}
.cat-chevron.open {
transform: rotate(90deg);
}
.category-grid {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
right: 0;
z-index: 20;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
padding: 0.5rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
gap: 0.25rem;
box-shadow: 0 8px 24px hsl(var(--color-foreground) / 0.08);
}
.category-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
transition: background-color 0.1s;
}
.category-btn:hover {
background: hsl(var(--color-muted) / 0.5);
}
.category-btn.selected {
background: hsl(var(--color-muted));
border-color: hsl(var(--color-border));
font-weight: 500;
}
.category-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Form actions ──────────────────────────────────────────────── */
.form-actions {
display: flex;
align-items: center;
gap: 0.75rem;
padding-bottom: 1rem;
padding-top: 0.25rem;
}
/* ── Marketplace section ───────────────────────────────────────── */
.marketplace-body {
gap: 0.875rem;
}
.marketplace-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-primary));
text-decoration: none;
}
.marketplace-link:hover {
text-decoration: underline;
}
.update-banner {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.875rem;
background: hsl(var(--color-primary) / 0.06);
border: 1px solid hsl(var(--color-primary) / 0.2);
border-radius: 0.5rem;
}
.update-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: hsl(var(--color-primary));
flex-shrink: 0;
}
.update-text {
font-size: 0.875rem;
font-weight: 500;
flex: 1;
}
.meta-text {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
.up-to-date {
color: hsl(var(--color-success, 142 76% 36%));
}
/* ── Danger section ────────────────────────────────────────────── */
.danger-section .section-title {
color: hsl(var(--color-muted-foreground));
}
.danger-body {
gap: 0;
padding: 0;
}
.danger-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
}
.danger-divider {
height: 1px;
background: hsl(var(--color-border));
margin: 0;
}
.danger-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.danger-label {
font-size: 0.875rem;
font-weight: 500;
}
.delete-label {
color: hsl(var(--color-destructive, 0 84% 60%));
}
.danger-desc {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
/* ── Buttons ───────────────────────────────────────────────────── */
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1.125rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
@ -220,9 +656,65 @@
border: none;
cursor: pointer;
transition: background-color 0.12s;
flex-shrink: 0;
}
.btn-primary:hover:not(:disabled) {
background: hsl(var(--color-primary) / 0.88);
}
.btn-primary:disabled {
opacity: 0.45;
cursor: default;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.4375rem 0.875rem;
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.8125rem;
font-weight: 500;
border: 1px solid hsl(var(--color-border));
cursor: pointer;
transition: background-color 0.12s, border-color 0.12s;
flex-shrink: 0;
white-space: nowrap;
}
.btn-secondary:hover:not(:disabled) {
background: hsl(var(--color-muted) / 0.5);
}
.btn-secondary:disabled {
opacity: 0.45;
cursor: default;
}
.btn-danger {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.4375rem 0.875rem;
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--color-destructive, 0 84% 60%));
font: inherit;
font-size: 0.8125rem;
font-weight: 500;
border: 1px solid hsl(var(--color-destructive, 0 84% 60%) / 0.35);
cursor: pointer;
transition: background-color 0.12s;
flex-shrink: 0;
white-space: nowrap;
}
.btn-danger:hover:not(:disabled) {
background: hsl(var(--color-destructive, 0 84% 60%) / 0.08);
}
.btn-danger:disabled {
opacity: 0.45;
cursor: default;
}
.btn-primary:hover:not(:disabled) { background: hsl(var(--color-primary) / 0.88); }
.btn-primary:disabled { opacity: 0.45; cursor: default; }
.btn-ghost {
font-size: 0.875rem;
@ -230,5 +722,7 @@
text-decoration: none;
transition: color 0.12s;
}
.btn-ghost:hover { color: hsl(var(--color-foreground)); }
.btn-ghost:hover {
color: hsl(var(--color-foreground));
}
</style>

View file

@ -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(),
})