refactor(web): vereinfachte Navigation und inline Deck-Erstellung
- /study-Listing entfernt, Route redirectet auf /decks
- Study-Tab aus Nav entfernt; /study/*-Pfade highlighten Decks-Tab
- /decks/new und /decks/new-ai zusammengeführt: ein Formular mit
zwei Buttons (Anlegen + ✨ Mit KI generieren), new-ai redirectet
- Inline NewDeckCard: Formular klappt in der Kachel auf (gleiche
Größe, scrollbar), Fach als Toggle-Picker, alle Felder volle Breite
- DeckStack: Text linksbündig, Beschreibung ohne Clamp, Titel weiter oben
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9a07454b75
commit
5876f95d85
8 changed files with 732 additions and 390 deletions
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type { Deck } from '@cards/domain';
|
||||
import DeckStack from './DeckStack.svelte';
|
||||
import NewDeckCard from './NewDeckCard.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
interface DeckWithCounts {
|
||||
|
|
@ -30,6 +31,11 @@
|
|||
aria-label={t('decks.title')}
|
||||
style:--has-selection={selectedId ? 1 : 0}
|
||||
>
|
||||
<!-- "Neues Deck"-Kachel immer an erster Stelle -->
|
||||
<li class="grid-cell new-cell" class:fading={selectedId !== null}>
|
||||
<NewDeckCard />
|
||||
</li>
|
||||
|
||||
{#each decks as { deck, cardCount, dueCount } (deck.id)}
|
||||
<li
|
||||
class="grid-cell"
|
||||
|
|
@ -76,7 +82,13 @@
|
|||
z-index: 2;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* Die "Neues Deck"-Kachel faded bei Selektion mit aus, aber skaliert
|
||||
nicht — sie ist kein lernbares Deck. */
|
||||
.new-cell.fading {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.grid-cell {
|
||||
transition: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
<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 CardSurface from './CardSurface.svelte';
|
||||
import DeckCategoryIcon from './DeckCategoryIcon.svelte';
|
||||
|
||||
interface Props {
|
||||
deck: Deck;
|
||||
|
|
@ -18,6 +20,7 @@
|
|||
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 ??
|
||||
|
|
@ -30,7 +33,6 @@
|
|||
</script>
|
||||
|
||||
<div class="stack-wrap" class:empty={!hasContent}>
|
||||
<!-- Untere Karten (rein dekorativ) — sichtbar als Stapel-Hint -->
|
||||
{#if hasContent}
|
||||
{#each layers as layer, i (i)}
|
||||
<div
|
||||
|
|
@ -41,7 +43,6 @@
|
|||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Deckblatt (vordere Karte) — gleiche Optik wie Lern- und Fan-Karten -->
|
||||
<CardSurface
|
||||
size="md"
|
||||
as={href ? 'a' : 'button'}
|
||||
|
|
@ -52,12 +53,22 @@
|
|||
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}
|
||||
|
|
@ -76,7 +87,6 @@
|
|||
aspect-ratio: 5 / 7;
|
||||
}
|
||||
|
||||
/* Hintergrund-Layers — versteckte Karten unter dem Deckblatt */
|
||||
.layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
|
@ -86,52 +96,62 @@
|
|||
box-shadow: 0 1px 3px hsl(var(--color-foreground) / 0.06);
|
||||
}
|
||||
|
||||
/* Inneres Layout des Deckblatt-Inhalts */
|
||||
.cover-inner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem 1.125rem 1.25rem 1.5rem;
|
||||
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.125rem;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-desc {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.4;
|
||||
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.375rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
gap: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
|
@ -146,7 +166,6 @@
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Empty-State: kein Stapel-Hint, dashed Border über CardSurface-Wrapper */
|
||||
.stack-wrap.empty :global(.cover.empty) {
|
||||
border-style: dashed;
|
||||
background: transparent;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
|
||||
const navOptions = $derived([
|
||||
{ id: 'decks', label: t('nav.decks') },
|
||||
{ id: 'study', label: t('nav.study') },
|
||||
{ id: 'explore', label: t('nav.explore') },
|
||||
{ id: 'import', label: t('nav.import') },
|
||||
{ id: 'stats', label: t('nav.stats') },
|
||||
|
|
@ -20,73 +19,136 @@
|
|||
|
||||
const activeNav = $derived.by(() => {
|
||||
const path = page.url.pathname;
|
||||
if (path.startsWith('/decks')) return 'decks';
|
||||
if (path.startsWith('/study')) return 'study';
|
||||
// /explore zeigt aktiv für Marketplace-Surfaces:
|
||||
// /explore selbst, /d/<slug>, /u/<slug>, /me/{published,subscribed,forks}.
|
||||
if (path.startsWith('/decks') || path.startsWith('/study')) return 'decks';
|
||||
if (
|
||||
path.startsWith('/explore') ||
|
||||
path.startsWith('/d/') ||
|
||||
path.startsWith('/u/') ||
|
||||
path.startsWith('/me/')
|
||||
) {
|
||||
return 'explore';
|
||||
}
|
||||
) return 'explore';
|
||||
if (path.startsWith('/import')) return 'import';
|
||||
if (path.startsWith('/stats')) return 'stats';
|
||||
return '';
|
||||
});
|
||||
|
||||
const userInitial = $derived(
|
||||
devUser.user?.name?.charAt(0).toUpperCase() ??
|
||||
devUser.user?.email?.charAt(0).toUpperCase() ??
|
||||
'?'
|
||||
);
|
||||
|
||||
function navTo(id: string) {
|
||||
goto('/' + id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-40 w-full border-b bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))]"
|
||||
>
|
||||
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
||||
<a href="/" class="flex items-center gap-2 font-semibold">
|
||||
<span
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded bg-[hsl(var(--color-primary))] text-[hsl(var(--color-primary-foreground))] text-sm"
|
||||
aria-hidden="true"
|
||||
>
|
||||
C
|
||||
</span>
|
||||
<span>{t('app.name')}</span>
|
||||
<div class="bottom-bar" role="navigation" aria-label={t('common.main_nav')}>
|
||||
<!-- Logo -->
|
||||
<a href="/" class="logo-badge" aria-label={t('app.name')}>C</a>
|
||||
|
||||
<div class="divider" aria-hidden="true"></div>
|
||||
|
||||
<!-- Hauptnavigation -->
|
||||
<PillTabGroup options={navOptions} value={activeNav} onChange={navTo} />
|
||||
|
||||
<div class="divider" aria-hidden="true"></div>
|
||||
|
||||
<!-- Sprache -->
|
||||
<PillTabGroup
|
||||
options={langOptions}
|
||||
value={i18n.current}
|
||||
onChange={(id: string) => i18n.set(id as 'de' | 'en')}
|
||||
sectionLabel={t('common.language_switcher')}
|
||||
/>
|
||||
|
||||
<!-- Account -->
|
||||
{#if devUser.id}
|
||||
<a
|
||||
href="/account"
|
||||
class="account-badge"
|
||||
title={devUser.user?.email ?? devUser.id}
|
||||
aria-label={devUser.user?.email ?? devUser.id}
|
||||
>
|
||||
{userInitial}
|
||||
</a>
|
||||
{:else}
|
||||
<a href="/" class="login-btn">Login</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<nav aria-label={t('common.main_nav')}>
|
||||
<PillTabGroup
|
||||
options={navOptions}
|
||||
value={activeNav}
|
||||
onChange={navTo}
|
||||
/>
|
||||
</nav>
|
||||
<style>
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 1.25rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: hsl(var(--color-surface) / 0.88);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 9999px;
|
||||
box-shadow:
|
||||
0 8px 32px hsl(var(--color-foreground) / 0.12),
|
||||
0 2px 8px hsl(var(--color-foreground) / 0.08);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<PillTabGroup
|
||||
options={langOptions}
|
||||
value={i18n.current}
|
||||
onChange={(id: string) => i18n.set(id as 'de' | 'en')}
|
||||
sectionLabel={t('common.language_switcher')}
|
||||
/>
|
||||
{#if devUser.id}
|
||||
<a
|
||||
href="/account"
|
||||
class="truncate max-w-[200px] text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
|
||||
title={devUser.user?.email ?? devUser.id}
|
||||
>
|
||||
{devUser.user?.email ?? devUser.user?.name ?? devUser.id}
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
href="/"
|
||||
class="rounded bg-[hsl(var(--color-primary))] px-3 py-1 text-[hsl(var(--color-primary-foreground))]"
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 1.25rem;
|
||||
background: hsl(var(--color-border));
|
||||
flex-shrink: 0;
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
|
||||
.logo-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.account-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
color: hsl(var(--color-primary));
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.125rem;
|
||||
}
|
||||
|
||||
.account-badge:hover {
|
||||
background: hsl(var(--color-primary) / 0.2);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
398
apps/web/src/lib/components/NewDeckCard.svelte
Normal file
398
apps/web/src/lib/components/NewDeckCard.svelte
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { type DeckCategoryId, DECK_CATEGORY_IDS, DECK_CATEGORY_LABELS } from '@cards/domain';
|
||||
import { createDeck, generateDeck } from '$lib/api/decks.ts';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte.ts';
|
||||
import CardSurface from './CardSurface.svelte';
|
||||
import DeckCategoryIcon from './DeckCategoryIcon.svelte';
|
||||
|
||||
let open = $state(false);
|
||||
let catOpen = $state(false);
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
let color = $state('#0088ff');
|
||||
let category = $state<DeckCategoryId | undefined>(undefined);
|
||||
let count = $state(15);
|
||||
let language = $state<'de' | 'en'>(i18n.current);
|
||||
let saving = $state(false);
|
||||
let generating = $state(false);
|
||||
let aiError = $state<string | null>(null);
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
catOpen = false;
|
||||
name = '';
|
||||
description = '';
|
||||
color = '#0088ff';
|
||||
category = undefined;
|
||||
count = 15;
|
||||
saving = false;
|
||||
generating = false;
|
||||
aiError = null;
|
||||
}
|
||||
|
||||
function pickCategory(id: DeckCategoryId) {
|
||||
category = category === id ? undefined : id;
|
||||
catOpen = false;
|
||||
}
|
||||
|
||||
async function onManual(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !devUser.id) return;
|
||||
saving = true;
|
||||
try {
|
||||
const deck = await createDeck({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
color,
|
||||
category,
|
||||
});
|
||||
toasts.success(`${deck.name} ✓`);
|
||||
goto(`/decks/${deck.id}`);
|
||||
} catch (err) {
|
||||
toasts.error(t('deck_new.create_failed', { msg: (err as Error).message }));
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onAi() {
|
||||
if (!name.trim() || !devUser.id || generating) return;
|
||||
aiError = null;
|
||||
generating = true;
|
||||
try {
|
||||
const result = await generateDeck({ prompt: name.trim(), count, language });
|
||||
toasts.success(`✨ "${result.deck.name}" mit ${result.cards_created} Karten erstellt`);
|
||||
goto(`/decks/${result.deck.id}`);
|
||||
} catch (err) {
|
||||
aiError = (err as Error).message;
|
||||
generating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="new-wrap">
|
||||
{#if !open}
|
||||
<CardSurface size="md" as="button" onclick={() => (open = true)} ariaLabel={t('deck_new.title')} class="new-card">
|
||||
<div class="new-inner">
|
||||
<span class="new-plus" aria-hidden="true">+</span>
|
||||
<span class="new-label">{t('deck_new.title')}</span>
|
||||
</div>
|
||||
</CardSurface>
|
||||
{:else}
|
||||
<CardSurface size="md" class="form-card">
|
||||
<form class="form-scroll" onsubmit={onManual}>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Name / Thema</span>
|
||||
<input bind:value={name} required maxlength="200" autofocus
|
||||
placeholder="z.B. Französische Revolution" class="input" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">{t('deck_new.description_label')}</span>
|
||||
<textarea bind:value={description} maxlength="2000" rows="2" class="input"></textarea>
|
||||
</label>
|
||||
|
||||
<!-- Fach: einzelner Trigger-Button, klappt Picker inline auf -->
|
||||
<div class="field">
|
||||
<span class="label">Fach</span>
|
||||
<button type="button" class="picker-trigger" onclick={() => (catOpen = !catOpen)}
|
||||
aria-expanded={catOpen}>
|
||||
{#if category}
|
||||
<DeckCategoryIcon category={category} size={14} color={color} weight="fill" />
|
||||
<span>{DECK_CATEGORY_LABELS[category]}</span>
|
||||
{:else}
|
||||
<span class="placeholder">Kein Fach</span>
|
||||
{/if}
|
||||
<span class="chevron" class:flipped={catOpen} aria-hidden="true">▾</span>
|
||||
</button>
|
||||
{#if catOpen}
|
||||
<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]}
|
||||
aria-pressed={category === id}>
|
||||
<DeckCategoryIcon category={id} size={16}
|
||||
color={category === id ? color : null}
|
||||
weight={category === id ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">{t('deck_new.color_label')}</span>
|
||||
<input type="color" bind:value={color} class="color-input" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Anzahl Karten (KI)</span>
|
||||
<input type="number" bind:value={count} min="3" max="40" class="input" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Sprache (KI)</span>
|
||||
<select bind:value={language} class="input">
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{#if aiError}
|
||||
<p class="ai-error" role="alert">{aiError}</p>
|
||||
{/if}
|
||||
{#if generating}
|
||||
<p class="ai-hint" aria-live="polite">Generiere… ca. 10–60 s</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={saving || generating || !name.trim()} class="btn-primary">
|
||||
{saving ? t('deck_new.creating') : t('deck_new.create')}
|
||||
</button>
|
||||
<button type="button" disabled={generating || saving || !name.trim()} onclick={onAi} class="btn-ai">
|
||||
{generating ? '✨ Generiere…' : '✨ Mit KI generieren'}
|
||||
</button>
|
||||
<button type="button" onclick={close} class="btn-cancel">
|
||||
{t('deck_new.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</CardSurface>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.new-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 18rem;
|
||||
aspect-ratio: 5 / 7;
|
||||
}
|
||||
|
||||
.new-wrap :global(.new-card) {
|
||||
border-style: dashed;
|
||||
background: transparent;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.new-wrap :global(.new-card:hover) {
|
||||
border-color: hsl(var(--color-primary) / 0.6);
|
||||
background: hsl(var(--color-primary) / 0.04);
|
||||
}
|
||||
|
||||
.new-inner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.new-plus {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.new-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.new-wrap :global(.form-card) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.form-scroll {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1875rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.3125rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-family: inherit;
|
||||
border-radius: 0.3125rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-card));
|
||||
color: hsl(var(--color-foreground));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.input:focus {
|
||||
outline: 2px solid hsl(var(--color-primary) / 0.4);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Fach-Trigger — sieht aus wie ein Select */
|
||||
.picker-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
width: 100%;
|
||||
padding: 0.3125rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-family: inherit;
|
||||
border-radius: 0.3125rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-card));
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.picker-trigger:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.chevron {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.chevron.flipped {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Kategorie-Picker — 4 Spalten Icon-Grid, erscheint direkt unter dem Trigger */
|
||||
.cat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.cat-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 0.3125rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-surface));
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
}
|
||||
.cat-btn:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
.cat-btn.selected {
|
||||
border-color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.08);
|
||||
}
|
||||
|
||||
/* Farbe — volle Breite, feste Höhe wie ein Input */
|
||||
.color-input {
|
||||
width: 100%;
|
||||
height: 2rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.3125rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ai-error {
|
||||
margin: 0;
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
.ai-hint {
|
||||
margin: 0;
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
margin-top: auto;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
font-family: inherit;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-ai {
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
font-family: inherit;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(var(--color-primary));
|
||||
background: transparent;
|
||||
color: hsl(var(--color-primary));
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-ai:hover:not(:disabled) {
|
||||
background: hsl(var(--color-primary) / 0.08);
|
||||
}
|
||||
.btn-ai:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
font-family: inherit;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-cancel:hover {
|
||||
background: hsl(var(--color-surface-hover));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -56,25 +56,14 @@
|
|||
}
|
||||
|
||||
function handleSelect(deckId: string) {
|
||||
// Stufe 1: andere Decks weichen, selected hebt sich.
|
||||
selectedId = deckId;
|
||||
// URL wechselt nach kurzer Verzögerung. Klick auf einen Stapel
|
||||
// landet direkt im Lern-Modus — die Detail-View (/decks/<id>)
|
||||
// bleibt über den "Karten verwalten"-Link im Study-Header
|
||||
// erreichbar.
|
||||
setTimeout(() => {
|
||||
goto(`/study/${deckId}`);
|
||||
}, 220);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="header">
|
||||
<h1 class="title">{t('decks.title')}</h1>
|
||||
<div class="actions">
|
||||
<a class="btn-secondary" href="/decks/new-ai" title="Mit KI generieren">✨ KI-Deck</a>
|
||||
<a class="btn-primary" href="/decks/new">{t('decks.new')}</a>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="title">{t('decks.title')}</h1>
|
||||
|
||||
<div class="inbox">
|
||||
<InboxBanner />
|
||||
|
|
@ -84,61 +73,17 @@
|
|||
<p class="muted">{t('decks.loading')}</p>
|
||||
{:else if error}
|
||||
<p class="error">{t('decks.error', { msg: error })}</p>
|
||||
{:else if decks.length === 0}
|
||||
<div class="empty">
|
||||
<p class="muted">{t('decks.empty')}</p>
|
||||
<a class="empty-cta" href="/decks/new">{t('decks.empty_cta')} →</a>
|
||||
</div>
|
||||
{:else}
|
||||
<DeckGrid {decks} {selectedId} onSelect={handleSelect} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
color: hsl(var(--color-primary));
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
}
|
||||
|
||||
.inbox {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
|
@ -152,21 +97,4 @@
|
|||
color: hsl(var(--color-error));
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin-top: 2rem;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-cta {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
.empty-cta:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,146 +1,4 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { generateDeck } from '$lib/api/decks.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
let prompt = $state('');
|
||||
let count = $state(15);
|
||||
let language = $state<'de' | 'en'>(i18n.current);
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const examples = [
|
||||
'Deutsche Hunderassen mit Charaktermerkmalen',
|
||||
'Italienische Verben (essere, avere) Konjugation',
|
||||
'Wichtige Konzepte aus 1984 von Orwell',
|
||||
'Häufige React-Hooks und ihre typischen Use-Cases',
|
||||
'Pflanzenfamilien mit jeweils 3 typischen Vertretern',
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
if (!devUser.id) goto('/');
|
||||
});
|
||||
|
||||
async function onSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (busy || !prompt.trim()) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await generateDeck({
|
||||
prompt: prompt.trim(),
|
||||
count,
|
||||
language,
|
||||
});
|
||||
toasts.success(`✨ Deck "${result.deck.name}" mit ${result.cards_created} Karten erstellt`);
|
||||
goto(`/decks/${result.deck.id}`);
|
||||
} catch (err) {
|
||||
error = (err as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
goto('/decks/new?tab=ai', { replaceState: true });
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>KI-Deck · Cards</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||
<a href="/decks" class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]">
|
||||
← {t('nav.decks')}
|
||||
</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold">✨ Deck mit KI erstellen</h1>
|
||||
<p class="mt-2 text-sm text-[hsl(var(--color-muted-foreground))]">
|
||||
Beschreibe ein Thema, und mana-llm baut ein Deck mit Karteikarten daraus. Du kannst die Karten
|
||||
danach jederzeit editieren oder ergänzen.
|
||||
</p>
|
||||
|
||||
<form class="mt-6 space-y-4" onsubmit={onSubmit}>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Thema / Prompt</span>
|
||||
<textarea
|
||||
bind:value={prompt}
|
||||
required
|
||||
rows="4"
|
||||
maxlength="500"
|
||||
placeholder="z.B. Deutsche Hunderassen mit ihren wichtigsten Charaktermerkmalen"
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
Klar formulierte, abgegrenzte Themen funktionieren besser als zu breite („alles über
|
||||
Geschichte" → eher: „französische Revolution: Schlüsselereignisse 1789-1799").
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Anzahl Karten</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={count}
|
||||
min="3"
|
||||
max="40"
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">3–40 (Server-Cap).</p>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Sprache</span>
|
||||
<select
|
||||
bind:value={language}
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded border border-[hsl(var(--color-error))]/40 bg-[hsl(var(--color-error))]/10 p-3 text-sm text-[hsl(var(--color-error))]" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !prompt.trim()}
|
||||
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
{busy ? '✨ Generiere…' : '✨ Deck generieren'}
|
||||
</button>
|
||||
<a href="/decks" class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
|
||||
>{t('deck_new.cancel')}</a
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if busy}
|
||||
<p class="text-xs text-[hsl(var(--color-muted-foreground))]" aria-live="polite">
|
||||
mana-llm denkt nach. Bei {count} Karten typischerweise 10–60 Sekunden.
|
||||
</p>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<aside class="mt-10 rounded-lg border border-dashed border-[hsl(var(--color-border))] p-4">
|
||||
<div class="text-xs font-medium text-[hsl(var(--color-foreground))]">Beispiele zum Klicken:</div>
|
||||
<ul class="mt-2 space-y-1.5">
|
||||
{#each examples as ex (ex)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="text-left text-xs text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-primary))]"
|
||||
onclick={() => (prompt = ex)}
|
||||
>
|
||||
→ {ex}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createDeck } from '$lib/api/decks.ts';
|
||||
import { onMount } from 'svelte';
|
||||
import { type DeckCategoryId, DECK_CATEGORY_IDS, DECK_CATEGORY_LABELS } from '@cards/domain';
|
||||
import { createDeck, generateDeck } from '$lib/api/decks.ts';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte.ts';
|
||||
import DeckCategoryIcon from '$lib/components/DeckCategoryIcon.svelte';
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
let color = $state('#0088ff');
|
||||
let category = $state<DeckCategoryId | undefined>(undefined);
|
||||
let count = $state(15);
|
||||
let language = $state<'de' | 'en'>(i18n.current);
|
||||
let saving = $state(false);
|
||||
let generating = $state(false);
|
||||
let aiError = $state<string | null>(null);
|
||||
|
||||
async function onSubmit(e: SubmitEvent) {
|
||||
onMount(() => {
|
||||
if (!devUser.id) goto('/');
|
||||
});
|
||||
|
||||
function toggleCategory(id: DeckCategoryId) {
|
||||
category = category === id ? undefined : id;
|
||||
}
|
||||
|
||||
async function onManual(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
saving = true;
|
||||
|
|
@ -18,14 +35,29 @@
|
|||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
color,
|
||||
category,
|
||||
});
|
||||
toasts.success(`${deck.name} ✓`);
|
||||
goto(`/decks/${deck.id}`);
|
||||
} catch (e) {
|
||||
toasts.error(t('deck_new.create_failed', { msg: (e as Error).message }));
|
||||
} catch (err) {
|
||||
toasts.error(t('deck_new.create_failed', { msg: (err as Error).message }));
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onAi() {
|
||||
if (!name.trim() || generating) return;
|
||||
aiError = null;
|
||||
generating = true;
|
||||
try {
|
||||
const result = await generateDeck({ prompt: name.trim(), count, language });
|
||||
toasts.success(`✨ "${result.deck.name}" mit ${result.cards_created} Karten erstellt`);
|
||||
goto(`/decks/${result.deck.id}`);
|
||||
} catch (err) {
|
||||
aiError = (err as Error).message;
|
||||
generating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-xl">
|
||||
|
|
@ -34,13 +66,14 @@
|
|||
>
|
||||
<h1 class="mt-2 text-2xl font-semibold">{t('deck_new.title')}</h1>
|
||||
|
||||
<form class="mt-6 space-y-4" onsubmit={onSubmit}>
|
||||
<form class="mt-6 space-y-4" onsubmit={onManual}>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">{t('deck_new.name_label')}</span>
|
||||
<span class="text-sm font-medium">Name / Thema</span>
|
||||
<input
|
||||
bind:value={name}
|
||||
required
|
||||
maxlength="200"
|
||||
placeholder="z.B. Französische Revolution"
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
|
@ -55,26 +88,134 @@
|
|||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">{t('deck_new.color_label')}</span>
|
||||
<input
|
||||
type="color"
|
||||
bind:value={color}
|
||||
class="mt-1 h-10 w-20 rounded border border-[hsl(var(--color-border))] bg-transparent"
|
||||
/>
|
||||
</label>
|
||||
<div class="block">
|
||||
<span class="text-sm font-medium">Fach</span>
|
||||
<div class="category-grid mt-2">
|
||||
{#each DECK_CATEGORY_IDS as id}
|
||||
<button
|
||||
type="button"
|
||||
class="category-btn"
|
||||
class:selected={category === id}
|
||||
onclick={() => toggleCategory(id)}
|
||||
title={DECK_CATEGORY_LABELS[id]}
|
||||
>
|
||||
<DeckCategoryIcon
|
||||
category={id}
|
||||
size={22}
|
||||
color={category === id ? color : null}
|
||||
weight={category === id ? 'fill' : 'regular'}
|
||||
/>
|
||||
<span class="category-label">{DECK_CATEGORY_LABELS[id]}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex gap-4">
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">{t('deck_new.color_label')}</span>
|
||||
<input
|
||||
type="color"
|
||||
bind:value={color}
|
||||
class="mt-1 h-10 w-20 rounded border border-[hsl(var(--color-border))] bg-transparent"
|
||||
/>
|
||||
</label>
|
||||
<label class="block flex-1">
|
||||
<span class="text-sm font-medium">Anzahl Karten (KI)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={count}
|
||||
min="3"
|
||||
max="40"
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Sprache</span>
|
||||
<select
|
||||
bind:value={language}
|
||||
class="mt-1 block rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if aiError}
|
||||
<div
|
||||
class="rounded border border-[hsl(var(--color-error))]/40 bg-[hsl(var(--color-error))]/10 p-3 text-sm text-[hsl(var(--color-error))]"
|
||||
role="alert"
|
||||
>
|
||||
{aiError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if generating}
|
||||
<p class="text-xs text-[hsl(var(--color-muted-foreground))]" aria-live="polite">
|
||||
mana-llm generiert… Bei {count} Karten typischerweise 10–60 Sekunden.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !name.trim()}
|
||||
disabled={saving || generating || !name.trim()}
|
||||
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
{saving ? t('deck_new.creating') : t('deck_new.create')}
|
||||
</button>
|
||||
<a href="/decks" class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
|
||||
>{t('deck_new.cancel')}</a
|
||||
<button
|
||||
type="button"
|
||||
disabled={generating || saving || !name.trim()}
|
||||
onclick={onAi}
|
||||
class="rounded border border-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary))] disabled:opacity-50 hover:bg-[hsl(var(--color-primary)/0.08)]"
|
||||
>
|
||||
{generating ? '✨ Generiere…' : '✨ Mit KI generieren'}
|
||||
</button>
|
||||
<a
|
||||
href="/decks"
|
||||
class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
|
||||
>{t('deck_new.cancel')}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(4.5rem, 1fr));
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
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));
|
||||
}
|
||||
|
||||
.category-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.03em;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,80 +1,4 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Deck } from '@cards/domain';
|
||||
import { listDecks } from '$lib/api/decks.ts';
|
||||
import { listDueReviews } from '$lib/api/reviews.ts';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import InboxBanner from '$lib/components/InboxBanner.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
type Item = { deck: Deck; due: number };
|
||||
|
||||
let items = $state<Item[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
if (!devUser.id) {
|
||||
goto('/');
|
||||
return;
|
||||
}
|
||||
const r = await listDecks();
|
||||
const counts = await Promise.all(
|
||||
r.decks.map(async (d) => {
|
||||
try {
|
||||
const due = await listDueReviews({ deckId: d.id, limit: 1 });
|
||||
// limit=1 gibt total zurück (alle fälligen, gecappt nur an results-Größe)
|
||||
// für korrekte Counts müssen wir ohne Limit fragen — pragmatisch:
|
||||
const all = await listDueReviews({ deckId: d.id, limit: 500 });
|
||||
return { deck: d, due: all.total };
|
||||
} catch {
|
||||
return { deck: d, due: 0 };
|
||||
}
|
||||
})
|
||||
);
|
||||
items = counts;
|
||||
loading = false;
|
||||
});
|
||||
goto('/decks', { replaceState: true });
|
||||
</script>
|
||||
|
||||
<h1 class="text-2xl font-semibold">{t('study.title')}</h1>
|
||||
|
||||
<div class="mt-4">
|
||||
<InboxBanner />
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="mt-8 text-[hsl(var(--color-muted-foreground))]">{t('study_session.loading')}</p>
|
||||
{:else}
|
||||
<ul class="mt-6 space-y-2">
|
||||
{#each items as it (it.deck.id)}
|
||||
<li
|
||||
class="flex items-center justify-between rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
{#if it.deck.color}
|
||||
<span
|
||||
class="h-3 w-3 rounded-full"
|
||||
style="background:{it.deck.color}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
<span class="truncate font-medium">{it.deck.name}</span>
|
||||
<span class="text-sm text-[hsl(var(--color-muted-foreground))]">
|
||||
{t('study.due_count', { n: it.due })}
|
||||
</span>
|
||||
</div>
|
||||
{#if it.due > 0}
|
||||
<a
|
||||
href="/study/{it.deck.id}"
|
||||
class="rounded bg-[hsl(var(--color-primary))] px-3 py-1.5 text-sm text-[hsl(var(--color-primary-foreground))]"
|
||||
>
|
||||
{t('study.study_now')}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-sm text-[hsl(var(--color-muted-foreground))]" aria-label={t('study.none_due')}>—</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue