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>
This commit is contained in:
Till JS 2026-05-10 16:27:19 +02:00
parent f3a148171a
commit f2f752e9ee
24 changed files with 381 additions and 284 deletions

View file

@ -12,6 +12,7 @@
import { API_BASE } from '$lib/api/client.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
let {
imageRef = $bindable(''),
@ -44,7 +45,7 @@
imageRef = r.id;
maskRegionsJson = '[]';
} catch (err) {
toasts.error(`${(err as Error).message}`);
toasts.error(`${apiErrorMessage(err)}`);
} finally {
uploading = false;
input.value = '';

View file

@ -22,6 +22,7 @@
let options = $state<string[]>([]);
let selected = $state<string | null>(null);
let loading = $state(true);
let tooFewOptions = $state(false);
const correct = $derived(selected === answer);
@ -51,6 +52,13 @@
distractors = [...distractors, ...poolItems].slice(0, 3);
}
// Zu wenig Distractors: Fallback auf Antwort-Anzeige mit manueller Bewertung
if (distractors.length < 1) {
tooFewOptions = true;
loading = false;
return;
}
options = shuffle([answer, ...distractors.slice(0, 3)]);
loading = false;
});
@ -100,6 +108,15 @@
{#if loading}
<div class="loading" aria-live="polite">Optionen werden geladen …</div>
{:else if tooFewOptions}
<div class="few-options">
<p class="few-hint">Deck hat zu wenig Karten für Multiple Choice.</p>
<div class="few-answer">{answer}</div>
<div class="few-actions">
<button class="few-btn few-btn-again" onclick={() => ongrade('again')}>Nochmal</button>
<button class="few-btn few-btn-good" onclick={() => ongrade('good')}>Gewusst</button>
</div>
</div>
{:else}
<div class="options" role="group" aria-label="Antwortmöglichkeiten">
{#each options as opt, i (opt)}
@ -168,6 +185,47 @@
padding: 0.5rem 0;
}
.few-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.few-hint {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.few-answer {
padding: 0.75rem 1rem;
border-radius: 0.5rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
font-size: 1rem;
}
.few-actions {
display: flex;
gap: 0.5rem;
}
.few-btn {
flex: 1;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
font: inherit;
font-size: 0.9375rem;
cursor: pointer;
}
.few-btn-again {
background: hsl(var(--color-error) / 0.1);
border-color: hsl(var(--color-error) / 0.4);
color: hsl(var(--color-foreground));
}
.few-btn-good {
background: hsl(var(--color-success) / 0.1);
border-color: hsl(var(--color-success) / 0.4);
color: hsl(var(--color-foreground));
}
.options {
display: flex;
flex-direction: column;

View file

@ -7,6 +7,7 @@
import { i18n, t } from '$lib/i18n/index.svelte.ts';
import CardSurface from './CardSurface.svelte';
import DeckCategoryIcon from './DeckCategoryIcon.svelte';
import { apiErrorMessage } from '$lib/api/error.ts';
let open = $state(false);
let catOpen = $state(false);
@ -95,7 +96,7 @@
toasts.success(`🖼 "${result.deck.name}" mit ${result.cards_created} Karten erstellt`);
goto(`/decks/${result.deck.id}`);
} catch (err) {
imageError = (err as Error).message;
imageError = apiErrorMessage(err);
imageGenerating = false;
}
}
@ -119,7 +120,7 @@
toasts.success(`${deck.name} ✓`);
goto(`/decks/${deck.id}`);
} catch (err) {
toasts.error(t('deck_new.create_failed', { msg: (err as Error).message }));
toasts.error(t('deck_new.create_failed', { msg: apiErrorMessage(err) }));
saving = false;
}
}
@ -133,7 +134,7 @@
toasts.success(`✨ "${result.deck.name}" mit ${result.cards_created} Karten erstellt`);
goto(`/decks/${result.deck.id}`);
} catch (err) {
aiError = (err as Error).message;
aiError = apiErrorMessage(err);
generating = false;
}
}

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import {
import { apiErrorMessage } from '$lib/api/error.ts';
hideDiscussion,
listDiscussions,
postDiscussion,
@ -37,7 +38,7 @@
const result = await listDiscussions(cardContentHash);
comments = result.discussions;
} catch (e) {
toasts.error(`Discussions laden fehlgeschlagen: ${(e as Error).message}`);
toasts.error(`Discussions laden fehlgeschlagen: ${apiErrorMessage(e)}`);
} finally {
loading = false;
}
@ -53,7 +54,7 @@
replyToId = null;
await refresh();
} catch (e) {
toasts.error((e as Error).message);
toasts.error(apiErrorMessage(e));
} finally {
posting = false;
}
@ -65,7 +66,7 @@
await hideDiscussion(id);
await refresh();
} catch (e) {
toasts.error((e as Error).message);
toasts.error(apiErrorMessage(e));
}
}

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import type { Card, Deck } from '@cards/domain';
import {
import { apiErrorMessage } from '$lib/api/error.ts';
getMyAuthorProfile,
getMarketplaceDeck,
initMarketplaceDeck,
@ -125,7 +126,7 @@
);
onPublished(slug.trim());
} catch (e) {
error = (e as Error).message;
error = apiErrorMessage(e);
busy = false;
}
}

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { publishMarketplaceVersion } from '$lib/api/marketplace.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
interface Props {
slug: string;
@ -45,7 +46,7 @@
}
cards = parsed;
} catch (e) {
error = `JSON-Parse-Fehler: ${(e as Error).message}`;
error = `JSON-Parse-Fehler: ${apiErrorMessage(e)}`;
busy = false;
return;
}
@ -59,7 +60,7 @@
toasts.success(`Version ${result.version.semver} veröffentlicht (${result.version.card_count} Karten)`);
onPublished?.();
} catch (e) {
error = (e as Error).message;
error = apiErrorMessage(e);
busy = false;
}
}

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import {
import { apiErrorMessage } from '$lib/api/error.ts';
closePullRequest,
listPullRequests,
mergePullRequest,
@ -37,7 +38,7 @@
const result = await listPullRequests(slug, statusFilter);
prs = result.pull_requests;
} catch (e) {
toasts.error(`PRs laden fehlgeschlagen: ${(e as Error).message}`);
toasts.error(`PRs laden fehlgeschlagen: ${apiErrorMessage(e)}`);
} finally {
loading = false;
}
@ -54,7 +55,7 @@
toasts.success('PR gemerged — neue Version live.');
await refresh();
} catch (e) {
toasts.error((e as Error).message);
toasts.error(apiErrorMessage(e));
} finally {
busyId = null;
}
@ -67,7 +68,7 @@
await rejectPullRequest(id);
await refresh();
} catch (e) {
toasts.error((e as Error).message);
toasts.error(apiErrorMessage(e));
} finally {
busyId = null;
}
@ -79,7 +80,7 @@
await closePullRequest(id);
await refresh();
} catch (e) {
toasts.error((e as Error).message);
toasts.error(apiErrorMessage(e));
} finally {
busyId = null;
}

View file

@ -2,6 +2,7 @@
import { createPullRequest } from '$lib/api/marketplace.ts';
import type { MarketplaceVersionCard } from '$lib/api/marketplace.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
interface Props {
slug: string;
@ -59,7 +60,7 @@
toasts.success('Pull-Request eingereicht — Author bekommt Bescheid.');
onSubmitted?.();
} catch (e) {
error = (e as Error).message;
error = apiErrorMessage(e);
busy = false;
}
}