refactor(deck-detail): Redesign mit Kategorie-Picker, Card-Menü, Markdown

- Deck-Header mit Farb-Dot, Titel, Aktions-Buttons (Lernen / Neue Karte)
- Kategorie-Picker (eingeklappt, inline wie /decks/new)
- Card-List mit kontextuellem 3-Punkte-Menü (Edit / Löschen)
- Karten-Vorschau mit Front/Back via marked
- NewDeckCard: Kategorie-Icon-Größe korrigiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-10 15:59:56 +02:00
parent 03ec7e7b3e
commit 731481ffe3
2 changed files with 527 additions and 149 deletions

View file

@ -25,6 +25,7 @@
let imageFiles = $state<File[]>([]);
let imagePreviews = $state<string[]>([]);
let imageUrl = $state('');
let imageGenerating = $state(false);
let imageError = $state<string | null>(null);
let fileInput = $state<HTMLInputElement | null>(null);
@ -76,16 +77,21 @@
for (const url of imagePreviews) URL.revokeObjectURL(url);
imageFiles = [];
imagePreviews = [];
imageUrl = '';
imageGenerating = false;
imageError = null;
}
async function onFromImage() {
if (imageFiles.length === 0 || !devUser.id || imageGenerating) return;
if ((imageFiles.length === 0 && !imageUrl.trim()) || !devUser.id || imageGenerating) return;
imageError = null;
imageGenerating = true;
try {
const result = await generateDeckFromImage(imageFiles, { count, language });
const result = await generateDeckFromImage(imageFiles, {
count,
language,
url: imageUrl.trim() || undefined,
});
toasts.success(`🖼 "${result.deck.name}" mit ${result.cards_created} Karten erstellt`);
goto(`/decks/${result.deck.id}`);
} catch (err) {
@ -173,11 +179,12 @@
<div class="cat-grid">
{#each DECK_CATEGORY_IDS as id}
<button type="button" class="cat-btn" class:selected={category === id}
onclick={() => pickCategory(id)} title={DECK_CATEGORY_LABELS[id]}
onclick={() => pickCategory(id)}
aria-pressed={category === id}>
<DeckCategoryIcon category={id} size={16}
<DeckCategoryIcon category={id} size={13}
color={category === id ? color : null}
weight={category === id ? 'fill' : 'regular'} />
<span class="cat-label">{DECK_CATEGORY_LABELS[id]}</span>
</button>
{/each}
</div>
@ -248,6 +255,16 @@
</div>
</div>
<label class="field">
<span class="label">URL als Kontext (optional)</span>
<input
bind:value={imageUrl}
type="url"
placeholder="https://…"
class="input"
/>
</label>
{#if aiError}
<p class="ai-error" role="alert">{aiError}</p>
{/if}
@ -268,8 +285,16 @@
<button type="button" disabled={generating || saving || imageGenerating || !name.trim()} onclick={onAi} class="btn-ai">
{generating ? '✨ Generiere…' : '✨ Mit KI generieren'}
</button>
<button type="button" disabled={imageFiles.length === 0 || imageGenerating || saving || generating} onclick={onFromImage} class="btn-ai">
{imageGenerating ? '🖼 Analysiere…' : '🖼 Aus Bild'}
<button type="button" disabled={(imageFiles.length === 0 && !imageUrl.trim()) || imageGenerating || saving || generating} onclick={onFromImage} class="btn-ai">
{#if imageGenerating}
🖼 Analysiere…
{:else if imageFiles.length > 0 && imageUrl.trim()}
🖼 Bild + URL
{:else if imageUrl.trim()}
🖼 Aus URL
{:else}
🖼 Aus Bild
{/if}
</button>
<button type="button" onclick={close} class="btn-cancel">
{t('deck_new.cancel')}
@ -402,31 +427,39 @@
transform: rotate(180deg);
}
/* Kategorie-Picker — 4 Spalten Icon-Grid, erscheint direkt unter dem Trigger */
/* Kategorie-Picker — Flexwrap Pills mit Icon + Label */
.cat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding-top: 0.25rem;
}
.cat-btn {
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
border-radius: 0.3125rem;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: border-color 0.12s, background 0.12s;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.cat-btn:hover {
border-color: hsl(var(--color-primary) / 0.5);
color: hsl(var(--color-foreground));
}
.cat-btn.selected {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.08);
color: hsl(var(--color-foreground));
}
.cat-label {
font-size: 0.75rem;
font-weight: 500;
}
/* Farbe — volle Breite, feste Höhe wie ein Input */

View file

@ -10,14 +10,32 @@
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t, tn } from '$lib/i18n/index.svelte.ts';
import DeckFan from '$lib/components/DeckFan.svelte';
import CardSurface from '$lib/components/CardSurface.svelte';
import DeckCategoryIcon from '$lib/components/DeckCategoryIcon.svelte';
import { Image, CaretRight, DotsThree, PencilSimple, Trash } from '@mana/shared-icons';
import { marked } from 'marked';
function md(text: string): string {
return marked.parse(text, { async: false }) as string;
}
let deck = $state<Deck | null>(null);
let cards = $state<Card[]>([]);
let dueCount = $state(0);
let loading = $state(true);
let error = $state<string | null>(null);
let categoryOpen = $state(false);
let openMenuId = $state<string | null>(null);
function toggleMenu(cardId: string, e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
openMenuId = openMenuId === cardId ? null : cardId;
}
function closeMenu() {
openMenuId = null;
}
const deckId = $derived(page.params.id ?? '');
@ -53,6 +71,7 @@
const next = deck.category === id ? null : id;
try {
deck = await updateDeck(deck.id, { category: next ?? undefined });
categoryOpen = false;
} catch (e) {
toasts.error((e as Error).message);
}
@ -68,48 +87,46 @@
toasts.error(t('card_edit.delete_failed', { msg: (e as Error).message }));
}
}
function cardFront(card: Card): string {
const f = card.fields as Record<string, string | undefined>;
if (card.type === 'cloze') return f.text ?? '';
return f.front ?? '';
}
function cardBack(card: Card): string | null {
const f = card.fields as Record<string, string | undefined>;
if (card.type === 'basic') return f.back ?? null;
return null;
}
</script>
<svelte:window onclick={closeMenu} />
{#if loading}
<p class="text-[hsl(var(--color-muted-foreground))]">{t('decks.loading')}</p>
{:else if error}
<p class="text-[hsl(var(--color-error))]">{t('decks.error', { msg: error })}</p>
{:else if deck}
<a href="/decks" class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
>← {t('nav.decks')}</a
>
<a href="/decks" class="back-link">{t('nav.decks')}</a>
<div class="mt-2 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="deck-header">
<div class="deck-title-row">
{#if deck.color}
<span
class="h-4 w-4 rounded-full"
style="background:{deck.color}"
aria-hidden="true"
></span>
<span class="color-dot" style="background:{deck.color}" aria-hidden="true"></span>
{/if}
<h1 class="text-2xl font-semibold">{deck.name}</h1>
<h1 class="deck-title">{deck.name}</h1>
</div>
<div class="flex gap-2">
<a
href="/cards/new?deck={deck.id}"
class="rounded border border-[hsl(var(--color-border))] px-3 py-2 text-sm hover:border-[hsl(var(--color-primary))]"
>
<div class="deck-actions">
<a href="/cards/new?deck={deck.id}" class="btn-outline">
+ {t('deck_detail.new_card')}
</a>
{#if dueCount > 0}
<a
href="/study/{deck.id}"
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))]"
>
<a href="/study/{deck.id}" class="btn-primary">
{t('deck_detail.study_button')} ({t('study.due_count', { n: dueCount })})
</a>
{:else}
<button
disabled
class="rounded bg-[hsl(var(--color-muted-foreground))] px-4 py-2 text-sm text-[hsl(var(--color-background))] opacity-50"
title={t('study.none_due')}
>
<button disabled class="btn-primary opacity-50" title={t('study.none_due')}>
{t('deck_detail.study_button')}
</button>
{/if}
@ -117,19 +134,46 @@
</div>
{#if deck.description}
<p class="mt-2 text-[hsl(var(--color-muted-foreground))]">{deck.description}</p>
<p class="deck-desc">{deck.description}</p>
{/if}
<div class="mt-2 text-sm text-[hsl(var(--color-muted-foreground))]">
<div class="deck-meta">
{tn('decks.card_count', cards.length)} · {t('study.due_count', { n: dueCount })}
</div>
<div class="category-row mt-3">
<!-- Fach-Picker: eingeklappt, same style wie /decks/new -->
<div class="category-picker">
<button
class="cat-trigger"
class:has-category={!!deck.category}
onclick={() => (categoryOpen = !categoryOpen)}
aria-expanded={categoryOpen}
>
{#if deck.category}
<DeckCategoryIcon
category={deck.category}
size={15}
color={deck.color ?? null}
weight="fill"
/>
<span>{DECK_CATEGORY_LABELS[deck.category]}</span>
{:else}
<span class="no-cat">Fach wählen</span>
{/if}
<span class="cat-chevron" class:open={categoryOpen}>
<CaretRight size={12} weight="bold" />
</span>
</button>
{#if categoryOpen}
<div class="category-grid">
{#each DECK_CATEGORY_IDS as id}
<button
class="cat-btn"
class:active={deck.category === id}
type="button"
class="category-btn"
class:selected={deck.category === id}
onclick={() => onSetCategory(id)}
title={DECK_CATEGORY_LABELS[id]}
>
<DeckCategoryIcon
category={id}
@ -137,136 +181,437 @@
color={deck.category === id ? (deck.color ?? null) : null}
weight={deck.category === id ? 'fill' : 'regular'}
/>
<span class="cat-label">{DECK_CATEGORY_LABELS[id]}</span>
<span class="category-label">{DECK_CATEGORY_LABELS[id]}</span>
</button>
{/each}
</div>
{/if}
</div>
{#if cards.length === 0}
<div
class="mt-8 rounded-lg border border-dashed border-[hsl(var(--color-border))] p-12 text-center"
>
<div class="empty-state">
<p class="text-[hsl(var(--color-muted-foreground))]">{t('deck_detail.empty')}</p>
<a
href="/cards/new?deck={deck.id}"
class="mt-4 inline-block text-[hsl(var(--color-primary))] hover:underline"
>{t('deck_detail.empty_cta')}</a
>
<a href="/cards/new?deck={deck.id}" class="empty-cta">{t('deck_detail.empty_cta')}</a>
</div>
{:else}
<!-- Auffächer-Hero: zeigt die obersten Karten als "Hand of Cards" -->
<div class="mt-4">
<DeckFan
{deck}
{cards}
totalCount={cards.length}
{dueCount}
maxFanCards={7}
onCardClick={(card) => goto(`/cards/${card.id}/edit`)}
/>
<ul class="card-grid" aria-label="Karten">
{#each cards as card (card.id)}
<li class="card-item">
<CardSurface
size="md"
as="a"
href="/cards/{card.id}/edit"
colorAccent={deck.color ?? null}
ariaLabel="{card.type}{cardFront(card)}"
>
<div class="card-inner">
<!-- Header: Typ + Menü-Button -->
<div class="card-header">
<span class="card-type">{card.type}</span>
<button
class="card-menu-btn"
onclick={(e) => toggleMenu(card.id, e)}
aria-label="Kartenoptionen"
tabindex="-1"
>
<DotsThree size={18} weight="bold" />
</button>
</div>
<!-- Detail-Liste für Edit/Delete-Operationen — nicht jeder Klick soll
durch die Auffächerung müssen, Power-User wollen die Liste. -->
<details class="mt-2">
<summary class="list-toggle">{t('deck_detail.new_card')} · alle Karten</summary>
<ul class="mt-3 divide-y divide-[hsl(var(--color-border))] rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))]">
{#each cards as card (card.id)}
<li class="group flex items-start justify-between gap-4 px-4 py-3">
<!-- Menü-Popup -->
{#if openMenuId === card.id}
<div class="card-menu-popup" role="menu">
<a
href="/cards/{card.id}/edit"
class="min-w-0 flex-1 hover:text-[hsl(var(--color-primary))]"
class="menu-item"
role="menuitem"
onclick={(e) => e.stopPropagation()}
>
<div class="flex items-center gap-2">
<span
class="rounded bg-[hsl(var(--color-border))] px-2 py-0.5 text-xs text-[hsl(var(--color-muted-foreground))]"
>{card.type}</span>
</div>
<p class="mt-1 truncate text-sm">
{#if card.type === 'cloze'}
<span class="font-medium">{card.fields.text ?? t('common.empty')}</span>
{:else if card.type === 'image-occlusion'}
<span class="font-medium">🖼 image-occlusion</span>
<span class="text-[hsl(var(--color-muted-foreground))]">
· {card.fields.image_ref
? card.fields.image_ref.slice(0, 12)
: t('common.empty')}
</span>
{:else}
<span class="font-medium">{card.fields.front ?? t('common.empty')}</span>
<span class="text-[hsl(var(--color-muted-foreground))]">{card.fields.back ?? t('common.empty')}</span>
{/if}
</p>
<PencilSimple size={14} weight="regular" />
Bearbeiten
</a>
<button
class="text-sm text-[hsl(var(--color-muted-foreground))] opacity-0 hover:text-[hsl(var(--color-error))] focus-visible:opacity-100 group-hover:opacity-100"
onclick={() => onDeleteCard(card.id)}
aria-label={t('deck_detail.card_delete_aria')}
class="menu-item menu-item-danger"
role="menuitem"
onclick={(e) => { e.preventDefault(); e.stopPropagation(); openMenuId = null; onDeleteCard(card.id); }}
>
{t('deck_detail.card_delete_label')}
<Trash size={14} weight="regular" />
Löschen
</button>
</div>
{/if}
<!-- Karteninhalt -->
{#if card.type === 'image-occlusion'}
<div class="card-image-placeholder" aria-hidden="true">
<Image size={32} weight="duotone" />
</div>
{:else}
<div class="card-content">
<div class="card-side card-front md-content">
{@html md(cardFront(card))}
</div>
{#if cardBack(card)}
<div class="card-divider" aria-hidden="true"></div>
<div class="card-side card-back md-content">
{@html md(cardBack(card) ?? '')}
</div>
{/if}
</div>
{/if}
</div>
</CardSurface>
</li>
{/each}
</ul>
</details>
{/if}
{/if}
<style>
.category-row {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.cat-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid transparent;
background: transparent;
.back-link {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: border-color 0.12s, background 0.12s, color 0.12s;
text-decoration: none;
}
.cat-btn:hover {
border-color: hsl(var(--color-border));
.back-link:hover {
color: hsl(var(--color-foreground));
}
.cat-btn.active {
border-color: hsl(var(--color-primary) / 0.4);
.deck-header {
margin-top: 0.5rem;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.deck-title-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.color-dot {
width: 1rem;
height: 1rem;
border-radius: 9999px;
flex-shrink: 0;
}
.deck-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.deck-actions {
display: flex;
gap: 0.5rem;
}
.btn-outline {
display: inline-block;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
text-decoration: none;
color: inherit;
transition: border-color 0.12s;
}
.btn-outline:hover {
border-color: hsl(var(--color-primary));
}
.btn-primary {
display: inline-block;
border-radius: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-family: inherit;
cursor: pointer;
text-decoration: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border: none;
}
.deck-desc {
margin-top: 0.5rem;
color: hsl(var(--color-muted-foreground));
}
.deck-meta {
margin-top: 0.25rem;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
/* ── Category picker ─────────────────────────────────── */
.category-picker {
margin-top: 0.875rem;
}
.cat-trigger {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.3rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
font-size: 0.8125rem;
font-family: inherit;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: border-color 0.12s, color 0.12s;
}
.cat-trigger:hover,
.cat-trigger[aria-expanded='true'] {
border-color: hsl(var(--color-primary) / 0.5);
color: hsl(var(--color-foreground));
}
.cat-trigger.has-category {
color: hsl(var(--color-foreground));
}
.no-cat {
font-style: italic;
}
.cat-chevron {
display: flex;
transition: transform 0.15s ease;
}
.cat-chevron.open {
transform: rotate(90deg);
}
.category-grid {
margin-top: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.category-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.category-btn:hover {
border-color: hsl(var(--color-primary) / 0.5);
color: hsl(var(--color-foreground));
}
.category-btn.selected {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.08);
color: hsl(var(--color-foreground));
}
.cat-label {
font-size: 0.75rem;
.category-label {
font-size: 0.8125rem;
font-weight: 500;
}
.list-toggle {
cursor: pointer;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
padding: 0.5rem 0;
/* ── Card grid ───────────────────────────────────────── */
.card-grid {
list-style: none;
margin: 1.25rem 0 0;
padding: 0;
display: grid;
gap: 1.5rem 1rem;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
justify-items: center;
}
.list-toggle::-webkit-details-marker {
display: none;
.card-item {
width: 100%;
display: flex;
justify-content: center;
}
.list-toggle::before {
content: '▸ ';
display: inline-block;
transition: transform 0.15s ease;
.card-inner {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
padding: 0.75rem 0.75rem 0.875rem 1.25rem;
overflow: hidden;
}
details[open] .list-toggle::before {
transform: rotate(90deg);
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.list-toggle:hover {
.card-type {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: hsl(var(--color-muted-foreground));
}
.card-menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
opacity: 0;
transition: opacity 0.12s, background 0.12s, color 0.12s;
padding: 0;
}
.card-menu-btn:hover {
background: hsl(var(--color-border));
color: hsl(var(--color-foreground));
}
:global(.card-item:hover .card-menu-btn),
:global(.card-item:focus-within .card-menu-btn) {
opacity: 1;
}
.card-menu-popup {
position: absolute;
top: 2.25rem;
right: 0.5rem;
z-index: 10;
min-width: 9rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
box-shadow: 0 8px 24px hsl(var(--color-foreground) / 0.12);
overflow: hidden;
display: flex;
flex-direction: column;
}
.menu-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-family: inherit;
color: hsl(var(--color-foreground));
text-decoration: none;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.menu-item:hover {
background: hsl(var(--color-border) / 0.5);
}
.menu-item-danger {
color: hsl(var(--color-error));
}
/* Wrapper teilt den verfügbaren Raum zwischen Frage und Antwort */
.card-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
margin-top: 0.5rem;
}
.card-side {
flex: 1;
min-height: 0;
overflow: hidden;
font-size: 0.875rem;
line-height: 1.45;
}
.card-front {
font-weight: 500;
}
.card-divider {
margin: 0.5rem 0;
height: 1px;
background: hsl(var(--color-border));
flex-shrink: 0;
}
.card-back {
color: hsl(var(--color-foreground) / 0.75);
}
/* Markdown-rendered content */
.md-content :global(p) {
margin: 0 0 0.35em;
}
.md-content :global(p:last-child) {
margin-bottom: 0;
}
.md-content :global(strong) {
font-weight: 700;
}
.md-content :global(em) {
font-style: italic;
}
.md-content :global(code) {
font-family: ui-monospace, monospace;
font-size: 0.875em;
background: hsl(var(--color-border) / 0.7);
padding: 0.1em 0.35em;
border-radius: 3px;
}
.md-content :global(ul),
.md-content :global(ol) {
margin: 0 0 0.35em 1.1em;
padding: 0;
}
.md-content :global(li) {
margin-bottom: 0.15em;
}
.card-image-placeholder {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--color-muted-foreground));
opacity: 0.4;
}
.empty-state {
margin-top: 2rem;
border: 1px dashed hsl(var(--color-border));
border-radius: 0.5rem;
padding: 3rem;
text-align: center;
}
.empty-cta {
margin-top: 1rem;
display: inline-block;
color: hsl(var(--color-primary));
text-decoration: none;
}
.empty-cta:hover {
text-decoration: underline;
}
</style>