diff --git a/apps/web/src/lib/components/CardSurface.svelte b/apps/web/src/lib/components/CardSurface.svelte new file mode 100644 index 0000000..9c9ed56 --- /dev/null +++ b/apps/web/src/lib/components/CardSurface.svelte @@ -0,0 +1,187 @@ + + +{#if as === 'a' || href} + + {#if colorAccent} + + {/if} + {#if children}{@render children()}{/if} + +{:else if as === 'button'} + +{:else} + + {#if colorAccent} + + {/if} + {#if children}{@render children()}{/if} + +{/if} + + diff --git a/apps/web/src/lib/components/DeckFan.svelte b/apps/web/src/lib/components/DeckFan.svelte new file mode 100644 index 0000000..a18f0d0 --- /dev/null +++ b/apps/web/src/lib/components/DeckFan.svelte @@ -0,0 +1,205 @@ + + +
+ + {#each stackLayers(deck.id, 3) as layer, i (i)} + + {/each} + + +
+ {#each fanCards as card, i (card.id)} +
+ onCardClick?.(card)} + ariaLabel={t('deck_detail.card_open', { type: card.type })} + title={previewText(card)} + > +
+ {card.type} + {previewText(card)} +
+
+
+ {/each} +
+ + {#if hiddenCount > 0} +

+ + {tn('decks.card_count_more', hiddenCount)} +

+ {/if} +
+ + diff --git a/apps/web/src/lib/components/DeckGrid.svelte b/apps/web/src/lib/components/DeckGrid.svelte new file mode 100644 index 0000000..d4b7a14 --- /dev/null +++ b/apps/web/src/lib/components/DeckGrid.svelte @@ -0,0 +1,90 @@ + + + + + diff --git a/apps/web/src/lib/components/DeckStack.svelte b/apps/web/src/lib/components/DeckStack.svelte new file mode 100644 index 0000000..05120d7 --- /dev/null +++ b/apps/web/src/lib/components/DeckStack.svelte @@ -0,0 +1,154 @@ + + +
+ + {#if hasContent} + {#each layers as layer, i (i)} + + {/each} + {/if} + + + +
+
+

{deck.name}

+ {#if deck.description} +

{deck.description}

+ {/if} +
+
+ {tn('decks.card_count', cardCount)} + {#if dueCount > 0} + {t('study.due_count', { n: dueCount })} + {/if} +
+
+
+
+ + diff --git a/apps/web/src/lib/utils/deck-tilt.ts b/apps/web/src/lib/utils/deck-tilt.ts new file mode 100644 index 0000000..914e44d --- /dev/null +++ b/apps/web/src/lib/utils/deck-tilt.ts @@ -0,0 +1,50 @@ +/** + * Deterministische Pseudo-Zufall-Werte basierend auf Deck-ID. + * Gleiche ID liefert immer die gleichen Werte — beim Reload bleibt der + * Stapel optisch konsistent (kein "lebendiges Zucken" zwischen Mounts). + */ + +function cyrb53(str: string, seed = 0): number { + let h1 = 0xdeadbeef ^ seed; + let h2 = 0x41c6ce57 ^ seed; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +} + +/** + * Liefert n deterministische Werte aus [0, 1) für eine ID + Salt. + * Salt unterscheidet z.B. den Tilt von Karte 1, 2, 3 für die gleiche + * Deck-ID. + */ +export function deterministicRandoms(id: string, count: number): number[] { + return Array.from({ length: count }, (_, i) => { + const v = cyrb53(id, i + 1); + return (v % 10000) / 10000; + }); +} + +export interface CardLayer { + tilt: number; // Grad, ~ -2 .. +2 + dx: number; // px, ~ -3 .. +3 + dy: number; // px, ~ -2 .. +2 +} + +/** + * Liefert Stapel-Layer-Tilts für eine Deck-ID. Index 0 ist die unterste + * Karte, der höchste Index ist die vorderste. Sub-pixel-Versätze und + * leichte Rotationen ergeben das "echte Karten"-Gefühl. + */ +export function stackLayers(id: string, count: number): CardLayer[] { + const r = deterministicRandoms(id, count * 3); + return Array.from({ length: count }, (_, i) => ({ + tilt: (r[i * 3] - 0.5) * 4, // -2 .. +2 deg + dx: (r[i * 3 + 1] - 0.5) * 6, // -3 .. +3 px + dy: (r[i * 3 + 2] - 0.5) * 4, // -2 .. +2 px + })); +} diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index ed3a694..35e63dd 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import Header from '$lib/components/Header.svelte'; import ToastStack from '$lib/components/ToastStack.svelte'; import { i18n, t } from '$lib/i18n/index.svelte.ts'; + import { page } from '$app/state'; let { children } = $props(); @@ -13,14 +14,50 @@ document.documentElement.setAttribute('lang', i18n.current); } }); + + // Im Lernmodus (`/study/`) wird der globale Header ausgeblendet, + // damit die Lernkarte volle visuelle Konzentration kriegt. Die + // Übersicht `/study` (ohne deckId) zeigt den Header weiter. + const isFocusMode = $derived.by(() => { + const path = page.url.pathname; + return /^\/study\/[^/]+\/?$/.test(path); + }); -
+{#if !isFocusMode} +
+{/if} -
+
{@render children?.()}
+ + diff --git a/apps/web/src/routes/decks/+page.svelte b/apps/web/src/routes/decks/+page.svelte index 98ba17c..0962bb9 100644 --- a/apps/web/src/routes/decks/+page.svelte +++ b/apps/web/src/routes/decks/+page.svelte @@ -2,15 +2,24 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import type { Deck } from '@cards/domain'; - import { listDecks, deleteDeck } from '$lib/api/decks.ts'; + import { listDecks } from '$lib/api/decks.ts'; + import { listCards } from '$lib/api/cards.ts'; + import { listDueReviews } from '$lib/api/reviews.ts'; import { devUser } from '$lib/auth/dev-stub.svelte.ts'; - import { toasts } from '$lib/stores/toasts.svelte.ts'; import InboxBanner from '$lib/components/InboxBanner.svelte'; + import DeckGrid from '$lib/components/DeckGrid.svelte'; import { t } from '$lib/i18n/index.svelte.ts'; - let decks = $state([]); + interface DeckWithCounts { + deck: Deck; + cardCount: number; + dueCount: number; + } + + let decks = $state([]); let loading = $state(true); let error = $state(null); + let selectedId = $state(null); onMount(async () => { if (!devUser.id) { @@ -24,7 +33,20 @@ try { loading = true; const r = await listDecks(); - decks = r.decks; + const enriched = await Promise.all( + r.decks.map(async (deck) => { + try { + const [c, due] = await Promise.all([ + listCards(deck.id), + listDueReviews({ deckId: deck.id, limit: 500 }), + ]); + return { deck, cardCount: c.cards.length, dueCount: due.total }; + } catch { + return { deck, cardCount: 0, dueCount: 0 }; + } + }), + ); + decks = enriched; error = null; } catch (e) { error = (e as Error).message; @@ -33,86 +55,118 @@ } } - async function onDelete(id: string, name: string) { - if (!confirm(t('decks.delete_confirm', { name }))) return; - try { - await deleteDeck(id); - toasts.success(t('decks.deleted', { name })); - await refresh(); - } catch (e) { - toasts.error(t('decks.delete_failed', { msg: (e as Error).message })); - } + 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/) + // bleibt über den "Karten verwalten"-Link im Study-Header + // erreichbar. + setTimeout(() => { + goto(`/study/${deckId}`); + }, 220); } -
-

{t('decks.title')}

-
- - ✨ KI-Deck - - {t('decks.new')} +
+

{t('decks.title')}

+
-
+
{#if loading} -

{t('decks.loading')}

+

{t('decks.loading')}

{:else if error} -

{t('decks.error', { msg: error })}

+

{t('decks.error', { msg: error })}

{:else if decks.length === 0} -
-

{t('decks.empty')}

- {t('decks.empty_cta')} → +
+

{t('decks.empty')}

+ {t('decks.empty_cta')} →
{:else} - + {/if} + + diff --git a/apps/web/src/routes/decks/[id]/+page.svelte b/apps/web/src/routes/decks/[id]/+page.svelte index f544b67..9d467ca 100644 --- a/apps/web/src/routes/decks/[id]/+page.svelte +++ b/apps/web/src/routes/decks/[id]/+page.svelte @@ -9,6 +9,7 @@ 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'; let deck = $state(null); let cards = $state([]); @@ -58,11 +59,11 @@ {#if loading} -

{t('decks.loading')}

+

{t('decks.loading')}

{:else if error} -

{t('decks.error', { msg: error })}

+

{t('decks.error', { msg: error })}

{:else if deck} - ← {t('nav.decks')} @@ -80,21 +81,21 @@
+ {t('deck_detail.new_card')} {#if dueCount > 0} {t('deck_detail.study_button')} ({t('study.due_count', { n: dueCount })}) {:else}
{#if deck.description} -

{deck.description}

+

{deck.description}

{/if} -
+
{tn('decks.card_count', cards.length)} · {t('study.due_count', { n: dueCount })}
{#if cards.length === 0}
-

{t('deck_detail.empty')}

+

{t('deck_detail.empty')}

{t('deck_detail.empty_cta')}
{:else} -
    + +
    + goto(`/cards/${card.id}/edit`)} + /> +
    + + +
    + {t('deck_detail.new_card')} · alle Karten + +
    {/if} {/if} + + diff --git a/apps/web/src/routes/study/[deckId]/+page.svelte b/apps/web/src/routes/study/[deckId]/+page.svelte index 7c3325e..cce1a2b 100644 --- a/apps/web/src/routes/study/[deckId]/+page.svelte +++ b/apps/web/src/routes/study/[deckId]/+page.svelte @@ -16,10 +16,12 @@ import { toasts } from '$lib/stores/toasts.svelte.ts'; import { t } from '$lib/i18n/index.svelte.ts'; import ImageOcclusionView from '$lib/components/ImageOcclusionView.svelte'; + import CardSurface from '$lib/components/CardSurface.svelte'; const deckId = $derived(page.params.deckId ?? ''); let deckName = $state(''); + let deckColor = $state(null); let queue = $state([]); let queueIndex = $state(0); let revealed = $state(false); @@ -99,6 +101,7 @@ listDueReviews({ deckId, limit: 200 }), ]); deckName = d.name; + deckColor = d.color ?? null; queue = due.reviews; } catch (e) { toasts.error(`Sitzung konnte nicht geladen werden: ${(e as Error).message}`); @@ -152,117 +155,398 @@ } -
    - {t('study_session.back')} -

    {deckName}

    -

    - {#if !loading && !isDone} - {queueIndex + 1} / {queue.length} - {/if} -

    +
    + + +
    {#if loading} -

    {t('study_session.loading')}

    +

    {t('study_session.loading')}

    {:else if queue.length === 0} -
    -

    {t('study.none_due')} 🎉

    - - {t('card_edit.back')} - +
    + +
    + +

    {t('study.none_due')}

    + {t('card_edit.back')} +
    +
    {:else if isDone} -
    -

    {t('study_session.all_done')}

    -

    - {t('study_session.stats', { reviewed: stats.reviewed, again: stats.again })} -

    - +
    + +
    +

    {t('study_session.all_done')}

    +

    {t('study_session.stats', { reviewed: stats.reviewed, again: stats.again })}

    + +
    +
    {:else} -
    -

    - {revealed ? t('card_new.preview_label') : t('study_session.reveal')} -

    - {#if isImageOcclusion && imageOcclusionData} - - {:else} -
    {@html promptHtml}
    - - {#if revealed} -
    -
    {@html answerHtml}
    - {/if} - {/if} -
    +
    + +
    +

    + {revealed ? t('card_new.preview_label') : t('study_session.reveal')} +

    + {#if isImageOcclusion && imageOcclusionData} + + {:else} +
    {@html promptHtml}
    + {#if revealed} +
    +
    {@html answerHtml}
    + {/if} + {/if} +
    +
    +
    {#if !revealed} -
    -
    {:else} -
    - - - -
    {/if} -

    - {t('study_session.grade_hint')} -

    +

    {t('study_session.grade_hint')}

    {/if} +
    + +