cards/apps/web/src/routes/study/[deckId]/+page.svelte
Till JS f2f752e9ee feat(web): apiErrorMessage-Utility + MultipleChoice-Fallback
- Neue Utility `apiErrorMessage()` in `$lib/api/error.ts`: liest `body.detail`
  / `body.error` aus ApiError-Responses statt generischer "(err as Error).message"
  — 22 Dateien auf die Utility umgestellt, keine rohen Type-Casts mehr
- MultipleChoiceView: Fallback-UI wenn < 1 Distractor verfügbar — zeigt
  Antwort direkt + Nochmal/Gewusst-Buttons statt kaputter 1-Option-Auswahl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:27:19 +02:00

666 lines
18 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import {
import { apiErrorMessage } from '$lib/api/error.ts';
clusterIdForSubIndex,
maskForSubIndex,
renderClozePrompt,
renderClozeAnswer,
type Rating,
} from '@cards/domain';
import { getDeck } from '$lib/api/decks.ts';
import { listDueReviews, gradeReview, type DueReview } from '$lib/api/reviews.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { renderMarkdown } from '$lib/markdown.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts';
import ImageOcclusionView from '$lib/components/ImageOcclusionView.svelte';
import AudioFrontView from '$lib/components/AudioFrontView.svelte';
import TypingView from '$lib/components/TypingView.svelte';
import MultipleChoiceView from '$lib/components/MultipleChoiceView.svelte';
import CardSurface from '$lib/components/CardSurface.svelte';
const deckId = $derived(page.params.deckId ?? '');
let deckName = $state('');
let deckColor = $state<string | null>(null);
let queue = $state<DueReview[]>([]);
let queueIndex = $state(0);
let revealed = $state(false);
let loading = $state(true);
let busy = $state(false);
let stats = $state({ reviewed: 0, again: 0 });
const current = $derived(queue[queueIndex]);
const isDone = $derived(!loading && queueIndex >= queue.length);
const cardColor = $derived(
(current?.card?.fields as Record<string, string>)?.color ?? deckColor,
);
const promptMarkdown = $derived.by(() => {
const c = current;
if (!c?.card) return '';
const subIndex = c.sub_index;
const fields = c.card.fields as Record<string, string>;
switch (c.card.type) {
case 'basic':
return fields.front ?? '';
case 'basic-reverse':
return subIndex === 0 ? (fields.front ?? '') : (fields.back ?? '');
case 'cloze': {
const text = fields.text ?? '';
const clusterId = clusterIdForSubIndex(text, subIndex);
return clusterId !== null ? renderClozePrompt(text, clusterId) : text;
}
default:
return fields.front ?? '';
}
});
const answerMarkdown = $derived.by(() => {
const c = current;
if (!c?.card) return '';
const subIndex = c.sub_index;
const fields = c.card.fields as Record<string, string>;
switch (c.card.type) {
case 'basic':
return fields.back ?? '';
case 'basic-reverse':
return subIndex === 0 ? (fields.back ?? '') : (fields.front ?? '');
case 'cloze': {
const text = fields.text ?? '';
const clusterId = clusterIdForSubIndex(text, subIndex);
const body = clusterId !== null ? renderClozeAnswer(text, clusterId) : text;
const extra = fields.extra ? `\n\n${fields.extra}` : '';
return body + extra;
}
default:
return fields.back ?? '';
}
});
const promptHtml = $derived(renderMarkdown(promptMarkdown));
const answerHtml = $derived(renderMarkdown(answerMarkdown));
const isImageOcclusion = $derived(current?.card?.type === 'image-occlusion');
const imageOcclusionData = $derived.by(() => {
const c = current;
if (!c?.card || c.card.type !== 'image-occlusion') return null;
const fields = c.card.fields as Record<string, string>;
const mask = maskForSubIndex(fields.mask_regions ?? '', c.sub_index);
return {
imageRef: fields.image_ref ?? '',
maskRegionsJson: fields.mask_regions ?? '[]',
activeMaskId: mask?.id ?? null,
};
});
const isMultipleChoice = $derived(current?.card?.type === 'multiple-choice');
const multipleChoiceData = $derived.by(() => {
const c = current;
if (!c?.card || c.card.type !== 'multiple-choice') return null;
const fields = c.card.fields as Record<string, string>;
return {
answer: fields.answer ?? '',
distractorPool: fields.distractor_pool || undefined,
};
});
const isTyping = $derived(current?.card?.type === 'typing');
const typingData = $derived.by(() => {
const c = current;
if (!c?.card || c.card.type !== 'typing') return null;
const fields = c.card.fields as Record<string, string>;
return {
answer: fields.answer ?? '',
aliases: fields.aliases || undefined,
};
});
const isAudioFront = $derived(current?.card?.type === 'audio-front');
const audioFrontData = $derived.by(() => {
const c = current;
if (!c?.card || c.card.type !== 'audio-front') return null;
const fields = c.card.fields as Record<string, string>;
return {
audioRef: fields.audio_ref ?? '',
frontText: fields.front || undefined,
};
});
onMount(async () => {
if (!devUser.id) {
goto('/');
return;
}
try {
const [d, due] = await Promise.all([
getDeck(deckId),
listDueReviews({ deckId, limit: 200 }),
]);
deckName = d.name;
deckColor = d.color ?? null;
queue = due.reviews;
} catch (e) {
toasts.error(`Sitzung konnte nicht geladen werden: ${apiErrorMessage(e)}`);
goto('/study');
return;
}
loading = false;
window.addEventListener('keydown', onKey);
});
onDestroy(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', onKey);
}
});
function onKey(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
if (busy || isDone) return;
if (isTyping) return; // TypingView übernimmt per svelte:window
if (isMultipleChoice) return; // MultipleChoiceView übernimmt per svelte:window
if (!revealed) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
revealed = true;
}
return;
}
if (e.key === '1') return grade('again');
if (e.key === '2') return grade('hard');
if (e.key === '3' || e.key === ' ' || e.key === 'Enter') return grade('good');
if (e.key === '4') return grade('easy');
}
async function grade(rating: Rating) {
const c = current;
if (!c || busy) return;
busy = true;
try {
await gradeReview(c.card_id, c.sub_index, rating);
stats.reviewed += 1;
if (rating === 'again') stats.again += 1;
queueIndex += 1;
revealed = false;
} catch (e) {
toasts.error(t('card_edit.save_failed', { msg: apiErrorMessage(e) }));
} finally {
busy = false;
}
}
</script>
<div class="study-page">
<!-- Sidebar links neben der Karte: Zurück-Pfeil, Deck-Name,
Fortschritt, Karten-Verwalten-Link — alles als kleiner UI-Block
gepackt, damit der Karten-Bereich frei vom Chrome bleibt. -->
<aside class="study-aside" aria-label={t('study_session.back')}>
<a class="aside-back" href="/decks" aria-label={t('study_session.back')}>
<span aria-hidden="true"></span>
<span>{t('nav.decks')}</span>
</a>
<h1 class="aside-name">{deckName}</h1>
<p class="aside-progress" aria-live="polite">
{#if !loading && !isDone}
{queueIndex + 1} / {queue.length}
{/if}
</p>
<a class="aside-manage" href="/decks/{deckId}">{t('study_session.manage_link')}</a>
</aside>
<div class="study-content">
{#if loading}
<p class="loading">{t('study_session.loading')}</p>
{:else if queue.length === 0}
<div class="study-stage">
<CardSurface size="hero" raised colorAccent={deckColor} class="study-card empty">
<div class="study-inner centered">
<p class="big-emoji" aria-hidden="true">🎉</p>
<p class="big-line">{t('study.none_due')}</p>
<a class="action-link" href="/decks/{deckId}">{t('card_edit.back')}</a>
</div>
</CardSurface>
</div>
{:else if isDone}
<div class="study-stage">
<CardSurface size="hero" raised colorAccent={deckColor} class="study-card done">
<div class="study-inner centered">
<h2 class="big-line">{t('study_session.all_done')}</h2>
<p class="muted">{t('study_session.stats', { reviewed: stats.reviewed, again: stats.again })}</p>
<div class="done-actions">
<a class="btn-secondary" href="/decks/{deckId}">{t('card_edit.back')}</a>
<a class="btn-primary" href="/study/{deckId}">{t('study.study_now')}</a>
</div>
</div>
</CardSurface>
</div>
{:else}
<div class="study-stage">
<CardSurface size="hero" raised colorAccent={cardColor} class="study-card">
<article class="study-inner" aria-labelledby="study-prompt-heading">
<h2 id="study-prompt-heading" class="sr-only">
{revealed ? t('card_new.preview_label') : t('study_session.reveal')}
</h2>
{#if isImageOcclusion && imageOcclusionData}
<ImageOcclusionView
imageRef={imageOcclusionData.imageRef}
maskRegionsJson={imageOcclusionData.maskRegionsJson}
activeMaskId={imageOcclusionData.activeMaskId}
{revealed}
/>
{:else if isMultipleChoice && multipleChoiceData}
<MultipleChoiceView
promptHtml={promptHtml}
answer={multipleChoiceData.answer}
distractorPool={multipleChoiceData.distractorPool}
deckId={deckId}
cardId={current?.card_id ?? ''}
ongrade={grade}
/>
{:else if isTyping && typingData}
<TypingView
promptHtml={promptHtml}
answer={typingData.answer}
aliases={typingData.aliases}
answerHtml={answerHtml}
ongrade={grade}
/>
{:else}
{#if isAudioFront && audioFrontData}
<AudioFrontView
audioRef={audioFrontData.audioRef}
frontText={audioFrontData.frontText}
/>
{:else}
<div class="prose">{@html promptHtml}</div>
{/if}
{#if revealed}
<hr class="divider" />
<div class="prose answer">{@html answerHtml}</div>
{/if}
{/if}
</article>
</CardSurface>
</div>
<div class="action-bar" class:invisible={isTyping || isMultipleChoice}>
<!-- grade-row immer im Flow → setzt die Höhe der action-bar -->
<div
class="grade-row"
class:invisible={!revealed}
role="group"
aria-label={t('study_session.grade_hint')}
aria-hidden={!revealed}
>
<button class="grade grade-again" onclick={() => grade('again')} disabled={busy} tabindex={revealed ? 0 : -1}>
<span>{t('study_session.grade_again')}</span>
<kbd>1</kbd>
</button>
<button class="grade grade-hard" onclick={() => grade('hard')} disabled={busy} tabindex={revealed ? 0 : -1}>
<span>{t('study_session.grade_hard')}</span>
<kbd>2</kbd>
</button>
<button class="grade grade-good" onclick={() => grade('good')} disabled={busy} tabindex={revealed ? 0 : -1}>
<span>{t('study_session.grade_good')}</span>
<kbd>3</kbd>
</button>
<button class="grade grade-easy" onclick={() => grade('easy')} disabled={busy} tabindex={revealed ? 0 : -1}>
<span>{t('study_session.grade_easy')}</span>
<kbd>4</kbd>
</button>
</div>
<!-- reveal-row liegt absolut über der grade-row -->
<div class="reveal-row" class:invisible={revealed} aria-hidden={revealed}>
<button class="btn-primary reveal" onclick={() => (revealed = true)} tabindex={revealed ? -1 : 0}>
{t('study_session.reveal')} <kbd>Space</kbd>
</button>
</div>
</div>
{/if}
</div>
</div>
<style>
.study-page {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
overflow: hidden;
padding: 2rem 1.5rem;
}
/* Sidebar oben links — Deck-Info als kompakter Block, KEIN Einfluss
auf die Karten-Zentrierung (absolut positioniert). */
.study-aside {
position: absolute;
top: 1.5rem;
left: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 16rem;
}
.aside-back {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
text-decoration: none;
width: fit-content;
}
.aside-back:hover {
color: hsl(var(--color-foreground));
}
.aside-name {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--color-foreground));
line-height: 1.25;
}
.aside-progress {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
.aside-manage {
margin-top: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
text-decoration: none;
width: fit-content;
}
.aside-manage:hover {
color: hsl(var(--color-primary));
}
/* Content — Karte horizontal + vertikal zentriert auf dem Viewport,
unabhängig von der Sidebar (die ist absolut platziert). */
.study-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
.loading {
text-align: center;
color: hsl(var(--color-muted-foreground));
}
.study-stage {
display: flex;
justify-content: center;
width: 100%;
}
/* Mobile: Sidebar bleibt oben links, aber Karte rückt unter die
Sidebar — Top-Padding größer, damit nichts überlappt. */
@media (max-width: 720px) {
.study-page {
padding: 6.5rem 1rem 2rem;
}
.study-aside {
top: 1rem;
left: 1rem;
right: 1rem;
max-width: none;
}
.aside-name {
font-size: 1.0625rem;
}
}
/* Die Karte selbst — minimal, fokussiert. CardSurface trägt Border,
Radius, Shadow, Color-Stripe links. Inneres Layout passt sich an
Portrait-Format (5:7) an: Inhalt vertikal zentriert, Padding rechts
vom Color-Stripe. */
.study-inner {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1.25rem;
padding: 2rem 1.5rem 2rem 1.875rem;
height: 100%;
overflow-y: auto;
}
.study-inner.centered {
justify-content: center;
align-items: center;
text-align: center;
}
.prose {
font-size: 1.125rem;
line-height: 1.55;
color: hsl(var(--color-foreground));
}
.prose :global(p) {
margin: 0 0 0.875em;
}
.prose :global(p:last-child) {
margin-bottom: 0;
}
.prose :global(strong) {
font-weight: 600;
}
.prose :global(code) {
background: hsl(var(--color-muted));
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
/* Großes Symbol-Heading — z.B. "# H" auf Periodensystem-Karten */
.prose :global(h1) {
font-size: 4rem;
font-weight: 700;
text-align: center;
margin: 0;
line-height: 1;
letter-spacing: -0.02em;
color: hsl(var(--color-foreground));
}
/* Eigenschaften-Tabelle auf der Rückseite */
.prose :global(table) {
border-collapse: collapse;
width: 100%;
margin-top: 0.625rem;
font-size: 0.875rem;
}
.prose :global(thead tr) {
display: none;
}
.prose :global(td) {
padding: 0.3rem 0;
border-bottom: 1px solid hsl(var(--color-border));
vertical-align: top;
}
.prose :global(td:first-child) {
color: hsl(var(--color-muted-foreground));
padding-right: 1rem;
white-space: nowrap;
width: 1%;
}
.prose :global(tr:last-child td) {
border-bottom: none;
}
.prose.answer {
color: hsl(var(--color-foreground));
}
.divider {
border: none;
border-top: 1px solid hsl(var(--color-border));
margin: 0.25rem 0;
}
.big-emoji {
font-size: 2.5rem;
margin: 0;
}
.big-line {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.muted {
margin: 0;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
}
.action-link {
color: hsl(var(--color-primary));
font-size: 0.875rem;
text-decoration: none;
}
.action-link:hover {
text-decoration: underline;
}
.done-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
text-decoration: none;
border: 1px solid transparent;
font-family: inherit;
cursor: pointer;
}
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.btn-secondary {
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
border-color: hsl(var(--color-border));
}
/* Reveal-Button und Grade-Row sitzen UNTER der Karte — keine
Sub-Karten, sondern Aktions-Leiste. Bewahrt das "eine Karte ist
eine Karte"-Gefühl. */
.action-bar {
position: relative;
margin-top: 1.5rem;
width: 100%;
max-width: 24rem;
}
.invisible {
visibility: hidden;
pointer-events: none;
}
.reveal-row {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.btn-primary.reveal {
padding: 0.75rem 1.5rem;
font-size: 0.9375rem;
}
.btn-primary.reveal kbd {
margin-left: 0.5rem;
padding: 0.0625rem 0.375rem;
background: hsl(var(--color-primary-foreground) / 0.15);
border-radius: 0.25rem;
font-size: 0.75rem;
font-family: inherit;
}
.grade-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.grade {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.875rem 0.5rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.grade:hover:not(:disabled) {
background: hsl(var(--color-surface-hover));
}
.grade:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.grade kbd {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
font-family: inherit;
}
.grade.grade-again {
border-color: hsl(var(--color-error) / 0.4);
}
.grade.grade-again:hover:not(:disabled) {
background: hsl(var(--color-error) / 0.08);
}
.grade.grade-good {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
.grade.grade-good:hover:not(:disabled) {
background: hsl(var(--color-primary) / 0.9);
}
.grade.grade-good kbd {
color: hsl(var(--color-primary-foreground) / 0.7);
}
.grade.grade-easy {
border-color: hsl(var(--color-success) / 0.4);
}
.grade.grade-easy:hover:not(:disabled) {
background: hsl(var(--color-success) / 0.08);
}
</style>