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>
315 lines
7 KiB
Svelte
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>
|