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

View file

@ -22,6 +22,7 @@
let options = $state<string[]>([]); let options = $state<string[]>([]);
let selected = $state<string | null>(null); let selected = $state<string | null>(null);
let loading = $state(true); let loading = $state(true);
let tooFewOptions = $state(false);
const correct = $derived(selected === answer); const correct = $derived(selected === answer);
@ -51,6 +52,13 @@
distractors = [...distractors, ...poolItems].slice(0, 3); 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)]); options = shuffle([answer, ...distractors.slice(0, 3)]);
loading = false; loading = false;
}); });
@ -100,6 +108,15 @@
{#if loading} {#if loading}
<div class="loading" aria-live="polite">Optionen werden geladen …</div> <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} {:else}
<div class="options" role="group" aria-label="Antwortmöglichkeiten"> <div class="options" role="group" aria-label="Antwortmöglichkeiten">
{#each options as opt, i (opt)} {#each options as opt, i (opt)}
@ -168,6 +185,47 @@
padding: 0.5rem 0; 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 { .options {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,9 @@
import { exportMe, deleteMe } from '$lib/api/me.ts'; import { exportMe, deleteMe } from '$lib/api/me.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts'; import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t } from '$lib/i18n/index.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 exporting = $state(false);
let deleting = $state(false); let deleting = $state(false);
@ -23,7 +26,9 @@
.join(''); .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() { async function onExport() {
exporting = true; exporting = true;
@ -46,7 +51,7 @@
}), }),
); );
} catch (e) { } catch (e) {
toasts.error(t('account.export_failed', { msg: (e as Error).message })); toasts.error(t('account.export_failed', { msg: apiErrorMessage(e) }));
} finally { } finally {
exporting = false; exporting = false;
} }
@ -64,7 +69,7 @@
devUser.clear(); devUser.clear();
goto('/'); goto('/');
} catch (e) { } catch (e) {
toasts.error(t('account.delete_failed', { msg: (e as Error).message })); toasts.error(t('account.delete_failed', { msg: apiErrorMessage(e) }));
deleting = false; deleting = false;
} }
} }
@ -79,112 +84,179 @@
<title>{t('account.title')} · {t('app.title_suffix')}</title> <title>{t('account.title')} · {t('app.title_suffix')}</title>
</svelte:head> </svelte:head>
<div class="page"> <h1 class="page-title">{t('account.title')}</h1>
<ul class="card-row" aria-label="Account-Karten">
<!-- Profil-Karte --> <!-- Profil-Karte -->
<div class="card profile-card"> <li class="stack-wrap">
<div class="avatar" aria-hidden="true">{initials}</div> {#each profileLayers as layer, i (i)}
<div class="profile-info"> <div class="layer" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
{#if devUser.user?.name} {/each}
<p class="profile-name">{devUser.user.name}</p> <CardSurface size="md" colorAccent="hsl(var(--color-primary))">
{/if} <div class="card-inner">
<p class="profile-email">{devUser.user?.email ?? '—'}</p> <div class="card-corner">
{#if devUser.user?.tier} <div class="avatar" aria-hidden="true">{initials}</div>
<span class="tier-badge">{devUser.user.tier}</span> </div>
{/if} <div class="card-body">
</div> {#if devUser.user?.name}
<button type="button" class="btn-outline logout-btn" onclick={logout}> <p class="card-title">{devUser.user.name}</p>
{t('account.logout')} {/if}
</button> <p class="card-sub">{devUser.user?.email ?? '—'}</p>
</div> {#if devUser.user?.tier}
<span class="tier-badge">{devUser.user.tier}</span>
<!-- Meta-Grid --> {/if}
<div class="meta-grid"> </div>
<div class="card meta-card"> <div class="card-meta">
<p class="meta-label">{t('account.user_id_label')}</p> <button type="button" class="btn-ghost" onclick={logout}>
<code class="meta-value">{shortId}</code> {t('account.logout')}
<code class="meta-full">{devUser.id ?? '—'}</code> </button>
</div> </div>
<div class="card meta-card"> </div>
<p class="meta-label">Tier</p> </CardSurface>
<p class="meta-value">{devUser.user?.tier ?? '—'}</p> </li>
</div>
</div>
<!-- Export-Karte --> <!-- Export-Karte -->
<div class="card action-card"> <li class="stack-wrap">
<div class="action-header"> {#each exportLayers as layer, i (i)}
<div class="action-icon">📦</div> <div class="layer" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
<div> {/each}
<h2 class="action-title">{t('account.export_title')}</h2> <CardSurface size="md" colorAccent="#22C55E">
<p class="action-desc">{t('account.export_intro')}</p> <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"
onclick={onExport}
disabled={exporting}
>
{exporting ? t('account.export_loading') : t('account.export_button')}
</button>
</div>
</div> </div>
</div> </CardSurface>
<button </li>
type="button"
class="btn-primary"
onclick={onExport}
disabled={exporting}
>
{exporting ? t('account.export_loading') : t('account.export_button')}
</button>
</div>
<!-- Danger-Karte --> <!-- Danger-Karte -->
<div class="card danger-card"> <li class="stack-wrap">
<div class="action-header"> {#each dangerLayers as layer, i (i)}
<div class="action-icon danger-icon">⚠️</div> <div class="layer layer-danger" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
<div> {/each}
<h2 class="action-title danger-title">{t('account.delete_title')}</h2> <CardSurface size="md" colorAccent="hsl(var(--color-error))">
<p class="action-desc">{t('account.delete_intro')}</p> <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"
onclick={onDelete}
disabled={deleting}
>
{deleting ? t('account.delete_loading') : t('account.delete_button')}
</button>
</div>
</div> </div>
</div> </CardSurface>
<button </li>
type="button"
class="btn-danger"
onclick={onDelete}
disabled={deleting}
>
{deleting ? t('account.delete_loading') : t('account.delete_button')}
</button>
</div>
</div> </ul>
<style> <style>
.page { .page-title {
max-width: 36rem; margin: 0 0 1.5rem;
margin: 0 auto; font-size: 1.75rem;
padding: 2rem 1rem; 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; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; padding: 1rem 1rem 1.125rem 1.375rem;
overflow: hidden;
} }
/* ── Basis-Karte ─────────────────────────────────────────────────── */ .card-corner {
.card { position: absolute;
border-radius: 0.875rem; top: 0.875rem;
border: 1px solid hsl(var(--color-border)); right: 0.875rem;
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));
} }
.avatar { .avatar {
flex-shrink: 0; width: 2.5rem;
width: 3.5rem; height: 2.5rem;
height: 3.5rem;
border-radius: 50%; border-radius: 50%;
background: hsl(var(--color-primary) / 0.15); background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary)); color: hsl(var(--color-primary));
font-size: 1.25rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
display: flex; display: flex;
align-items: center; align-items: center;
@ -192,196 +264,121 @@
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.profile-info { .card-icon {
flex: 1; font-size: 1.5rem;
min-width: 0; line-height: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
} }
.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; margin: 0;
font-size: 1.0625rem; font-size: 1.0625rem;
font-weight: 600; font-weight: 600;
line-height: 1.3; line-height: 1.3;
color: hsl(var(--color-foreground));
} }
.profile-email { .card-sub {
margin: 0; margin: 0;
font-size: 0.8125rem; font-size: 0.75rem;
color: hsl(var(--color-muted-foreground)); color: hsl(var(--color-muted-foreground));
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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 { .tier-badge {
align-self: flex-start; align-self: flex-start;
margin-top: 0.25rem; padding: 0.125rem 0.5rem;
padding: 0.125rem 0.625rem;
border-radius: 9999px; border-radius: 9999px;
background: hsl(var(--color-primary) / 0.1); background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary)); color: hsl(var(--color-primary));
font-size: 0.6875rem; font-size: 0.625rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.04em; letter-spacing: 0.05em;
text-transform: uppercase; 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 { .danger-title {
color: hsl(var(--color-error)); color: hsl(var(--color-error));
} }
.action-desc { .card-meta {
margin: 0; flex: 0 0 auto;
font-size: 0.8125rem; padding-top: 0.5rem;
line-height: 1.55;
color: hsl(var(--color-muted-foreground));
} }
/* ── Buttons ─────────────────────────────────────────────────────── */ /* Buttons */
.btn-outline { .btn-ghost {
padding: 0.4375rem 0.875rem; padding: 0.375rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.4375rem;
border: 1px solid hsl(var(--color-border)); border: 1px solid hsl(var(--color-border));
background: transparent; background: transparent;
font-size: 0.8125rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
color: hsl(var(--color-foreground)); color: hsl(var(--color-foreground));
cursor: pointer; 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; transition: background 0.15s;
} }
.btn-danger:hover:not(:disabled) { .btn-ghost:hover {
background: hsl(var(--color-error) / 0.08); background: hsl(var(--color-border) / 0.4);
} }
.btn-danger:disabled { .btn-primary {
opacity: 0.5; padding: 0.4375rem 0.875rem;
cursor: not-allowed; 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> </style>

View file

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

View file

@ -3,6 +3,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { import {
import { apiErrorMessage } from '$lib/api/error.ts';
extractClusterIds, extractClusterIds,
maskRegionCount, maskRegionCount,
renderClozePrompt, renderClozePrompt,
@ -82,7 +83,7 @@
} }
} }
} catch (e) { } 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); toasts.success(msg);
goto(`/decks/${card.deck_id}`); goto(`/decks/${card.deck_id}`);
} catch (e) { } 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; saving = false;
} }
} }

View file

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

View file

@ -15,6 +15,7 @@
import PublishDeckModal from '$lib/components/marketplace/PublishDeckModal.svelte'; import PublishDeckModal from '$lib/components/marketplace/PublishDeckModal.svelte';
import { Image, CaretRight, DotsThree, PencilSimple, Trash } from '@mana/shared-icons'; import { Image, CaretRight, DotsThree, PencilSimple, Trash } from '@mana/shared-icons';
import { marked } from 'marked'; import { marked } from 'marked';
import { apiErrorMessage } from '$lib/api/error.ts';
function md(text: string): string { function md(text: string): string {
return marked.parse(text, { async: false }) as string; return marked.parse(text, { async: false }) as string;
@ -62,7 +63,7 @@
dueCount = due.total; dueCount = due.total;
error = null; error = null;
} catch (e) { } catch (e) {
error = (e as Error).message; error = apiErrorMessage(e);
} finally { } finally {
loading = false; loading = false;
} }
@ -75,7 +76,7 @@
deck = await updateDeck(deck.id, { category: next ?? undefined }); deck = await updateDeck(deck.id, { category: next ?? undefined });
categoryOpen = false; categoryOpen = false;
} catch (e) { } catch (e) {
toasts.error((e as Error).message); toasts.error(apiErrorMessage(e));
} }
} }
@ -86,7 +87,7 @@
toasts.success(t('card_edit.deleted')); toasts.success(t('card_edit.deleted'));
await refresh(); await refresh();
} catch (e) { } 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 { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts'; import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts'; import { t } from '$lib/i18n/index.svelte.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
const deckId = $derived(page.params.id ?? ''); const deckId = $derived(page.params.id ?? '');
@ -23,7 +24,7 @@
description = deck.description ?? ''; description = deck.description ?? '';
color = deck.color ?? '#6366f1'; color = deck.color ?? '#6366f1';
} catch (e) { } catch (e) {
toasts.error((e as Error).message); toasts.error(apiErrorMessage(e));
goto(`/decks/${deckId}`); goto(`/decks/${deckId}`);
} finally { } finally {
loading = false; loading = false;
@ -45,7 +46,7 @@
toasts.success(t('deck_edit.saved')); toasts.success(t('deck_edit.saved'));
goto(`/decks/${deckId}`); goto(`/decks/${deckId}`);
} catch (e) { } 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; saving = false;
} }
} }

View file

@ -7,6 +7,7 @@
import { toasts } from '$lib/stores/toasts.svelte.ts'; import { toasts } from '$lib/stores/toasts.svelte.ts';
import { i18n, t } from '$lib/i18n/index.svelte.ts'; import { i18n, t } from '$lib/i18n/index.svelte.ts';
import DeckCategoryIcon from '$lib/components/DeckCategoryIcon.svelte'; import DeckCategoryIcon from '$lib/components/DeckCategoryIcon.svelte';
import { apiErrorMessage } from '$lib/api/error.ts';
let name = $state(''); let name = $state('');
let description = $state(''); let description = $state('');
@ -68,7 +69,7 @@
toasts.success(`🖼 "${result.deck.name}" mit ${result.cards_created} Karten erstellt`); toasts.success(`🖼 "${result.deck.name}" mit ${result.cards_created} Karten erstellt`);
goto(`/decks/${result.deck.id}`); goto(`/decks/${result.deck.id}`);
} catch (err) { } catch (err) {
imageError = (err as Error).message; imageError = apiErrorMessage(err);
imageGenerating = false; imageGenerating = false;
} }
} }
@ -95,7 +96,7 @@
toasts.success(`${deck.name} ✓`); toasts.success(`${deck.name} ✓`);
goto(`/decks/${deck.id}`); goto(`/decks/${deck.id}`);
} catch (err) { } 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; saving = false;
} }
} }
@ -109,7 +110,7 @@
toasts.success(`✨ "${result.deck.name}" mit ${result.cards_created} Karten erstellt`); toasts.success(`✨ "${result.deck.name}" mit ${result.cards_created} Karten erstellt`);
goto(`/decks/${result.deck.id}`); goto(`/decks/${result.deck.id}`);
} catch (err) { } catch (err) {
aiError = (err as Error).message; aiError = apiErrorMessage(err);
generating = false; generating = false;
} }
} }

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { import {
import { apiErrorMessage } from '$lib/api/error.ts';
browseDecks, browseDecks,
getExplore, getExplore,
type DeckListEntry, type DeckListEntry,
@ -31,7 +32,7 @@
featured = explore.featured; featured = explore.featured;
trending = explore.trending; trending = explore.trending;
} catch (e) { } catch (e) {
toasts.error(`Explore-Liste laden fehlgeschlagen: ${(e as Error).message}`); toasts.error(`Explore-Liste laden fehlgeschlagen: ${apiErrorMessage(e)}`);
} finally { } finally {
loadingExplore = false; loadingExplore = false;
} }
@ -59,7 +60,7 @@
} }
browseTotal = result.total; browseTotal = result.total;
} catch (e) { } catch (e) {
toasts.error(`Browse fehlgeschlagen: ${(e as Error).message}`); toasts.error(`Browse fehlgeschlagen: ${apiErrorMessage(e)}`);
} finally { } finally {
loadingBrowse = false; loadingBrowse = false;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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