cards/apps/web/src/lib/components/DeckStack.svelte
Till JS 5859e202c5 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>
2026-05-11 18:50:27 +02:00

315 lines
7 KiB
Svelte

<script lang="ts">
import type { Deck } from '@cards/domain';
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, Archive, ArrowCounterClockwise, DotsThree } from '@mana/shared-icons';
interface Props {
deck: Deck;
cardCount?: number;
dueCount?: number;
href?: string;
onclick?: (e: MouseEvent) => void;
ariaLabel?: string;
onarchive?: (deck: Deck) => void;
}
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);
const accentColor = $derived(deck.color ?? null);
const category = $derived(deck.category ?? null);
const label = $derived(
ariaLabel ??
t('deck_stack.aria_label', {
name: deck.name,
cards: cardCount.toString(),
due: dueCount.toString(),
}),
);
</script>
<div class="stack-wrap" class:empty={!hasContent}>
{#if hasContent}
{#each layers as layer, i (i)}
<div
class="layer"
style:transform="translate({layer.dx}px, {layer.dy}px) rotate({layer.tilt}deg)"
aria-hidden="true"
></div>
{/each}
{/if}
<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"
as={href ? 'a' : 'button'}
{href}
{onclick}
ariaLabel={label}
colorAccent={accentColor}
class={hasContent ? 'cover' : 'cover empty'}
>
<div class="cover-inner">
<!-- Icon oben rechts, wie Kartenfarbe beim Spielkarten-Design -->
{#if category}
<div class="cover-corner" aria-hidden="true">
<DeckCategoryIcon {category} size={20} color={accentColor} weight="duotone" />
</div>
{/if}
<!-- Titel zentriert im Kartenkörper -->
<div class="cover-body">
<h2 class="cover-title">{deck.name}</h2>
{#if deck.description}
<p class="cover-desc">{deck.description}</p>
{/if}
</div>
<!-- Meta unten -->
<div class="cover-meta">
<span class="meta-count">{tn('decks.card_count', cardCount)}</span>
{#if dueCount > 0}
<span class="meta-due">{t('study.due_count', { n: dueCount })}</span>
{/if}
</div>
</div>
</CardSurface>
</div>
<style>
.stack-wrap {
position: relative;
width: 100%;
max-width: 18rem;
aspect-ratio: 5 / 7;
}
.layer {
position: absolute;
inset: 0;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.875rem;
box-shadow: 0 1px 3px hsl(var(--color-foreground) / 0.06);
}
.cover-inner {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
padding: 1rem 1rem 1.125rem 1.375rem;
overflow: hidden;
}
/* Icon-Ecke oben rechts */
.cover-corner {
position: absolute;
top: 0.875rem;
right: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.85;
}
.cover-body {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 0.875rem;
padding: 2.5rem 0.5rem 0 0;
min-height: 0;
}
.cover-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 600;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.cover-desc {
margin: 0;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.4;
}
.cover-meta {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
.meta-due {
display: inline-flex;
align-items: center;
padding: 0.0625rem 0.5rem;
border-radius: 9999px;
background: hsl(var(--color-primary) / 0.12);
color: hsl(var(--color-primary));
font-weight: 500;
}
.stack-wrap.empty :global(.cover.empty) {
border-style: dashed;
background: transparent;
}
.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;
background: hsl(var(--color-surface) / 0.92);
border: 1px solid hsl(var(--color-border));
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--color-muted-foreground));
text-decoration: none;
cursor: pointer;
padding: 0;
transition: color 0.12s, background 0.12s, border-color 0.12s;
backdrop-filter: blur(4px);
}
.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>