feat(cards): deck management UI + production auth portal wiring

Deck schema, API routes, and SvelteKit UI for creating and browsing decks
(DeckStack component, inline creation, floating nav). Production compose
updated with PUBLIC_AUTH_WEB_URL so cards-web redirects to auth.mana.how
for login/register instead of the raw API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-11 18:50:27 +02:00
parent 7116bd66b4
commit 5859e202c5
9 changed files with 271 additions and 28 deletions

View file

@ -44,6 +44,7 @@ export const decks = cardsSchema.table(
// Quelle nachzuladen.
forkedFromMarketplaceDeckId: text('forked_from_marketplace_deck_id'),
forkedFromMarketplaceVersionId: text('forked_from_marketplace_version_id'),
archivedAt: timestamp('archived_at', { withTimezone: true, mode: 'date' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
.notNull()
.defaultNow(),

View file

@ -13,6 +13,7 @@ export function toDeckDto(row: typeof decks.$inferSelect) {
content_hash: row.contentHash,
forked_from_marketplace_deck_id: row.forkedFromMarketplaceDeckId,
forked_from_marketplace_version_id: row.forkedFromMarketplaceVersionId,
archived_at: row.archivedAt?.toISOString() ?? null,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};

View file

@ -1,4 +1,4 @@
import { and, eq, isNotNull, ne } from 'drizzle-orm';
import { and, eq, isNotNull, isNull, ne } from 'drizzle-orm';
import { sql } from 'drizzle-orm';
import { Hono } from 'hono';
@ -52,10 +52,17 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
r.get('/', async (c) => {
const userId = c.get('userId');
const forkedFromMarketplace = c.req.query('forked_from_marketplace');
const archivedParam = c.req.query('archived');
const conditions = [eq(decks.userId, userId)];
if (forkedFromMarketplace === 'true') {
conditions.push(isNotNull(decks.forkedFromMarketplaceDeckId));
}
// archived=true → nur archivierte; default → nur aktive
if (archivedParam === 'true') {
conditions.push(isNotNull(decks.archivedAt));
} else {
conditions.push(isNull(decks.archivedAt));
}
const rows = await dbOf()
.select()
.from(decks)
@ -86,6 +93,7 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
422
);
}
const now = new Date();
const [row] = await dbOf()
.update(decks)
.set({
@ -97,7 +105,9 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
...(parsed.data.fsrs_settings !== undefined && {
fsrsSettings: parsed.data.fsrs_settings,
}),
updatedAt: new Date(),
...(parsed.data.archived === true && { archivedAt: now }),
...(parsed.data.archived === false && { archivedAt: null }),
updatedAt: now,
})
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
.returning();

View file

@ -1,11 +1,22 @@
import type { Deck, DeckCreate, DeckUpdate } from '@cards/domain';
import { api, apiForm } from './client.ts';
export function listDecks(opts: { forkedFromMarketplace?: boolean } = {}) {
const qs = opts.forkedFromMarketplace ? '?forked_from_marketplace=true' : '';
export function listDecks(opts: { forkedFromMarketplace?: boolean; archived?: boolean } = {}) {
const params = new URLSearchParams();
if (opts.forkedFromMarketplace) params.set('forked_from_marketplace', 'true');
if (opts.archived) params.set('archived', 'true');
const qs = params.size ? `?${params}` : '';
return api<{ decks: Deck[]; total: number }>(`/api/v1/decks${qs}`);
}
export function archiveDeck(id: string) {
return api<Deck>(`/api/v1/decks/${id}`, { method: 'PATCH', body: { archived: true } });
}
export function unarchiveDeck(id: string) {
return api<Deck>(`/api/v1/decks/${id}`, { method: 'PATCH', body: { archived: false } });
}
export function getDeck(id: string) {
return api<Deck>(`/api/v1/decks/${id}`);
}

View file

@ -3,9 +3,11 @@
import { DECK_CATEGORY_LABELS } from '@cards/domain';
import { stackLayers } from '$lib/utils/deck-tilt';
import { t, tn } from '$lib/i18n/index.svelte.ts';
import { archiveDeck, unarchiveDeck } from '$lib/api/decks.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import CardSurface from './CardSurface.svelte';
import DeckCategoryIcon from './DeckCategoryIcon.svelte';
import { PencilSimple } from '@mana/shared-icons';
import { PencilSimple, Archive, ArrowCounterClockwise, DotsThree } from '@mana/shared-icons';
interface Props {
deck: Deck;
@ -14,9 +16,35 @@
href?: string;
onclick?: (e: MouseEvent) => void;
ariaLabel?: string;
onarchive?: (deck: Deck) => void;
}
let { deck, cardCount = 0, dueCount = 0, href, onclick, ariaLabel }: Props = $props();
let { deck, cardCount = 0, dueCount = 0, href, onclick, ariaLabel, onarchive }: Props = $props();
let menuOpen = $state(false);
const isArchived = $derived(deck.archived_at != null);
function toggleMenu(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
menuOpen = !menuOpen;
}
function closeMenu() {
menuOpen = false;
}
async function handleArchiveToggle(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
closeMenu();
try {
const updated = isArchived ? await unarchiveDeck(deck.id) : await archiveDeck(deck.id);
onarchive?.(updated);
} catch {
toasts.error(isArchived ? 'Wiederherstellen fehlgeschlagen' : 'Archivieren fehlgeschlagen');
}
}
const layers = $derived(stackLayers(deck.id, 3));
const hasContent = $derived(cardCount > 0);
@ -44,15 +72,41 @@
{/each}
{/if}
<a
href="/decks/{deck.id}/edit"
class="edit-btn"
onclick={(e) => e.stopPropagation()}
aria-label="Deck bearbeiten"
title="Deck bearbeiten"
>
<PencilSimple size={13} weight="bold" />
</a>
<div class="actions">
<a
href="/decks/{deck.id}/edit"
class="action-btn"
onclick={(e) => e.stopPropagation()}
aria-label="Deck bearbeiten"
title="Deck bearbeiten"
>
<PencilSimple size={13} weight="bold" />
</a>
<button
class="action-btn"
onclick={toggleMenu}
aria-label="Weitere Aktionen"
title="Weitere Aktionen"
aria-expanded={menuOpen}
>
<DotsThree size={13} weight="bold" />
</button>
</div>
{#if menuOpen}
<div class="menu-backdrop" role="presentation" onclick={closeMenu} onkeydown={() => {}}></div>
<div class="menu" role="menu">
<button class="menu-item" role="menuitem" onclick={handleArchiveToggle}>
{#if isArchived}
<ArrowCounterClockwise size={14} weight="bold" />
Wiederherstellen
{:else}
<Archive size={14} weight="bold" />
Archivieren
{/if}
</button>
</div>
{/if}
<CardSurface
size="md"
@ -182,11 +236,22 @@
background: transparent;
}
.edit-btn {
.actions {
position: absolute;
bottom: 0.625rem;
right: 0.625rem;
z-index: 10;
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.15s;
}
.stack-wrap:hover .actions {
opacity: 1;
}
.action-btn {
width: 1.625rem;
height: 1.625rem;
border-radius: 0.375rem;
@ -197,18 +262,54 @@
justify-content: center;
color: hsl(var(--color-muted-foreground));
text-decoration: none;
opacity: 0;
transition: opacity 0.15s, color 0.12s, background 0.12s, border-color 0.12s;
cursor: pointer;
padding: 0;
transition: color 0.12s, background 0.12s, border-color 0.12s;
backdrop-filter: blur(4px);
}
.stack-wrap:hover .edit-btn {
opacity: 1;
}
.edit-btn:hover {
.action-btn:hover {
color: hsl(var(--color-foreground));
background: hsl(var(--color-surface));
border-color: hsl(var(--color-primary) / 0.5);
}
.menu-backdrop {
position: fixed;
inset: 0;
z-index: 19;
}
.menu {
position: absolute;
bottom: 2.5rem;
right: 0.625rem;
z-index: 20;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
box-shadow: 0 4px 16px hsl(var(--color-foreground) / 0.1);
padding: 0.25rem;
min-width: 10rem;
}
.menu-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.menu-item:hover {
background: hsl(var(--color-muted));
}
</style>

View file

@ -17,7 +17,7 @@
import DeckListGrid from '$lib/components/marketplace/DeckListGrid.svelte';
import SkeletonGrid from '$lib/components/marketplace/SkeletonGrid.svelte';
import { t } from '$lib/i18n/index.svelte.ts';
import { Books, Star } from '@mana/shared-icons';
import { Books, Star, Archive } from '@mana/shared-icons';
interface DeckWithCounts {
deck: Deck;
@ -31,9 +31,12 @@
}
let decks = $state<DeckWithCounts[]>([]);
let archivedDecks = $state<Deck[]>([]);
let subscriptions = $state<SubscriptionItem[]>([]);
let loadingOwn = $state(true);
let loadingSubs = $state(true);
let loadingArchived = $state(false);
let archiveOpen = $state(false);
let selectedId = $state<string | null>(null);
// For each subscribed deck that the user has also forked, point directly to study mode.
@ -78,6 +81,35 @@
}
}
async function toggleArchive() {
archiveOpen = !archiveOpen;
if (archiveOpen && archivedDecks.length === 0) {
loadingArchived = true;
try {
const r = await listDecks({ archived: true });
archivedDecks = r.decks;
} finally {
loadingArchived = false;
}
}
}
function handleDeckArchived(updated: Deck) {
// Deck aus der aktiven Liste raus
decks = decks.filter((d) => d.deck.id !== updated.id);
// Wenn Archiv-Sektion offen, füge es dort hinzu
if (archiveOpen) {
archivedDecks = [updated, ...archivedDecks];
}
}
function handleDeckUnarchived(updated: Deck) {
// Deck aus Archiv raus
archivedDecks = archivedDecks.filter((d) => d.id !== updated.id);
// Zur aktiven Liste hinzufügen (ohne Counts — werden beim nächsten Laden korrekt)
decks = [{ deck: updated, cardCount: 0, dueCount: 0 }, ...decks];
}
async function loadSubscriptions() {
try {
const { subscriptions: subs } = await getMySubscriptions();
@ -175,6 +207,7 @@
e.preventDefault();
handleSelect(deck.id);
}}
onarchive={handleDeckArchived}
/>
</li>
{/each}
@ -182,6 +215,35 @@
{/if}
</section>
<!-- Archiv -->
<section>
<button class="archive-toggle" onclick={toggleArchive} aria-expanded={archiveOpen}>
<Archive size={20} weight="duotone" />
Archiv
<span class="toggle-chevron" class:open={archiveOpen}></span>
</button>
{#if archiveOpen}
{#if loadingArchived}
<SkeletonGrid count={2} />
{:else if archivedDecks.length === 0}
<p class="archive-empty">Keine archivierten Decks.</p>
{:else}
<ul class="deck-row" aria-label="Archivierte Decks" style:--has-selection={0}>
{#each archivedDecks as deck (deck.id)}
<li class="deck-item">
<DeckStack
{deck}
href={`/decks/${deck.id}`}
onarchive={handleDeckUnarchived}
/>
</li>
{/each}
</ul>
{/if}
{/if}
</section>
<!-- Abonnierte Marketplace-Decks -->
{#if loadingSubs || subscriptions.length > 0}
<section>
@ -203,6 +265,41 @@
</div>
<style>
.archive-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0;
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--color-foreground));
background: none;
border: none;
cursor: pointer;
padding: 0;
width: 100%;
text-align: left;
}
.toggle-chevron {
margin-left: auto;
font-size: 1rem;
color: hsl(var(--color-muted-foreground));
display: inline-block;
transform: rotate(0deg);
transition: transform 0.2s;
}
.toggle-chevron.open {
transform: rotate(90deg);
}
.archive-empty {
margin-top: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
}
.section-head {
display: flex;
align-items: center;