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:
parent
7116bd66b4
commit
5859e202c5
9 changed files with 271 additions and 28 deletions
24
STATUS.md
24
STATUS.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Cards — Projekt-Status & Onboarding
|
# 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
|
**Wenn du gerade neu bist (Mensch oder KI):** dieses Dokument soll dir
|
||||||
in 5 Minuten den vollen Kontext geben. Lies es vor allem anderen.
|
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` |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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
|
```bash
|
||||||
cd /Users/till/Documents/Code/cards
|
cd /Users/till/Documents/Code/cards
|
||||||
NPM_AUTH_TOKEN=<verdaccio-token> pnpm install # einmalig / nach pull
|
NPM_AUTH_TOKEN=<verdaccio-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 <noreply@mana.how>" \
|
||||||
|
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`,
|
Oder von überall via zsh-Alias: `cards-dev` (definiert in `~/.zshrc`,
|
||||||
zeigt auf `pnpm dev:full` im cards-Repo).
|
zeigt auf `pnpm dev:full` im cards-Repo).
|
||||||
|
|
||||||
|
|
@ -261,7 +278,8 @@ Volle Konventionen: [`CLAUDE.md`](CLAUDE.md)
|
||||||
## Git-Historie
|
## 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
|
39b1791 Phase 9l: Image-Occlusion als 4. MVP-CardType
|
||||||
c9eb0a6 Phase 9k: Media-Upload via MinIO-Container
|
c9eb0a6 Phase 9k: Media-Upload via MinIO-Container
|
||||||
e7ae93d docs: STATUS.md auf Phase-9-Welle-2-Stand
|
e7ae93d docs: STATUS.md auf Phase-9-Welle-2-Stand
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export const decks = cardsSchema.table(
|
||||||
// Quelle nachzuladen.
|
// Quelle nachzuladen.
|
||||||
forkedFromMarketplaceDeckId: text('forked_from_marketplace_deck_id'),
|
forkedFromMarketplaceDeckId: text('forked_from_marketplace_deck_id'),
|
||||||
forkedFromMarketplaceVersionId: text('forked_from_marketplace_version_id'),
|
forkedFromMarketplaceVersionId: text('forked_from_marketplace_version_id'),
|
||||||
|
archivedAt: timestamp('archived_at', { withTimezone: true, mode: 'date' }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export function toDeckDto(row: typeof decks.$inferSelect) {
|
||||||
content_hash: row.contentHash,
|
content_hash: row.contentHash,
|
||||||
forked_from_marketplace_deck_id: row.forkedFromMarketplaceDeckId,
|
forked_from_marketplace_deck_id: row.forkedFromMarketplaceDeckId,
|
||||||
forked_from_marketplace_version_id: row.forkedFromMarketplaceVersionId,
|
forked_from_marketplace_version_id: row.forkedFromMarketplaceVersionId,
|
||||||
|
archived_at: row.archivedAt?.toISOString() ?? null,
|
||||||
created_at: row.createdAt.toISOString(),
|
created_at: row.createdAt.toISOString(),
|
||||||
updated_at: row.updatedAt.toISOString(),
|
updated_at: row.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 { sql } from 'drizzle-orm';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
|
@ -52,10 +52,17 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
|
||||||
r.get('/', async (c) => {
|
r.get('/', async (c) => {
|
||||||
const userId = c.get('userId');
|
const userId = c.get('userId');
|
||||||
const forkedFromMarketplace = c.req.query('forked_from_marketplace');
|
const forkedFromMarketplace = c.req.query('forked_from_marketplace');
|
||||||
|
const archivedParam = c.req.query('archived');
|
||||||
const conditions = [eq(decks.userId, userId)];
|
const conditions = [eq(decks.userId, userId)];
|
||||||
if (forkedFromMarketplace === 'true') {
|
if (forkedFromMarketplace === 'true') {
|
||||||
conditions.push(isNotNull(decks.forkedFromMarketplaceDeckId));
|
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()
|
const rows = await dbOf()
|
||||||
.select()
|
.select()
|
||||||
.from(decks)
|
.from(decks)
|
||||||
|
|
@ -86,6 +93,7 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
|
||||||
422
|
422
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const now = new Date();
|
||||||
const [row] = await dbOf()
|
const [row] = await dbOf()
|
||||||
.update(decks)
|
.update(decks)
|
||||||
.set({
|
.set({
|
||||||
|
|
@ -97,7 +105,9 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
|
||||||
...(parsed.data.fsrs_settings !== undefined && {
|
...(parsed.data.fsrs_settings !== undefined && {
|
||||||
fsrsSettings: parsed.data.fsrs_settings,
|
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)))
|
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,22 @@
|
||||||
import type { Deck, DeckCreate, DeckUpdate } from '@cards/domain';
|
import type { Deck, DeckCreate, DeckUpdate } from '@cards/domain';
|
||||||
import { api, apiForm } from './client.ts';
|
import { api, apiForm } from './client.ts';
|
||||||
|
|
||||||
export function listDecks(opts: { forkedFromMarketplace?: boolean } = {}) {
|
export function listDecks(opts: { forkedFromMarketplace?: boolean; archived?: boolean } = {}) {
|
||||||
const qs = opts.forkedFromMarketplace ? '?forked_from_marketplace=true' : '';
|
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}`);
|
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) {
|
export function getDeck(id: string) {
|
||||||
return api<Deck>(`/api/v1/decks/${id}`);
|
return api<Deck>(`/api/v1/decks/${id}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
import { DECK_CATEGORY_LABELS } from '@cards/domain';
|
import { DECK_CATEGORY_LABELS } from '@cards/domain';
|
||||||
import { stackLayers } from '$lib/utils/deck-tilt';
|
import { stackLayers } from '$lib/utils/deck-tilt';
|
||||||
import { t, tn } from '$lib/i18n/index.svelte.ts';
|
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 CardSurface from './CardSurface.svelte';
|
||||||
import DeckCategoryIcon from './DeckCategoryIcon.svelte';
|
import DeckCategoryIcon from './DeckCategoryIcon.svelte';
|
||||||
import { PencilSimple } from '@mana/shared-icons';
|
import { PencilSimple, Archive, ArrowCounterClockwise, DotsThree } from '@mana/shared-icons';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
deck: Deck;
|
deck: Deck;
|
||||||
|
|
@ -14,9 +16,35 @@
|
||||||
href?: string;
|
href?: string;
|
||||||
onclick?: (e: MouseEvent) => void;
|
onclick?: (e: MouseEvent) => void;
|
||||||
ariaLabel?: string;
|
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 layers = $derived(stackLayers(deck.id, 3));
|
||||||
const hasContent = $derived(cardCount > 0);
|
const hasContent = $derived(cardCount > 0);
|
||||||
|
|
@ -44,15 +72,41 @@
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<a
|
<div class="actions">
|
||||||
href="/decks/{deck.id}/edit"
|
<a
|
||||||
class="edit-btn"
|
href="/decks/{deck.id}/edit"
|
||||||
onclick={(e) => e.stopPropagation()}
|
class="action-btn"
|
||||||
aria-label="Deck bearbeiten"
|
onclick={(e) => e.stopPropagation()}
|
||||||
title="Deck bearbeiten"
|
aria-label="Deck bearbeiten"
|
||||||
>
|
title="Deck bearbeiten"
|
||||||
<PencilSimple size={13} weight="bold" />
|
>
|
||||||
</a>
|
<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
|
<CardSurface
|
||||||
size="md"
|
size="md"
|
||||||
|
|
@ -182,11 +236,22 @@
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-btn {
|
.actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0.625rem;
|
bottom: 0.625rem;
|
||||||
right: 0.625rem;
|
right: 0.625rem;
|
||||||
z-index: 10;
|
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;
|
width: 1.625rem;
|
||||||
height: 1.625rem;
|
height: 1.625rem;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
|
|
@ -197,18 +262,54 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: hsl(var(--color-muted-foreground));
|
color: hsl(var(--color-muted-foreground));
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
opacity: 0;
|
cursor: pointer;
|
||||||
transition: opacity 0.15s, color 0.12s, background 0.12s, border-color 0.12s;
|
padding: 0;
|
||||||
|
transition: color 0.12s, background 0.12s, border-color 0.12s;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack-wrap:hover .edit-btn {
|
.action-btn:hover {
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-btn:hover {
|
|
||||||
color: hsl(var(--color-foreground));
|
color: hsl(var(--color-foreground));
|
||||||
background: hsl(var(--color-surface));
|
background: hsl(var(--color-surface));
|
||||||
border-color: hsl(var(--color-primary) / 0.5);
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
import DeckListGrid from '$lib/components/marketplace/DeckListGrid.svelte';
|
import DeckListGrid from '$lib/components/marketplace/DeckListGrid.svelte';
|
||||||
import SkeletonGrid from '$lib/components/marketplace/SkeletonGrid.svelte';
|
import SkeletonGrid from '$lib/components/marketplace/SkeletonGrid.svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte.ts';
|
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 {
|
interface DeckWithCounts {
|
||||||
deck: Deck;
|
deck: Deck;
|
||||||
|
|
@ -31,9 +31,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let decks = $state<DeckWithCounts[]>([]);
|
let decks = $state<DeckWithCounts[]>([]);
|
||||||
|
let archivedDecks = $state<Deck[]>([]);
|
||||||
let subscriptions = $state<SubscriptionItem[]>([]);
|
let subscriptions = $state<SubscriptionItem[]>([]);
|
||||||
let loadingOwn = $state(true);
|
let loadingOwn = $state(true);
|
||||||
let loadingSubs = $state(true);
|
let loadingSubs = $state(true);
|
||||||
|
let loadingArchived = $state(false);
|
||||||
|
let archiveOpen = $state(false);
|
||||||
let selectedId = $state<string | null>(null);
|
let selectedId = $state<string | null>(null);
|
||||||
|
|
||||||
// For each subscribed deck that the user has also forked, point directly to study mode.
|
// 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() {
|
async function loadSubscriptions() {
|
||||||
try {
|
try {
|
||||||
const { subscriptions: subs } = await getMySubscriptions();
|
const { subscriptions: subs } = await getMySubscriptions();
|
||||||
|
|
@ -175,6 +207,7 @@
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSelect(deck.id);
|
handleSelect(deck.id);
|
||||||
}}
|
}}
|
||||||
|
onarchive={handleDeckArchived}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -182,6 +215,35 @@
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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 -->
|
<!-- Abonnierte Marketplace-Decks -->
|
||||||
{#if loadingSubs || subscriptions.length > 0}
|
{#if loadingSubs || subscriptions.length > 0}
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -203,6 +265,41 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ services:
|
||||||
# und in client-fetches.
|
# und in client-fetches.
|
||||||
PUBLIC_CARDS_API_URL: https://cardecky-api.mana.how
|
PUBLIC_CARDS_API_URL: https://cardecky-api.mana.how
|
||||||
PUBLIC_MANA_AUTH_URL: https://auth.mana.how
|
PUBLIC_MANA_AUTH_URL: https://auth.mana.how
|
||||||
|
PUBLIC_AUTH_WEB_URL: https://auth.mana.how
|
||||||
CARDS_API_URL: https://cardecky-api.mana.how
|
CARDS_API_URL: https://cardecky-api.mana.how
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ export const DeckSchema = z
|
||||||
content_hash: z.string().optional().nullable(),
|
content_hash: z.string().optional().nullable(),
|
||||||
forked_from_marketplace_deck_id: z.string().optional().nullable(),
|
forked_from_marketplace_deck_id: z.string().optional().nullable(),
|
||||||
forked_from_marketplace_version_id: z.string().optional().nullable(),
|
forked_from_marketplace_version_id: z.string().optional().nullable(),
|
||||||
|
archived_at: z.string().datetime().optional().nullable(),
|
||||||
created_at: z.string().datetime(),
|
created_at: z.string().datetime(),
|
||||||
updated_at: z.string().datetime(),
|
updated_at: z.string().datetime(),
|
||||||
})
|
})
|
||||||
|
|
@ -73,5 +74,7 @@ export const DeckCreateSchema = z
|
||||||
.strict();
|
.strict();
|
||||||
export type DeckCreate = z.infer<typeof DeckCreateSchema>;
|
export type DeckCreate = z.infer<typeof DeckCreateSchema>;
|
||||||
|
|
||||||
export const DeckUpdateSchema = DeckCreateSchema.partial();
|
export const DeckUpdateSchema = DeckCreateSchema.partial().extend({
|
||||||
|
archived: z.boolean().optional(),
|
||||||
|
});
|
||||||
export type DeckUpdate = z.infer<typeof DeckUpdateSchema>;
|
export type DeckUpdate = z.infer<typeof DeckUpdateSchema>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue