mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:21: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',
|
id: 'ai-settings',
|
||||||
label: 'KI-Einstellungen',
|
label: 'KI-Einstellungen',
|
||||||
icon: 'settings',
|
icon: 'settings',
|
||||||
onClick: () => goto('/settings#ai-options'),
|
onClick: () => goto('/'),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 { 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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 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">
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue