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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue