mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
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:
parent
3b99356464
commit
988c17a678
13 changed files with 144 additions and 421 deletions
|
|
@ -184,7 +184,7 @@ export function useAiTierItems() {
|
|||
id: 'ai-settings',
|
||||
label: 'KI-Einstellungen',
|
||||
icon: 'settings',
|
||||
onClick: () => goto('/settings#ai-options'),
|
||||
onClick: () => goto('/'),
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@
|
|||
</a>
|
||||
|
||||
<a
|
||||
href="/settings"
|
||||
href="/"
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<!--
|
||||
SettingsSidebar — vertical category nav (lg+) and horizontal pill row
|
||||
(mobile), with an inline search field that surfaces a quick result list.
|
||||
Owns the search query; the parent owns the active category.
|
||||
SettingsSidebar — horizontal category chip row with an inline search
|
||||
field that surfaces a quick result list. Owns the search query; the
|
||||
parent owns the active category.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { MagnifyingGlass, X } from '@mana/shared-icons';
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
activeCategory: CategoryId;
|
||||
onSelect: (id: CategoryId) => void;
|
||||
onJump: (entry: SearchEntry) => void;
|
||||
/** Override the default categories list (e.g. to exclude profile in workbench). */
|
||||
/** Override the default categories list. */
|
||||
categories?: Category[];
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +78,6 @@
|
|||
<div class="no-results">Keine Treffer für „{query}"</div>
|
||||
{/if}
|
||||
|
||||
<!-- Mobile horizontal chips (hidden on lg+ via local media query) -->
|
||||
<div class="chip-row" role="tablist">
|
||||
{#each categories as cat (cat.id)}
|
||||
{@const Icon = cat.icon}
|
||||
|
|
@ -98,36 +97,6 @@
|
|||
</button>
|
||||
{/each}
|
||||
</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>
|
||||
|
||||
<style>
|
||||
|
|
@ -136,14 +105,6 @@
|
|||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.settings-sidebar {
|
||||
position: sticky;
|
||||
top: 6rem;
|
||||
width: 17rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Search field ───────────────────────────────────────────────── */
|
||||
.search-wrapper {
|
||||
|
|
@ -245,7 +206,7 @@
|
|||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* ── Mobile chips ───────────────────────────────────────────────── */
|
||||
/* ── Category chips ─────────────────────────────────────────────── */
|
||||
.chip-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
|
@ -257,14 +218,6 @@
|
|||
.chip-row::-webkit-scrollbar {
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
|
|
@ -292,85 +245,4 @@
|
|||
.chip-btn.dim {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
* updates both the navigation and the search results.
|
||||
*/
|
||||
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 {
|
||||
id: CategoryId;
|
||||
|
|
@ -18,13 +18,6 @@ export interface Category {
|
|||
}
|
||||
|
||||
export const categories: Category[] = [
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Profil',
|
||||
description: 'Persönliche Daten & Konto',
|
||||
icon: User,
|
||||
anchors: ['profile', 'account'],
|
||||
},
|
||||
{
|
||||
id: 'general',
|
||||
label: 'Allgemein',
|
||||
|
|
@ -72,18 +65,6 @@ export interface 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
|
||||
{
|
||||
label: 'Theme',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -34,6 +34,7 @@ import { STRETCH_GUEST_SEED } from '$lib/modules/stretch/collections';
|
|||
import { MEDITATE_GUEST_SEED } from '$lib/modules/meditate/collections';
|
||||
import { SLEEP_GUEST_SEED } from '$lib/modules/sleep/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
|
||||
|
|
@ -72,6 +73,7 @@ register(STRETCH_GUEST_SEED);
|
|||
register(MEDITATE_GUEST_SEED);
|
||||
register(SLEEP_GUEST_SEED);
|
||||
register(MOOD_GUEST_SEED);
|
||||
register(QUIZ_GUEST_SEED);
|
||||
|
||||
/**
|
||||
* Seed all module guest data into empty tables. Idempotent: tables
|
||||
|
|
|
|||
|
|
@ -8,15 +8,17 @@
|
|||
import { quizzesStore } from './stores/quizzes.svelte';
|
||||
import { QUESTION_TYPE_LABELS } 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 {
|
||||
quizId: string;
|
||||
}
|
||||
let { quizId }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const quiz$ = useQuiz(quizId);
|
||||
const quiz = $derived(quiz$.value);
|
||||
// svelte-ignore state_referenced_locally
|
||||
const questions$ = useQuestions(quizId);
|
||||
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 newText = $state('');
|
||||
let newExplanation = $state('');
|
||||
|
|
@ -59,30 +62,52 @@
|
|||
]);
|
||||
let newTextAnswer = $state('');
|
||||
|
||||
function resetNewForm() {
|
||||
newText = '';
|
||||
newExplanation = '';
|
||||
newTextAnswer = '';
|
||||
if (newType === 'truefalse') {
|
||||
newOptions = [
|
||||
function defaultOptions(type: QuestionType): QuestionOption[] {
|
||||
if (type === 'truefalse') {
|
||||
return [
|
||||
{ id: 't', text: 'Wahr', isCorrect: true },
|
||||
{ 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 = [];
|
||||
} else {
|
||||
newOptions = [
|
||||
{ id: crypto.randomUUID(), text: '', isCorrect: newType === 'single' },
|
||||
{ id: crypto.randomUUID(), text: '', isCorrect: false },
|
||||
];
|
||||
newTextAnswer = '';
|
||||
newOptions = q.options.map((o) => ({ ...o }));
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Rebuild option slots when question type changes.
|
||||
newType;
|
||||
function cancelEdit() {
|
||||
resetNewForm();
|
||||
});
|
||||
}
|
||||
|
||||
function toggleNewCorrect(id: string) {
|
||||
if (newType === 'single' || newType === 'truefalse') {
|
||||
|
|
@ -100,7 +125,7 @@
|
|||
newOptions = newOptions.filter((o) => o.id !== id);
|
||||
}
|
||||
|
||||
async function submitNewQuestion() {
|
||||
async function submitQuestion() {
|
||||
const text = newText.trim();
|
||||
if (!text) return;
|
||||
|
||||
|
|
@ -116,12 +141,21 @@
|
|||
options = valid.map((o) => ({ ...o, text: o.text.trim() }));
|
||||
}
|
||||
|
||||
await quizzesStore.addQuestion(quizId, {
|
||||
type: newType,
|
||||
questionText: text,
|
||||
options,
|
||||
explanation: newExplanation.trim() || null,
|
||||
});
|
||||
if (editingId) {
|
||||
await quizzesStore.updateQuestion(editingId, {
|
||||
type: newType,
|
||||
questionText: text,
|
||||
options,
|
||||
explanation: newExplanation.trim() || null,
|
||||
});
|
||||
} else {
|
||||
await quizzesStore.addQuestion(quizId, {
|
||||
type: newType,
|
||||
questionText: text,
|
||||
options,
|
||||
explanation: newExplanation.trim() || null,
|
||||
});
|
||||
}
|
||||
resetNewForm();
|
||||
}
|
||||
|
||||
|
|
@ -198,10 +232,18 @@
|
|||
{:else}
|
||||
<ol class="question-list">
|
||||
{#each questions as q, i (q.id)}
|
||||
<li class="question-item">
|
||||
<li class="question-item" class:editing={editingId === q.id}>
|
||||
<div class="q-header">
|
||||
<span class="q-num">{i + 1}</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
|
||||
class="icon-btn"
|
||||
title="Löschen"
|
||||
|
|
@ -224,11 +266,22 @@
|
|||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="new-section">
|
||||
<h2>Neue Frage</h2>
|
||||
<section class="new-section" class:is-editing={editingId}>
|
||||
<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">
|
||||
<span>Typ</span>
|
||||
<select bind:value={newType}>
|
||||
<select bind:value={newType} onchange={onTypeChange}>
|
||||
<option value="single">Single Choice</option>
|
||||
<option value="multi">Multiple Choice</option>
|
||||
<option value="truefalse">Wahr / Falsch</option>
|
||||
|
|
@ -295,8 +348,12 @@
|
|||
></textarea>
|
||||
</label>
|
||||
|
||||
<button class="submit-btn" onclick={submitNewQuestion}>
|
||||
<Plus size={14} /> Frage hinzufügen
|
||||
<button class="submit-btn" onclick={submitQuestion}>
|
||||
{#if editingId}
|
||||
<Check size={14} /> Änderungen speichern
|
||||
{:else}
|
||||
<Plus size={14} /> Frage hinzufügen
|
||||
{/if}
|
||||
</button>
|
||||
</section>
|
||||
{/if}
|
||||
|
|
@ -417,6 +474,10 @@
|
|||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
.question-item.editing {
|
||||
border-color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
}
|
||||
.q-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -478,6 +539,35 @@
|
|||
border-radius: 0.5rem;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@
|
|||
}
|
||||
let { quizId }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const quiz$ = useQuiz(quizId);
|
||||
const quiz = $derived(quiz$.value);
|
||||
// svelte-ignore state_referenced_locally
|
||||
const questions$ = useQuestions(quizId);
|
||||
const questions = $derived(questions$.value);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<!--
|
||||
Settings — Workbench-embedded settings panel with category sidebar,
|
||||
search, and all setting sections (general, AI, security, credits, data).
|
||||
Profile and Themes are separate workbench apps — not duplicated here.
|
||||
Settings — the single home for app settings (general, AI, security,
|
||||
credits, data). Profile and Themes live in their own workbench apps.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
|
@ -10,19 +9,13 @@
|
|||
import { GlobalSettingsSection } from '@mana/shared-ui';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import SettingsSidebar from '$lib/components/settings/SettingsSidebar.svelte';
|
||||
import {
|
||||
categories,
|
||||
type CategoryId,
|
||||
type SearchEntry,
|
||||
} from '$lib/components/settings/searchIndex';
|
||||
import type { CategoryId, SearchEntry } from '$lib/components/settings/searchIndex';
|
||||
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';
|
||||
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');
|
||||
|
||||
onMount(() => {
|
||||
|
|
@ -30,7 +23,6 @@
|
|||
});
|
||||
|
||||
function jumpTo(entry: SearchEntry) {
|
||||
if (entry.category === 'profile') return;
|
||||
activeCategory = entry.category;
|
||||
void tick().then(() => {
|
||||
const target = document.getElementById(entry.anchor);
|
||||
|
|
@ -44,7 +36,6 @@
|
|||
{activeCategory}
|
||||
onSelect={(id) => (activeCategory = id)}
|
||||
onJump={jumpTo}
|
||||
categories={workbenchCategories}
|
||||
/>
|
||||
|
||||
<div class="settings-content">
|
||||
|
|
@ -71,21 +62,14 @@
|
|||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.settings-page {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
|
|
|
|||
|
|
@ -746,7 +746,7 @@
|
|||
id: 'settings',
|
||||
label: 'Einstellungen',
|
||||
category: 'Navigation',
|
||||
onExecute: () => goto('/settings'),
|
||||
onExecute: () => goto('/'),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
|
@ -974,7 +974,7 @@
|
|||
currentSyncLabel={syncStatus.label}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
settingsHref="/"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
spiralHref="/spiral"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -186,7 +186,7 @@
|
|||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<p class="text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@
|
|||
</script>
|
||||
|
||||
<div>
|
||||
<PageHeader title="Cloud Sync" backHref="/settings" sticky size="lg" />
|
||||
<PageHeader title="Cloud Sync" backHref="/" sticky size="lg" />
|
||||
|
||||
{#if syncBilling.loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
|
|
@ -268,8 +268,8 @@
|
|||
|
||||
<!-- Back link -->
|
||||
<div class="mt-6">
|
||||
<a href="/settings" class="text-sm text-primary hover:underline">
|
||||
← Zurück zu Einstellungen
|
||||
<a href="/" class="text-sm text-primary hover:underline">
|
||||
← Zurück
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue