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

@ -0,0 +1,21 @@
import { ApiError } from './client.ts';
/**
* Extrahiert die beste verfügbare Fehlermeldung aus einem unbekannten Fehler.
* Bei ApiError wird `body.detail` oder `body.error` bevorzugt das sind die
* vom Server gesetzten, lesbaren Fehlertexte. Fallback auf Error.message bzw.
* String-Konvertierung.
*/
export function apiErrorMessage(err: unknown): string {
if (err instanceof ApiError) {
const b = err.body;
if (b && typeof b === 'object') {
const body = b as Record<string, unknown>;
if (typeof body.detail === 'string' && body.detail) return body.detail;
if (typeof body.error === 'string' && body.error) return body.error;
}
return `Fehler ${err.status}`;
}
if (err instanceof Error) return err.message;
return String(err);
}

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;
}
}

View file

@ -3,6 +3,7 @@
import { goto } from '$app/navigation';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
let email = $state('');
let password = $state('');
@ -22,7 +23,7 @@
await devUser.login(email.trim(), password);
goto('/decks');
} catch (err) {
error = (err as Error).message;
error = apiErrorMessage(err);
} finally {
busy = false;
}

View file

@ -5,6 +5,9 @@
import { exportMe, deleteMe } from '$lib/api/me.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts';
import { stackLayers } from '$lib/utils/deck-tilt';
import CardSurface from '$lib/components/CardSurface.svelte';
import { apiErrorMessage } from '$lib/api/error.ts';
let exporting = $state(false);
let deleting = $state(false);
@ -23,7 +26,9 @@
.join('');
});
const shortId = $derived(devUser.id ? devUser.id.slice(0, 8) + '…' : '—');
const profileLayers = stackLayers('account-profile', 3);
const exportLayers = stackLayers('account-export', 3);
const dangerLayers = stackLayers('account-danger', 3);
async function onExport() {
exporting = true;
@ -46,7 +51,7 @@
}),
);
} catch (e) {
toasts.error(t('account.export_failed', { msg: (e as Error).message }));
toasts.error(t('account.export_failed', { msg: apiErrorMessage(e) }));
} finally {
exporting = false;
}
@ -64,7 +69,7 @@
devUser.clear();
goto('/');
} catch (e) {
toasts.error(t('account.delete_failed', { msg: (e as Error).message }));
toasts.error(t('account.delete_failed', { msg: apiErrorMessage(e) }));
deleting = false;
}
}
@ -79,47 +84,53 @@
<title>{t('account.title')} · {t('app.title_suffix')}</title>
</svelte:head>
<div class="page">
<h1 class="page-title">{t('account.title')}</h1>
<ul class="card-row" aria-label="Account-Karten">
<!-- Profil-Karte -->
<div class="card profile-card">
<li class="stack-wrap">
{#each profileLayers as layer, i (i)}
<div class="layer" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
{/each}
<CardSurface size="md" colorAccent="hsl(var(--color-primary))">
<div class="card-inner">
<div class="card-corner">
<div class="avatar" aria-hidden="true">{initials}</div>
<div class="profile-info">
</div>
<div class="card-body">
{#if devUser.user?.name}
<p class="profile-name">{devUser.user.name}</p>
<p class="card-title">{devUser.user.name}</p>
{/if}
<p class="profile-email">{devUser.user?.email ?? '—'}</p>
<p class="card-sub">{devUser.user?.email ?? '—'}</p>
{#if devUser.user?.tier}
<span class="tier-badge">{devUser.user.tier}</span>
{/if}
</div>
<button type="button" class="btn-outline logout-btn" onclick={logout}>
<div class="card-meta">
<button type="button" class="btn-ghost" onclick={logout}>
{t('account.logout')}
</button>
</div>
<!-- Meta-Grid -->
<div class="meta-grid">
<div class="card meta-card">
<p class="meta-label">{t('account.user_id_label')}</p>
<code class="meta-value">{shortId}</code>
<code class="meta-full">{devUser.id ?? '—'}</code>
</div>
<div class="card meta-card">
<p class="meta-label">Tier</p>
<p class="meta-value">{devUser.user?.tier ?? '—'}</p>
</div>
</div>
</CardSurface>
</li>
<!-- Export-Karte -->
<div class="card action-card">
<div class="action-header">
<div class="action-icon">📦</div>
<div>
<h2 class="action-title">{t('account.export_title')}</h2>
<p class="action-desc">{t('account.export_intro')}</p>
<li class="stack-wrap">
{#each exportLayers as layer, i (i)}
<div class="layer" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
{/each}
<CardSurface size="md" colorAccent="#22C55E">
<div class="card-inner">
<div class="card-corner">
<span class="card-icon" aria-hidden="true">📦</span>
</div>
<div class="card-body">
<p class="card-title">{t('account.export_title')}</p>
<p class="card-desc">{t('account.export_intro')}</p>
</div>
<div class="card-meta">
<button
type="button"
class="btn-primary"
@ -129,16 +140,25 @@
{exporting ? t('account.export_loading') : t('account.export_button')}
</button>
</div>
</div>
</CardSurface>
</li>
<!-- Danger-Karte -->
<div class="card danger-card">
<div class="action-header">
<div class="action-icon danger-icon">⚠️</div>
<div>
<h2 class="action-title danger-title">{t('account.delete_title')}</h2>
<p class="action-desc">{t('account.delete_intro')}</p>
<li class="stack-wrap">
{#each dangerLayers as layer, i (i)}
<div class="layer layer-danger" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
{/each}
<CardSurface size="md" colorAccent="hsl(var(--color-error))">
<div class="card-inner">
<div class="card-corner">
<span class="card-icon" aria-hidden="true">⚠️</span>
</div>
<div class="card-body">
<p class="card-title danger-title">{t('account.delete_title')}</p>
<p class="card-desc">{t('account.delete_intro')}</p>
</div>
<div class="card-meta">
<button
type="button"
class="btn-danger"
@ -148,43 +168,95 @@
{deleting ? t('account.delete_loading') : t('account.delete_button')}
</button>
</div>
</div>
</CardSurface>
</li>
</div>
</ul>
<style>
.page {
max-width: 36rem;
margin: 0 auto;
padding: 2rem 1rem;
.page-title {
margin: 0 0 1.5rem;
font-size: 1.75rem;
font-weight: 600;
}
/* Horizontal-Scroll-Reihe — gleiche Mechanik wie DeckListGrid */
.card-row {
list-style: none;
margin: 0;
padding-block: 1.25rem 2.5rem;
display: flex;
flex-direction: row;
gap: 1.5rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-padding-inline-start: max(1rem, calc((100dvw - 72rem) / 2 + 1rem));
scrollbar-width: thin;
scrollbar-color: hsl(var(--color-border)) transparent;
width: 100dvw;
margin-left: calc(50% - 50dvw);
}
.card-row::before,
.card-row::after {
content: '';
flex-shrink: 0;
width: max(1rem, calc((100dvw - 72rem) / 2 + 1rem));
}
.card-row::-webkit-scrollbar { height: 4px; }
.card-row::-webkit-scrollbar-track { background: transparent; }
.card-row::-webkit-scrollbar-thumb {
background: hsl(var(--color-border));
border-radius: 9999px;
}
/* Stack-Wrapper — identisch zu DeckStack / MarketplaceDeckStack */
.stack-wrap {
position: relative;
flex: 0 0 auto;
width: 16rem;
aspect-ratio: 5 / 7;
scroll-snap-align: start;
}
.layer {
position: absolute;
inset: 0;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.875rem;
box-shadow: 0 1px 3px hsl(var(--color-foreground) / 0.06);
}
.layer-danger {
border-color: hsl(var(--color-error) / 0.25);
}
/* Karten-Innenlayout — gleiche Struktur wie MarketplaceDeckStack */
.card-inner {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem 1rem 1.125rem 1.375rem;
overflow: hidden;
}
/* ── Basis-Karte ─────────────────────────────────────────────────── */
.card {
border-radius: 0.875rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
padding: 1.5rem;
}
/* ── Profil-Karte ────────────────────────────────────────────────── */
.profile-card {
display: flex;
align-items: center;
gap: 1.25rem;
border-left: 4px solid hsl(var(--color-primary));
.card-corner {
position: absolute;
top: 0.875rem;
right: 0.875rem;
}
.avatar {
flex-shrink: 0;
width: 3.5rem;
height: 3.5rem;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
font-size: 1.25rem;
font-size: 1rem;
font-weight: 700;
display: flex;
align-items: center;
@ -192,196 +264,121 @@
letter-spacing: -0.02em;
}
.profile-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
.card-icon {
font-size: 1.5rem;
line-height: 1;
}
.profile-name {
.card-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 2.75rem 0.5rem 0 0;
min-height: 0;
}
.card-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 600;
line-height: 1.3;
color: hsl(var(--color-foreground));
}
.profile-email {
.card-sub {
margin: 0;
font-size: 0.8125rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-desc {
margin: 0;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 5;
line-clamp: 5;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tier-badge {
align-self: flex-start;
margin-top: 0.25rem;
padding: 0.125rem 0.625rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
font-size: 0.6875rem;
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.04em;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.logout-btn {
flex-shrink: 0;
align-self: flex-start;
}
/* ── Meta-Grid ───────────────────────────────────────────────────── */
.meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.meta-card {
padding: 1.125rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.meta-label {
margin: 0;
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
color: hsl(var(--color-muted-foreground));
}
.meta-value {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.meta-full {
margin: 0;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
word-break: break-all;
display: none;
}
.meta-card:hover .meta-full {
display: block;
}
.meta-card:hover .meta-value:has(+ .meta-full) {
display: none;
}
/* ── Action-Karten ───────────────────────────────────────────────── */
.action-card {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.danger-card {
border-color: hsl(var(--color-error) / 0.35);
}
.action-header {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.action-icon {
flex-shrink: 0;
font-size: 1.5rem;
line-height: 1;
margin-top: 0.125rem;
}
.action-title {
margin: 0 0 0.375rem;
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.danger-title {
color: hsl(var(--color-error));
}
.action-desc {
margin: 0;
font-size: 0.8125rem;
line-height: 1.55;
color: hsl(var(--color-muted-foreground));
.card-meta {
flex: 0 0 auto;
padding-top: 0.5rem;
}
/* ── Buttons ─────────────────────────────────────────────────────── */
.btn-outline {
padding: 0.4375rem 0.875rem;
border-radius: 0.5rem;
/* Buttons */
.btn-ghost {
padding: 0.375rem 0.75rem;
border-radius: 0.4375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
font-size: 0.8125rem;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-foreground));
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.btn-outline:hover {
background: hsl(var(--color-border) / 0.4);
border-color: hsl(var(--color-border));
}
.btn-primary {
align-self: flex-start;
padding: 0.5rem 1.25rem;
border-radius: 0.5rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.88;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-danger {
align-self: flex-start;
padding: 0.5rem 1.25rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-error));
background: transparent;
color: hsl(var(--color-error));
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-danger:hover:not(:disabled) {
background: hsl(var(--color-error) / 0.08);
.btn-ghost:hover {
background: hsl(var(--color-border) / 0.4);
}
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
.btn-primary {
padding: 0.4375rem 0.875rem;
border-radius: 0.4375rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.btn-primary:hover:not(:disabled) { opacity: 0.88; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-danger {
padding: 0.4375rem 0.875rem;
border-radius: 0.4375rem;
border: 1px solid hsl(var(--color-error));
background: transparent;
color: hsl(var(--color-error));
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.btn-danger:hover:not(:disabled) { background: hsl(var(--color-error) / 0.08); }
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
</style>

View file

@ -3,6 +3,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
import { apiErrorMessage } from '$lib/api/error.ts';
extractClusterIds,
maskRegionCount,
renderClozePrompt,
@ -60,7 +61,7 @@
back = fields.back ?? '';
}
} catch (e) {
error = (e as Error).message;
error = apiErrorMessage(e);
} finally {
loading = false;
}
@ -98,7 +99,7 @@
toasts.success(t('card_edit.updated'));
goto(`/decks/${updated.deck_id}`);
} catch (e) {
toasts.error(t('card_edit.save_failed', { msg: (e as Error).message }));
toasts.error(t('card_edit.save_failed', { msg: apiErrorMessage(e) }));
saving = false;
}
}
@ -111,7 +112,7 @@
toasts.success(t('card_edit.deleted'));
goto(`/decks/${card.deck_id}`);
} catch (e) {
toasts.error(t('card_edit.delete_failed', { msg: (e as Error).message }));
toasts.error(t('card_edit.delete_failed', { msg: apiErrorMessage(e) }));
}
}
</script>

View file

@ -3,6 +3,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
import { apiErrorMessage } from '$lib/api/error.ts';
extractClusterIds,
maskRegionCount,
renderClozePrompt,
@ -82,7 +83,7 @@
}
}
} catch (e) {
toasts.error(t('card_new.decks_load_failed', { msg: (e as Error).message }));
toasts.error(t('card_new.decks_load_failed', { msg: apiErrorMessage(e) }));
}
});
@ -145,7 +146,7 @@
toasts.success(msg);
goto(`/decks/${card.deck_id}`);
} catch (e) {
toasts.error(t('card_new.create_failed', { msg: (e as Error).message }));
toasts.error(t('card_new.create_failed', { msg: apiErrorMessage(e) }));
saving = false;
}
}

View file

@ -5,6 +5,7 @@
import { goto } from '$app/navigation';
import {
import { apiErrorMessage } from '$lib/api/error.ts';
forkDeck,
getMarketplaceDeck,
getMarketplaceVersion,
@ -80,7 +81,7 @@
cards = version;
discussionCounts = counts;
} catch (e) {
toasts.error(`Deck laden fehlgeschlagen: ${(e as Error).message}`);
toasts.error(`Deck laden fehlgeschlagen: ${apiErrorMessage(e)}`);
} finally {
loading = false;
}
@ -101,7 +102,7 @@
starred = true;
}
} catch (e) {
toasts.error((e as Error).message);
toasts.error(apiErrorMessage(e));
} finally {
busy = false;
}
@ -124,7 +125,7 @@
toasts.success('Abonniert. Update-Benachrichtigung an.');
}
} catch (e) {
toasts.error((e as Error).message);
toasts.error(apiErrorMessage(e));
} finally {
busy = false;
}
@ -141,7 +142,7 @@
toasts.success(`Deck geforkt — ${result.cards_created} Karten kopiert.`);
await goto(`/decks/${result.deck.id}`);
} catch (e) {
toasts.error((e as Error).message);
toasts.error(apiErrorMessage(e));
} finally {
busy = false;
}

View file

@ -15,6 +15,7 @@
import PublishDeckModal from '$lib/components/marketplace/PublishDeckModal.svelte';
import { Image, CaretRight, DotsThree, PencilSimple, Trash } from '@mana/shared-icons';
import { marked } from 'marked';
import { apiErrorMessage } from '$lib/api/error.ts';
function md(text: string): string {
return marked.parse(text, { async: false }) as string;
@ -62,7 +63,7 @@
dueCount = due.total;
error = null;
} catch (e) {
error = (e as Error).message;
error = apiErrorMessage(e);
} finally {
loading = false;
}
@ -75,7 +76,7 @@
deck = await updateDeck(deck.id, { category: next ?? undefined });
categoryOpen = false;
} catch (e) {
toasts.error((e as Error).message);
toasts.error(apiErrorMessage(e));
}
}
@ -86,7 +87,7 @@
toasts.success(t('card_edit.deleted'));
await refresh();
} catch (e) {
toasts.error(t('card_edit.delete_failed', { msg: (e as Error).message }));
toasts.error(t('card_edit.delete_failed', { msg: apiErrorMessage(e) }));
}
}

View file

@ -6,6 +6,7 @@
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 { apiErrorMessage } from '$lib/api/error.ts';
const deckId = $derived(page.params.id ?? '');
@ -23,7 +24,7 @@
description = deck.description ?? '';
color = deck.color ?? '#6366f1';
} catch (e) {
toasts.error((e as Error).message);
toasts.error(apiErrorMessage(e));
goto(`/decks/${deckId}`);
} finally {
loading = false;
@ -45,7 +46,7 @@
toasts.success(t('deck_edit.saved'));
goto(`/decks/${deckId}`);
} catch (e) {
toasts.error(t('deck_edit.save_failed', { msg: (e as Error).message }));
toasts.error(t('deck_edit.save_failed', { msg: apiErrorMessage(e) }));
saving = false;
}
}

View file

@ -7,6 +7,7 @@
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { i18n, t } from '$lib/i18n/index.svelte.ts';
import DeckCategoryIcon from '$lib/components/DeckCategoryIcon.svelte';
import { apiErrorMessage } from '$lib/api/error.ts';
let name = $state('');
let description = $state('');
@ -68,7 +69,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;
}
}
@ -95,7 +96,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;
}
}
@ -109,7 +110,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';
browseDecks,
getExplore,
type DeckListEntry,
@ -31,7 +32,7 @@
featured = explore.featured;
trending = explore.trending;
} catch (e) {
toasts.error(`Explore-Liste laden fehlgeschlagen: ${(e as Error).message}`);
toasts.error(`Explore-Liste laden fehlgeschlagen: ${apiErrorMessage(e)}`);
} finally {
loadingExplore = false;
}
@ -59,7 +60,7 @@
}
browseTotal = result.total;
} catch (e) {
toasts.error(`Browse fehlgeschlagen: ${(e as Error).message}`);
toasts.error(`Browse fehlgeschlagen: ${apiErrorMessage(e)}`);
} finally {
loadingBrowse = false;
}

View file

@ -10,6 +10,7 @@
import EmptyState from '$lib/components/marketplace/EmptyState.svelte';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { GitFork } from '@mana/shared-icons';
import { apiErrorMessage } from '$lib/api/error.ts';
let forks = $state<Deck[]>([]);
let loading = $state(true);
@ -29,7 +30,7 @@
const result = await listDecks({ forkedFromMarketplace: true });
forks = result.decks;
} catch (e) {
toasts.error(`Forks laden fehlgeschlagen: ${(e as Error).message}`);
toasts.error(`Forks laden fehlgeschlagen: ${apiErrorMessage(e)}`);
} finally {
loading = false;
}
@ -48,7 +49,7 @@
await refresh();
}
} catch (e) {
toasts.error((e as Error).message);
toasts.error(apiErrorMessage(e));
} finally {
busyId = null;
}

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import {
import { apiErrorMessage } from '$lib/api/error.ts';
browseDecks,
getMyAuthorProfile,
upsertMyAuthorProfile,
@ -42,7 +43,7 @@
decks = result.items;
}
} catch (e) {
toasts.error(`Profil laden fehlgeschlagen: ${(e as Error).message}`);
toasts.error(`Profil laden fehlgeschlagen: ${apiErrorMessage(e)}`);
} finally {
loading = false;
}
@ -85,7 +86,7 @@
decks = result.items;
}
} catch (e) {
toasts.error((e as Error).message);
toasts.error(apiErrorMessage(e));
} finally {
saving = false;
}

View file

@ -8,6 +8,7 @@
import EmptyState from '$lib/components/marketplace/EmptyState.svelte';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { ArrowCounterClockwise } from '@mana/shared-icons';
import { apiErrorMessage } from '$lib/api/error.ts';
let items = $state<SubscriptionEntry[]>([]);
let loading = $state(true);
@ -21,7 +22,7 @@
const result = await getMySubscriptions();
items = result.subscriptions;
} catch (e) {
toasts.error(`Subs laden fehlgeschlagen: ${(e as Error).message}`);
toasts.error(`Subs laden fehlgeschlagen: ${apiErrorMessage(e)}`);
} finally {
loading = false;
}

View file

@ -4,6 +4,7 @@
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { loadStats, type UserStats } from '$lib/api/me.ts';
import { i18n, t, tn } from '$lib/i18n/index.svelte.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
let stats = $state<UserStats | null>(null);
let loading = $state(true);
@ -27,7 +28,7 @@
try {
stats = await loadStats();
} catch (e) {
error = (e as Error).message;
error = apiErrorMessage(e);
} finally {
loading = false;
}

View file

@ -3,6 +3,7 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import {
import { apiErrorMessage } from '$lib/api/error.ts';
clusterIdForSubIndex,
maskForSubIndex,
renderClozePrompt,
@ -143,7 +144,7 @@
deckColor = d.color ?? null;
queue = due.reviews;
} catch (e) {
toasts.error(`Sitzung konnte nicht geladen werden: ${(e as Error).message}`);
toasts.error(`Sitzung konnte nicht geladen werden: ${apiErrorMessage(e)}`);
goto('/study');
return;
}
@ -189,7 +190,7 @@
queueIndex += 1;
revealed = false;
} catch (e) {
toasts.error(t('card_edit.save_failed', { msg: (e as Error).message }));
toasts.error(t('card_edit.save_failed', { msg: apiErrorMessage(e) }));
} finally {
busy = false;
}

View file

@ -4,6 +4,7 @@
import { page } from '$app/state';
import {
import { apiErrorMessage } from '$lib/api/error.ts';
browseDecks,
followAuthor,
getAuthor,
@ -44,7 +45,7 @@
decks = d.items;
following = follow.following;
} catch (e) {
toasts.error(`Author laden fehlgeschlagen: ${(e as Error).message}`);
toasts.error(`Author laden fehlgeschlagen: ${apiErrorMessage(e)}`);
} finally {
loading = false;
}
@ -65,7 +66,7 @@
following = true;
}
} catch (e) {
toasts.error((e as Error).message);
toasts.error(apiErrorMessage(e));
} finally {
busy = false;
}