feat(quiz): edit existing questions + wire up guest seed

- Pencil button on each question opens the bottom form pre-filled;
  submit updates in place instead of appending.
- Guest users now see the demo quiz on first visit (QUIZ_GUEST_SEED
  registered with seedAllGuestData).
- Silence state_referenced_locally warnings with svelte-ignore to
  match the pattern used in cards / landing / rsvp views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 21:24:53 +02:00
parent 3b99356464
commit 988c17a678
13 changed files with 144 additions and 421 deletions

View file

@ -184,7 +184,7 @@ export function useAiTierItems() {
id: 'ai-settings', id: 'ai-settings',
label: 'KI-Einstellungen', label: 'KI-Einstellungen',
icon: 'settings', icon: 'settings',
onClick: () => goto('/settings#ai-options'), onClick: () => goto('/'),
}, },
]); ]);

View file

@ -84,7 +84,7 @@
</a> </a>
<a <a
href="/settings" href="/"
class="p-4 rounded-xl bg-card border hover:border-primary/50 hover:shadow-md transition-all group" class="p-4 rounded-xl bg-card border hover:border-primary/50 hover:shadow-md transition-all group"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">

View file

@ -1,7 +1,7 @@
<!-- <!--
SettingsSidebar — vertical category nav (lg+) and horizontal pill row SettingsSidebar — horizontal category chip row with an inline search
(mobile), with an inline search field that surfaces a quick result list. field that surfaces a quick result list. Owns the search query; the
Owns the search query; the parent owns the active category. parent owns the active category.
--> -->
<script lang="ts"> <script lang="ts">
import { MagnifyingGlass, X } from '@mana/shared-icons'; import { MagnifyingGlass, X } from '@mana/shared-icons';
@ -17,7 +17,7 @@
activeCategory: CategoryId; activeCategory: CategoryId;
onSelect: (id: CategoryId) => void; onSelect: (id: CategoryId) => void;
onJump: (entry: SearchEntry) => void; onJump: (entry: SearchEntry) => void;
/** Override the default categories list (e.g. to exclude profile in workbench). */ /** Override the default categories list. */
categories?: Category[]; categories?: Category[];
} }
@ -78,7 +78,6 @@
<div class="no-results">Keine Treffer für „{query}"</div> <div class="no-results">Keine Treffer für „{query}"</div>
{/if} {/if}
<!-- Mobile horizontal chips (hidden on lg+ via local media query) -->
<div class="chip-row" role="tablist"> <div class="chip-row" role="tablist">
{#each categories as cat (cat.id)} {#each categories as cat (cat.id)}
{@const Icon = cat.icon} {@const Icon = cat.icon}
@ -98,36 +97,6 @@
</button> </button>
{/each} {/each}
</div> </div>
<!-- Desktop vertical sidebar -->
<nav class="hidden lg:block">
<ul class="cat-list" role="tablist">
{#each categories as cat (cat.id)}
{@const Icon = cat.icon}
{@const isActive = activeCategory === cat.id}
{@const dim = query.length > 0 && !highlightedCategoryIds.has(cat.id)}
<li>
<button
type="button"
role="tab"
aria-selected={isActive}
onclick={() => onSelect(cat.id)}
class="cat-btn"
class:active={isActive}
class:dim
>
<span class="cat-icon" class:icon-active={isActive}>
<Icon size={18} />
</span>
<span class="cat-text">
<span class="cat-label">{cat.label}</span>
<span class="cat-desc">{cat.description}</span>
</span>
</button>
</li>
{/each}
</ul>
</nav>
</aside> </aside>
<style> <style>
@ -136,14 +105,6 @@
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
} }
@media (min-width: 1024px) {
.settings-sidebar {
position: sticky;
top: 6rem;
width: 17rem;
flex-shrink: 0;
}
}
/* ── Search field ───────────────────────────────────────────────── */ /* ── Search field ───────────────────────────────────────────────── */
.search-wrapper { .search-wrapper {
@ -245,7 +206,7 @@
color: hsl(var(--color-muted-foreground)); color: hsl(var(--color-muted-foreground));
} }
/* ── Mobile chips ───────────────────────────────────────────────── */ /* ── Category chips ─────────────────────────────────────────────── */
.chip-row { .chip-row {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@ -257,14 +218,6 @@
.chip-row::-webkit-scrollbar { .chip-row::-webkit-scrollbar {
display: none; display: none;
} }
/* Hide the mobile chip row on desktop. A media query inside the
scoped <style> block beats the unscoped Tailwind .lg\:hidden,
which would otherwise lose the specificity battle. */
@media (min-width: 1024px) {
.chip-row {
display: none;
}
}
.chip-btn { .chip-btn {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
@ -292,85 +245,4 @@
.chip-btn.dim { .chip-btn.dim {
opacity: 0.4; opacity: 0.4;
} }
/* ── Desktop sidebar buttons ────────────────────────────────────── */
.cat-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.cat-btn {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.75rem;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 0.875rem;
text-align: left;
cursor: pointer;
color: hsl(var(--color-foreground));
transition:
background 0.15s,
border-color 0.15s,
box-shadow 0.15s,
transform 0.15s;
}
.cat-btn:hover {
background: hsl(var(--color-surface-hover));
border-color: hsl(var(--color-border-strong, var(--color-border)));
transform: translateY(-1px);
}
.cat-btn.active {
background: hsl(var(--color-primary) / 0.12);
border-color: hsl(var(--color-primary) / 0.35);
box-shadow:
inset 0 0 0 1px hsl(var(--color-primary) / 0.2),
0 2px 6px hsl(var(--color-primary) / 0.12);
}
.cat-btn.dim {
opacity: 0.45;
}
.cat-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.625rem;
background: hsl(var(--color-muted) / 0.5);
color: hsl(var(--color-muted-foreground));
transition:
background 0.15s,
color 0.15s;
}
.icon-active {
background: hsl(var(--color-primary) / 0.18);
color: hsl(var(--color-primary));
}
.cat-text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.cat-label {
font-size: 0.875rem;
font-weight: 600;
line-height: 1.2;
}
.cat-desc {
margin-top: 0.125rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style> </style>

View file

@ -4,9 +4,9 @@
* updates both the navigation and the search results. * updates both the navigation and the search results.
*/ */
import type { Component } from 'svelte'; import type { Component } from 'svelte';
import { User, Gear, Robot, ShieldCheck, CurrencyCircleDollar, Cloud } from '@mana/shared-icons'; import { Gear, Robot, ShieldCheck, CurrencyCircleDollar, Cloud } from '@mana/shared-icons';
export type CategoryId = 'profile' | 'general' | 'ai' | 'security' | 'credits' | 'data'; export type CategoryId = 'general' | 'ai' | 'security' | 'credits' | 'data';
export interface Category { export interface Category {
id: CategoryId; id: CategoryId;
@ -18,13 +18,6 @@ export interface Category {
} }
export const categories: Category[] = [ export const categories: Category[] = [
{
id: 'profile',
label: 'Profil',
description: 'Persönliche Daten & Konto',
icon: User,
anchors: ['profile', 'account'],
},
{ {
id: 'general', id: 'general',
label: 'Allgemein', label: 'Allgemein',
@ -72,18 +65,6 @@ export interface SearchEntry {
} }
export const searchIndex: SearchEntry[] = [ export const searchIndex: SearchEntry[] = [
// Profile
{ label: 'E-Mail', keywords: ['email', 'mail'], category: 'profile', anchor: 'profile' },
{ label: 'Vorname', keywords: ['name'], category: 'profile', anchor: 'profile' },
{ label: 'Nachname', keywords: ['name'], category: 'profile', anchor: 'profile' },
{
label: 'Konto-Status',
keywords: ['rolle', 'role', 'aktiv'],
category: 'profile',
anchor: 'account',
},
{ label: 'Benutzer-ID', keywords: ['id', 'uid'], category: 'profile', anchor: 'account' },
// General // General
{ {
label: 'Theme', label: 'Theme',

View file

@ -1,134 +0,0 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { Button, Input } from '@mana/shared-ui';
import { User, ShieldCheck } from '@mana/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { profileService } from '$lib/api/profile';
import { ManaEvents } from '@mana/shared-utils/analytics';
import SettingsPanel from '../SettingsPanel.svelte';
import SettingsSectionHeader from '../SettingsSectionHeader.svelte';
let savingProfile = $state(false);
let profileSuccess = $state(false);
let profileError = $state<string | null>(null);
let firstName = $state('');
let lastName = $state('');
async function handleUpdateProfile() {
const name = `${firstName} ${lastName}`.trim();
if (!name) {
profileError = 'Bitte gib einen Namen ein';
return;
}
savingProfile = true;
profileSuccess = false;
profileError = null;
try {
await profileService.updateProfile({ name });
profileSuccess = true;
ManaEvents.profileUpdated();
} catch (e) {
profileError = e instanceof Error ? e.message : $_('common.error_saving');
} finally {
savingProfile = false;
}
}
</script>
<SettingsPanel id="profile">
<SettingsSectionHeader
icon={User}
title="Profil"
description="Deine persönlichen Informationen"
tone="primary"
/>
{#if profileSuccess}
<div
class="mb-4 rounded-lg bg-green-50 p-4 text-sm text-green-800 dark:bg-green-900/20 dark:text-green-400"
>
Profil erfolgreich aktualisiert!
</div>
{/if}
{#if profileError}
<div
class="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-400"
>
{profileError}
</div>
{/if}
<div class="space-y-4">
<div>
<label for="email" class="mb-2 block text-sm font-medium">E-Mail</label>
<Input
type="email"
id="email"
value={authStore.user?.email || ''}
disabled
class="bg-muted"
/>
<p class="mt-1 text-xs text-muted-foreground">E-Mail kann nicht geändert werden</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="firstName" class="mb-2 block text-sm font-medium">Vorname</label>
<Input type="text" id="firstName" bind:value={firstName} placeholder="Max" />
</div>
<div>
<label for="lastName" class="mb-2 block text-sm font-medium">Nachname</label>
<Input type="text" id="lastName" bind:value={lastName} placeholder="Mustermann" />
</div>
</div>
<Button onclick={handleUpdateProfile} loading={savingProfile} class="w-full sm:w-auto">
{savingProfile ? $_('common.saving') : 'Änderungen speichern'}
</Button>
</div>
</SettingsPanel>
<SettingsPanel id="account">
<SettingsSectionHeader
icon={ShieldCheck}
title="Konto"
description="Konto- und Sicherheitsinformationen"
tone="blue"
/>
<div>
<div class="flex items-center justify-between border-b border-border py-3">
<div>
<p class="font-medium">Konto-Status</p>
<p class="text-sm text-muted-foreground">Dein aktueller Kontostatus</p>
</div>
<span
class="rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-800 dark:bg-green-900/20 dark:text-green-400"
>
Aktiv
</span>
</div>
<div class="flex items-center justify-between border-b border-border py-3">
<div>
<p class="font-medium">Rolle</p>
<p class="text-sm text-muted-foreground">Deine Berechtigungsstufe</p>
</div>
<span
class="rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
>
{authStore.user?.role || 'user'}
</span>
</div>
<div class="flex items-center justify-between py-3">
<div>
<p class="font-medium">Benutzer-ID</p>
<p class="text-sm text-muted-foreground">Deine eindeutige Kennung</p>
</div>
<code class="rounded bg-muted px-2 py-1 font-mono text-xs">
{authStore.user?.id?.slice(0, 8) || '...'}...
</code>
</div>
</div>
</SettingsPanel>

View file

@ -34,6 +34,7 @@ import { STRETCH_GUEST_SEED } from '$lib/modules/stretch/collections';
import { MEDITATE_GUEST_SEED } from '$lib/modules/meditate/collections'; import { MEDITATE_GUEST_SEED } from '$lib/modules/meditate/collections';
import { SLEEP_GUEST_SEED } from '$lib/modules/sleep/collections'; import { SLEEP_GUEST_SEED } from '$lib/modules/sleep/collections';
import { MOOD_GUEST_SEED } from '$lib/modules/mood/collections'; import { MOOD_GUEST_SEED } from '$lib/modules/mood/collections';
import { QUIZ_GUEST_SEED } from '$lib/modules/quiz/collections';
/** /**
* Flat list of { tableName, rows } entries. Only modules with non-empty * Flat list of { tableName, rows } entries. Only modules with non-empty
@ -72,6 +73,7 @@ register(STRETCH_GUEST_SEED);
register(MEDITATE_GUEST_SEED); register(MEDITATE_GUEST_SEED);
register(SLEEP_GUEST_SEED); register(SLEEP_GUEST_SEED);
register(MOOD_GUEST_SEED); register(MOOD_GUEST_SEED);
register(QUIZ_GUEST_SEED);
/** /**
* Seed all module guest data into empty tables. Idempotent: tables * Seed all module guest data into empty tables. Idempotent: tables

View file

@ -8,15 +8,17 @@
import { quizzesStore } from './stores/quizzes.svelte'; import { quizzesStore } from './stores/quizzes.svelte';
import { QUESTION_TYPE_LABELS } from './types'; import { QUESTION_TYPE_LABELS } from './types';
import type { QuestionType, QuestionOption, QuizQuestion } from './types'; import type { QuestionType, QuestionOption, QuizQuestion } from './types';
import { ArrowLeft, Plus, Trash, Check, Play } from '@mana/shared-icons'; import { ArrowLeft, Plus, Trash, Check, Play, PencilSimple, X } from '@mana/shared-icons';
interface Props { interface Props {
quizId: string; quizId: string;
} }
let { quizId }: Props = $props(); let { quizId }: Props = $props();
// svelte-ignore state_referenced_locally
const quiz$ = useQuiz(quizId); const quiz$ = useQuiz(quizId);
const quiz = $derived(quiz$.value); const quiz = $derived(quiz$.value);
// svelte-ignore state_referenced_locally
const questions$ = useQuestions(quizId); const questions$ = useQuestions(quizId);
const questions = $derived(questions$.value); const questions = $derived(questions$.value);
@ -49,7 +51,8 @@
}); });
} }
// ── New question form ─────────────────────────────── // ── Question form (new OR edit) ─────────────────────
let editingId = $state<string | null>(null);
let newType = $state<QuestionType>('single'); let newType = $state<QuestionType>('single');
let newText = $state(''); let newText = $state('');
let newExplanation = $state(''); let newExplanation = $state('');
@ -59,30 +62,52 @@
]); ]);
let newTextAnswer = $state(''); let newTextAnswer = $state('');
function resetNewForm() { function defaultOptions(type: QuestionType): QuestionOption[] {
newText = ''; if (type === 'truefalse') {
newExplanation = ''; return [
newTextAnswer = '';
if (newType === 'truefalse') {
newOptions = [
{ id: 't', text: 'Wahr', isCorrect: true }, { id: 't', text: 'Wahr', isCorrect: true },
{ id: 'f', text: 'Falsch', isCorrect: false }, { id: 'f', text: 'Falsch', isCorrect: false },
]; ];
} else if (newType === 'text') { }
if (type === 'text') return [];
return [
{ id: crypto.randomUUID(), text: '', isCorrect: type === 'single' },
{ id: crypto.randomUUID(), text: '', isCorrect: false },
];
}
function resetNewForm() {
editingId = null;
newText = '';
newExplanation = '';
newTextAnswer = '';
newOptions = defaultOptions(newType);
}
function onTypeChange() {
// Manual onchange so we don't rebuild options on every unrelated rerender
// (an $effect on newType would wipe the buffer when loading a question for edit).
newOptions = defaultOptions(newType);
newTextAnswer = '';
}
function startEdit(q: QuizQuestion) {
editingId = q.id;
newType = q.type;
newText = q.questionText;
newExplanation = q.explanation ?? '';
if (q.type === 'text') {
newTextAnswer = q.options[0]?.text ?? '';
newOptions = []; newOptions = [];
} else { } else {
newOptions = [ newTextAnswer = '';
{ id: crypto.randomUUID(), text: '', isCorrect: newType === 'single' }, newOptions = q.options.map((o) => ({ ...o }));
{ id: crypto.randomUUID(), text: '', isCorrect: false },
];
} }
} }
$effect(() => { function cancelEdit() {
// Rebuild option slots when question type changes.
newType;
resetNewForm(); resetNewForm();
}); }
function toggleNewCorrect(id: string) { function toggleNewCorrect(id: string) {
if (newType === 'single' || newType === 'truefalse') { if (newType === 'single' || newType === 'truefalse') {
@ -100,7 +125,7 @@
newOptions = newOptions.filter((o) => o.id !== id); newOptions = newOptions.filter((o) => o.id !== id);
} }
async function submitNewQuestion() { async function submitQuestion() {
const text = newText.trim(); const text = newText.trim();
if (!text) return; if (!text) return;
@ -116,12 +141,21 @@
options = valid.map((o) => ({ ...o, text: o.text.trim() })); options = valid.map((o) => ({ ...o, text: o.text.trim() }));
} }
await quizzesStore.addQuestion(quizId, { if (editingId) {
type: newType, await quizzesStore.updateQuestion(editingId, {
questionText: text, type: newType,
options, questionText: text,
explanation: newExplanation.trim() || null, options,
}); explanation: newExplanation.trim() || null,
});
} else {
await quizzesStore.addQuestion(quizId, {
type: newType,
questionText: text,
options,
explanation: newExplanation.trim() || null,
});
}
resetNewForm(); resetNewForm();
} }
@ -198,10 +232,18 @@
{:else} {:else}
<ol class="question-list"> <ol class="question-list">
{#each questions as q, i (q.id)} {#each questions as q, i (q.id)}
<li class="question-item"> <li class="question-item" class:editing={editingId === q.id}>
<div class="q-header"> <div class="q-header">
<span class="q-num">{i + 1}</span> <span class="q-num">{i + 1}</span>
<span class="q-type">{QUESTION_TYPE_LABELS[q.type]}</span> <span class="q-type">{QUESTION_TYPE_LABELS[q.type]}</span>
<button
class="icon-btn"
title="Bearbeiten"
aria-label="Bearbeiten"
onclick={() => startEdit(q)}
>
<PencilSimple size={14} />
</button>
<button <button
class="icon-btn" class="icon-btn"
title="Löschen" title="Löschen"
@ -224,11 +266,22 @@
{/if} {/if}
</section> </section>
<section class="new-section"> <section class="new-section" class:is-editing={editingId}>
<h2>Neue Frage</h2> <div class="new-header">
<h2>
{editingId
? `Frage ${questions.findIndex((x) => x.id === editingId) + 1} bearbeiten`
: 'Neue Frage'}
</h2>
{#if editingId}
<button class="cancel-btn" onclick={cancelEdit}>
<X size={12} /> Abbrechen
</button>
{/if}
</div>
<label class="field"> <label class="field">
<span>Typ</span> <span>Typ</span>
<select bind:value={newType}> <select bind:value={newType} onchange={onTypeChange}>
<option value="single">Single Choice</option> <option value="single">Single Choice</option>
<option value="multi">Multiple Choice</option> <option value="multi">Multiple Choice</option>
<option value="truefalse">Wahr / Falsch</option> <option value="truefalse">Wahr / Falsch</option>
@ -295,8 +348,12 @@
></textarea> ></textarea>
</label> </label>
<button class="submit-btn" onclick={submitNewQuestion}> <button class="submit-btn" onclick={submitQuestion}>
<Plus size={14} /> Frage hinzufügen {#if editingId}
<Check size={14} /> Änderungen speichern
{:else}
<Plus size={14} /> Frage hinzufügen
{/if}
</button> </button>
</section> </section>
{/if} {/if}
@ -417,6 +474,10 @@
border: 1px solid hsl(var(--color-border)); border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface)); background: hsl(var(--color-surface));
} }
.question-item.editing {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.05);
}
.q-header { .q-header {
display: flex; display: flex;
align-items: center; align-items: center;
@ -478,6 +539,35 @@
border-radius: 0.5rem; border-radius: 0.5rem;
border: 1px dashed hsl(var(--color-border)); border: 1px dashed hsl(var(--color-border));
} }
.new-section.is-editing {
border-style: solid;
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.03);
}
.new-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.new-header h2 {
margin: 0;
}
.cancel-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: transparent;
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-muted-foreground));
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
cursor: pointer;
}
.cancel-btn:hover {
color: hsl(var(--color-error));
border-color: hsl(var(--color-error));
}
.field { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -14,8 +14,10 @@
} }
let { quizId }: Props = $props(); let { quizId }: Props = $props();
// svelte-ignore state_referenced_locally
const quiz$ = useQuiz(quizId); const quiz$ = useQuiz(quizId);
const quiz = $derived(quiz$.value); const quiz = $derived(quiz$.value);
// svelte-ignore state_referenced_locally
const questions$ = useQuestions(quizId); const questions$ = useQuestions(quizId);
const questions = $derived(questions$.value); const questions = $derived(questions$.value);

View file

@ -1,7 +1,6 @@
<!-- <!--
Settings — Workbench-embedded settings panel with category sidebar, Settings — the single home for app settings (general, AI, security,
search, and all setting sections (general, AI, security, credits, data). credits, data). Profile and Themes live in their own workbench apps.
Profile and Themes are separate workbench apps — not duplicated here.
--> -->
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@ -10,19 +9,13 @@
import { GlobalSettingsSection } from '@mana/shared-ui'; import { GlobalSettingsSection } from '@mana/shared-ui';
import { userSettings } from '$lib/stores/user-settings.svelte'; import { userSettings } from '$lib/stores/user-settings.svelte';
import SettingsSidebar from '$lib/components/settings/SettingsSidebar.svelte'; import SettingsSidebar from '$lib/components/settings/SettingsSidebar.svelte';
import { import type { CategoryId, SearchEntry } from '$lib/components/settings/searchIndex';
categories,
type CategoryId,
type SearchEntry,
} from '$lib/components/settings/searchIndex';
import AiSection from '$lib/components/settings/sections/AiSection.svelte'; import AiSection from '$lib/components/settings/sections/AiSection.svelte';
import SecuritySection from '$lib/components/settings/sections/SecuritySection.svelte'; import SecuritySection from '$lib/components/settings/sections/SecuritySection.svelte';
import CreditsSection from '$lib/components/settings/sections/CreditsSection.svelte'; import CreditsSection from '$lib/components/settings/sections/CreditsSection.svelte';
import DataSection from '$lib/components/settings/sections/DataSection.svelte'; import DataSection from '$lib/components/settings/sections/DataSection.svelte';
import SettingsPanel from '$lib/components/settings/SettingsPanel.svelte'; import SettingsPanel from '$lib/components/settings/SettingsPanel.svelte';
// Filter out 'profile' — it's a separate workbench app now
const workbenchCategories = categories.filter((c) => c.id !== 'profile');
let activeCategory = $state<CategoryId>('general'); let activeCategory = $state<CategoryId>('general');
onMount(() => { onMount(() => {
@ -30,7 +23,6 @@
}); });
function jumpTo(entry: SearchEntry) { function jumpTo(entry: SearchEntry) {
if (entry.category === 'profile') return;
activeCategory = entry.category; activeCategory = entry.category;
void tick().then(() => { void tick().then(() => {
const target = document.getElementById(entry.anchor); const target = document.getElementById(entry.anchor);
@ -44,7 +36,6 @@
{activeCategory} {activeCategory}
onSelect={(id) => (activeCategory = id)} onSelect={(id) => (activeCategory = id)}
onJump={jumpTo} onJump={jumpTo}
categories={workbenchCategories}
/> />
<div class="settings-content"> <div class="settings-content">
@ -71,21 +62,14 @@
padding: 0.75rem; padding: 0.75rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 1rem;
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
} }
@media (min-width: 1024px) {
.settings-page {
flex-direction: row;
align-items: flex-start;
}
}
.settings-content { .settings-content {
min-width: 0; min-width: 0;
flex: 1; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;

View file

@ -746,7 +746,7 @@
id: 'settings', id: 'settings',
label: 'Einstellungen', label: 'Einstellungen',
category: 'Navigation', category: 'Navigation',
onExecute: () => goto('/settings'), onExecute: () => goto('/'),
}, },
]; ];
</script> </script>
@ -974,7 +974,7 @@
currentSyncLabel={syncStatus.label} currentSyncLabel={syncStatus.label}
{appItems} {appItems}
{userEmail} {userEmail}
settingsHref="/settings" settingsHref="/"
manaHref="/mana" manaHref="/mana"
profileHref="/profile" profileHref="/profile"
spiralHref="/spiral" spiralHref="/spiral"

View file

@ -1,74 +0,0 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import { PageHeader } from '@mana/shared-ui';
import { APP_VERSION } from '$lib/version';
import SettingsSidebar from '$lib/components/settings/SettingsSidebar.svelte';
import {
categories,
type CategoryId,
type SearchEntry,
} from '$lib/components/settings/searchIndex';
import ProfileSection from '$lib/components/settings/sections/ProfileSection.svelte';
import GeneralSection from '$lib/components/settings/sections/GeneralSection.svelte';
import AiSection from '$lib/components/settings/sections/AiSection.svelte';
import SecuritySection from '$lib/components/settings/sections/SecuritySection.svelte';
import CreditsSection from '$lib/components/settings/sections/CreditsSection.svelte';
import DataSection from '$lib/components/settings/sections/DataSection.svelte';
let activeCategory = $state<CategoryId>('profile');
let mounted = $state(false);
onMount(() => {
mounted = true;
});
// Map URL hash → active category and scroll the matching anchor into view.
// Re-runs on every hash change so the pill-nav `/settings#ai-options`
// shortcut still works when the user is already on /settings.
$effect(() => {
const hash = $page.url.hash?.slice(1);
if (!hash || !mounted) return;
const cat = categories.find((c) => c.anchors.includes(hash));
if (cat) activeCategory = cat.id;
void tick().then(() => {
const target = document.getElementById(hash);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
function jumpTo(entry: SearchEntry) {
activeCategory = entry.category;
void tick().then(() => {
const target = document.getElementById(entry.anchor);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
</script>
<div class="mx-auto w-full max-w-4xl px-4 sm:px-6">
<PageHeader title={$_('common.settings')} backHref="/" sticky size="lg" />
<div class="mt-6 flex flex-col gap-6 lg:flex-row lg:items-start">
<SettingsSidebar {activeCategory} onSelect={(id) => (activeCategory = id)} onJump={jumpTo} />
<div class="min-w-0 flex-1 space-y-6">
{#if activeCategory === 'profile'}
<ProfileSection />
{:else if activeCategory === 'general'}
<GeneralSection />
{:else if activeCategory === 'ai'}
<AiSection />
{:else if activeCategory === 'security'}
<SecuritySection />
{:else if activeCategory === 'credits'}
<CreditsSection />
{:else if activeCategory === 'data'}
<DataSection />
{/if}
</div>
</div>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</div>

View file

@ -186,7 +186,7 @@
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
<Breadcrumbs <Breadcrumbs
items={[{ label: 'Einstellungen', href: '/settings' }, { label: 'Meine Daten' }]} items={[{ label: 'Home', href: '/' }, { label: 'Meine Daten' }]}
/> />
<h1 class="text-2xl font-bold">Meine Daten</h1> <h1 class="text-2xl font-bold">Meine Daten</h1>
<p class="text-muted-foreground"> <p class="text-muted-foreground">

View file

@ -106,7 +106,7 @@
</script> </script>
<div> <div>
<PageHeader title="Cloud Sync" backHref="/settings" sticky size="lg" /> <PageHeader title="Cloud Sync" backHref="/" sticky size="lg" />
{#if syncBilling.loading} {#if syncBilling.loading}
<div class="flex items-center justify-center py-12"> <div class="flex items-center justify-center py-12">
@ -268,8 +268,8 @@
<!-- Back link --> <!-- Back link -->
<div class="mt-6"> <div class="mt-6">
<a href="/settings" class="text-sm text-primary hover:underline"> <a href="/" class="text-sm text-primary hover:underline">
← Zurück zu Einstellungen ← Zurück
</a> </a>
</div> </div>
{/if} {/if}