- 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>
384 lines
9.3 KiB
Svelte
384 lines
9.3 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { goto } from '$app/navigation';
|
|
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
|
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);
|
|
|
|
onMount(() => {
|
|
if (!devUser.id) goto('/');
|
|
});
|
|
|
|
const initials = $derived.by(() => {
|
|
const name = devUser.user?.name ?? devUser.user?.email ?? '?';
|
|
return name
|
|
.split(/[\s@.]+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((w) => w[0].toUpperCase())
|
|
.join('');
|
|
});
|
|
|
|
const profileLayers = stackLayers('account-profile', 3);
|
|
const exportLayers = stackLayers('account-export', 3);
|
|
const dangerLayers = stackLayers('account-danger', 3);
|
|
|
|
async function onExport() {
|
|
exporting = true;
|
|
try {
|
|
const data = await exportMe();
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `cards-export-${new Date().toISOString().slice(0, 10)}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
toasts.success(
|
|
t('account.export_done', {
|
|
decks: data.data.decks.length,
|
|
cards: data.data.cards.length,
|
|
reviews: data.data.reviews.length,
|
|
}),
|
|
);
|
|
} catch (e) {
|
|
toasts.error(t('account.export_failed', { msg: apiErrorMessage(e) }));
|
|
} finally {
|
|
exporting = false;
|
|
}
|
|
}
|
|
|
|
async function onDelete() {
|
|
const confirmation = prompt(t('account.delete_confirm'));
|
|
if (confirmation !== t('account.delete_confirm_word')) return;
|
|
deleting = true;
|
|
try {
|
|
const r = await deleteMe();
|
|
toasts.success(
|
|
t('account.delete_done', { decks: r.counts.decks, imports: r.counts.import_jobs }),
|
|
);
|
|
devUser.clear();
|
|
goto('/');
|
|
} catch (e) {
|
|
toasts.error(t('account.delete_failed', { msg: apiErrorMessage(e) }));
|
|
deleting = false;
|
|
}
|
|
}
|
|
|
|
function logout() {
|
|
devUser.clear();
|
|
goto('/');
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{t('account.title')} · {t('app.title_suffix')}</title>
|
|
</svelte:head>
|
|
|
|
<h1 class="page-title">{t('account.title')}</h1>
|
|
|
|
<ul class="card-row" aria-label="Account-Karten">
|
|
|
|
<!-- Profil-Karte -->
|
|
<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>
|
|
<div class="card-body">
|
|
{#if devUser.user?.name}
|
|
<p class="card-title">{devUser.user.name}</p>
|
|
{/if}
|
|
<p class="card-sub">{devUser.user?.email ?? '—'}</p>
|
|
{#if devUser.user?.tier}
|
|
<span class="tier-badge">{devUser.user.tier}</span>
|
|
{/if}
|
|
</div>
|
|
<div class="card-meta">
|
|
<button type="button" class="btn-ghost" onclick={logout}>
|
|
{t('account.logout')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</CardSurface>
|
|
</li>
|
|
|
|
<!-- Export-Karte -->
|
|
<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"
|
|
onclick={onExport}
|
|
disabled={exporting}
|
|
>
|
|
{exporting ? t('account.export_loading') : t('account.export_button')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</CardSurface>
|
|
</li>
|
|
|
|
<!-- Danger-Karte -->
|
|
<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"
|
|
onclick={onDelete}
|
|
disabled={deleting}
|
|
>
|
|
{deleting ? t('account.delete_loading') : t('account.delete_button')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</CardSurface>
|
|
</li>
|
|
|
|
</ul>
|
|
|
|
<style>
|
|
.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;
|
|
padding: 1rem 1rem 1.125rem 1.375rem;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.card-corner {
|
|
position: absolute;
|
|
top: 0.875rem;
|
|
right: 0.875rem;
|
|
}
|
|
|
|
.avatar {
|
|
width: 2.5rem;
|
|
height: 2.5rem;
|
|
border-radius: 50%;
|
|
background: hsl(var(--color-primary) / 0.15);
|
|
color: hsl(var(--color-primary));
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
|
|
.card-icon {
|
|
font-size: 1.5rem;
|
|
line-height: 1;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.card-sub {
|
|
margin: 0;
|
|
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;
|
|
padding: 0.125rem 0.5rem;
|
|
border-radius: 9999px;
|
|
background: hsl(var(--color-primary) / 0.1);
|
|
color: hsl(var(--color-primary));
|
|
font-size: 0.625rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.danger-title {
|
|
color: hsl(var(--color-error));
|
|
}
|
|
|
|
.card-meta {
|
|
flex: 0 0 auto;
|
|
padding-top: 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.75rem;
|
|
font-weight: 500;
|
|
color: hsl(var(--color-foreground));
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.btn-ghost:hover {
|
|
background: hsl(var(--color-border) / 0.4);
|
|
}
|
|
|
|
.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>
|