From 5859e202c53d0b9b6e6e3c8dbf639dbdb8a57cbd Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 11 May 2026 18:50:27 +0200 Subject: [PATCH] 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 --- STATUS.md | 24 +++- apps/api/src/db/schema/decks.ts | 1 + apps/api/src/lib/dto.ts | 1 + apps/api/src/routes/decks.ts | 14 +- apps/web/src/lib/api/decks.ts | 15 +- apps/web/src/lib/components/DeckStack.svelte | 139 ++++++++++++++++--- apps/web/src/routes/decks/+page.svelte | 99 ++++++++++++- infrastructure/docker-compose.production.yml | 1 + packages/cards-domain/src/schemas/deck.ts | 5 +- 9 files changed, 271 insertions(+), 28 deletions(-) diff --git a/STATUS.md b/STATUS.md index 79f8679..1a30997 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,6 +1,6 @@ # Cards — Projekt-Status & Onboarding -**Letztes Update:** 2026-05-08 (Phase 8 + Phase 9 erweiterte Polish-Welle) +**Letztes Update:** 2026-05-11 (Auth-Portal + Email-Verification E2E) **Wenn du gerade neu bist (Mensch oder KI):** dieses Dokument soll dir in 5 Minuten den vollen Kontext geben. Lies es vor allem anderen. @@ -89,6 +89,7 @@ Vollständiger Plan: [`mana/docs/playbooks/CARDS_GREENFIELD.md`](../mana/docs/pl | 0 | Read-Day mana-monorepo-Cards-Code lesen | ✅ | `docs/LESSONS_FROM_MANA_MONOREPO.md` | | 1 | Repo-Skelett (Turbo, pnpm, Bun, Docker, CI) | ✅ | `pnpm install` durch, 136 packages | | 2 | Auth-Föderation (mana-auth Registrierung, JWT-Verify) | ✅ live 2026-05-08 | App in mana-auth registriert, JWT-Verify additiv mit Dev-Stub-Fallback, E2E gegen `tills95@gmail.com` verifiziert | +| 2b | Auth-Portal (`mana-auth-web` :3002, auth.mana.how) | ✅ 2026-05-11 | SvelteKit-Auth-Portal auf :3002 gebaut. Login/Register/ForgotPassword/Reset/VerifyEmail/TwoFactor. Cards-App redirect zu auth.mana.how statt eigenem Login-Form. Email-Verification E2E verifiziert (mana-notify → mailpit → token → callback → JWT). | | 3 | Domain-Modell + Drizzle + CRUD-API | ✅ | 8 Tabellen, FSRS via ts-fsrs, 46 Tests grün, E2E-Smoke durch | | 4 | Frontend-Core (SvelteKit, Tailwind 4, Markdown-Editor, Study-View) | ✅ | type-check + build grün, manuell testbar im Browser | | 5 | Föderations-Endpunkte (share, tools, search, dsgvo) | ✅ | 70 Tests grün, E2E-Smoke (Quote→Inbox→Search→DSGVO-Roundtrip) | @@ -114,9 +115,25 @@ Legende: ✅ erledigt + verifiziert · 🚧 blockiert · ⏸ noch nicht begonnen ```bash cd /Users/till/Documents/Code/cards NPM_AUTH_TOKEN= pnpm install # einmalig / nach pull -pnpm dev:full # cards-docker + mana-docker + DB-Push (cards & auth) + dev (cards & mana-auth) +pnpm dev:full # cards-docker + mana-docker + DB-Push (cards & auth) + dev (cards, mana-auth, mana-auth-web) ``` +Für Email-Verification zusätzlich mailpit + mana-notify starten: + +```bash +# mailpit (SMTP-Catcher, Web :8025) +docker run -d --name mailpit -p 1025:1025 -p 8025:8025 axllent/mailpit + +# mana-notify (Notification-Service, :3066) +cd /Users/till/Documents/Code/mana/services/mana-notify +PORT=3066 DATABASE_URL="postgresql://mana:devpassword@localhost:5432/mana_notify" \ + SERVICE_KEY="dev-service-key-for-bot-sso-2024" MANA_AUTH_URL="http://localhost:3001" \ + SMTP_HOST="localhost" SMTP_PORT="1025" SMTP_FROM="Mana " \ + SMTP_INSECURE_TLS="true" go run ./cmd/server & +``` + +Dann: `open http://localhost:8025` — Verification-Mails landen hier. + Oder von überall via zsh-Alias: `cards-dev` (definiert in `~/.zshrc`, zeigt auf `pnpm dev:full` im cards-Repo). @@ -261,7 +278,8 @@ Volle Konventionen: [`CLAUDE.md`](CLAUDE.md) ## Git-Historie ``` -(aktuell) Marketplace-UX-Polish: Subscribe=Fork+Track, Deck-Settings-Page +(aktuell) Auth-Portal: mana-auth-web :3002, cards redirect → auth.mana.how, email verification E2E +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 diff --git a/apps/api/src/db/schema/decks.ts b/apps/api/src/db/schema/decks.ts index 0dba171..3edec82 100644 --- a/apps/api/src/db/schema/decks.ts +++ b/apps/api/src/db/schema/decks.ts @@ -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(), diff --git a/apps/api/src/lib/dto.ts b/apps/api/src/lib/dto.ts index 6574951..fa1570e 100644 --- a/apps/api/src/lib/dto.ts +++ b/apps/api/src/lib/dto.ts @@ -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(), }; diff --git a/apps/api/src/routes/decks.ts b/apps/api/src/routes/decks.ts index b348277..8bee455 100644 --- a/apps/api/src/routes/decks.ts +++ b/apps/api/src/routes/decks.ts @@ -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(); diff --git a/apps/web/src/lib/api/decks.ts b/apps/web/src/lib/api/decks.ts index c2f9df1..2d410aa 100644 --- a/apps/web/src/lib/api/decks.ts +++ b/apps/web/src/lib/api/decks.ts @@ -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(`/api/v1/decks/${id}`, { method: 'PATCH', body: { archived: true } }); +} + +export function unarchiveDeck(id: string) { + return api(`/api/v1/decks/${id}`, { method: 'PATCH', body: { archived: false } }); +} + export function getDeck(id: string) { return api(`/api/v1/decks/${id}`); } diff --git a/apps/web/src/lib/components/DeckStack.svelte b/apps/web/src/lib/components/DeckStack.svelte index 0251e72..734815b 100644 --- a/apps/web/src/lib/components/DeckStack.svelte +++ b/apps/web/src/lib/components/DeckStack.svelte @@ -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} - e.stopPropagation()} - aria-label="Deck bearbeiten" - title="Deck bearbeiten" - > - - + + + {#if menuOpen} + + + {/if} diff --git a/apps/web/src/routes/decks/+page.svelte b/apps/web/src/routes/decks/+page.svelte index 0c1e966..5bde739 100644 --- a/apps/web/src/routes/decks/+page.svelte +++ b/apps/web/src/routes/decks/+page.svelte @@ -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([]); + let archivedDecks = $state([]); let subscriptions = $state([]); let loadingOwn = $state(true); let loadingSubs = $state(true); + let loadingArchived = $state(false); + let archiveOpen = $state(false); let selectedId = $state(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} /> {/each} @@ -182,6 +215,35 @@ {/if} + +
+ + + {#if archiveOpen} + {#if loadingArchived} + + {:else if archivedDecks.length === 0} +

Keine archivierten Decks.

+ {:else} +
    + {#each archivedDecks as deck (deck.id)} +
  • + +
  • + {/each} +
+ {/if} + {/if} +
+ {#if loadingSubs || subscriptions.length > 0}
@@ -203,6 +265,41 @@