mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(mana/web): redesign settings page + pill-nav compute selector
Settings page now uses a sidebar layout with category buttons (Profil, Allgemein, KI, Sicherheit, Credits, Daten & Sync), an inline search field that jumps to the matching section, and componentized sections under lib/components/settings/. Each section owns its own data loading; the +page.svelte shrinks from 617 to ~85 lines as a thin orchestrator. The pill-nav AI tier dropdown now renders an icon per option (cpu, server, cloud) and a power icon for the off state, and the "KI-Einstellungen" shortcut deep-links to /settings#ai-options which auto-selects the KI tab and scrolls to the panel. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3a93c56fe5
commit
21360d9c18
15 changed files with 1144 additions and 420 deletions
|
|
@ -111,7 +111,7 @@
|
|||
const browserCacheReady = $derived(webgpuSupported && localLlmStatus.current.state === 'ready');
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<div id="ai-options" class="scroll-mt-24 p-6">
|
||||
<div class="mb-6 flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-indigo-100 text-indigo-600 dark:bg-indigo-900/20 dark:text-indigo-400"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
<!--
|
||||
SettingsPanel — frosted "page-shell"-style wrapper for a single settings
|
||||
section. Mirrors the workbench `.page-shell` aesthetic so /settings feels
|
||||
like the rest of the unified Mana app.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/** Anchor id used for deep-linking from the URL hash */
|
||||
id?: string;
|
||||
/** Apply default inner padding (set false when the child handles its own) */
|
||||
padded?: boolean;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { id, padded = true, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section {id} class="settings-panel scroll-mt-24" class:padded>
|
||||
{@render children()}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.settings-panel {
|
||||
background: hsl(var(--color-card));
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
box-shadow:
|
||||
0 2px 8px hsl(0 0% 0% / 0.06),
|
||||
0 1px 2px hsl(0 0% 0% / 0.04);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.settings-panel:hover {
|
||||
border-color: hsl(var(--color-border-strong, var(--color-border)));
|
||||
}
|
||||
.settings-panel.padded {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.settings-panel.padded {
|
||||
padding: 1.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<!--
|
||||
SettingsSectionHeader — icon-circle + title + description, with an optional
|
||||
right-aligned `action` snippet (e.g. an "Alle Details"-Link).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
|
||||
type Tone = 'primary' | 'blue' | 'yellow' | 'purple' | 'indigo' | 'red';
|
||||
|
||||
interface Props {
|
||||
icon: Component;
|
||||
title: string;
|
||||
description?: string;
|
||||
tone?: Tone;
|
||||
action?: Snippet;
|
||||
}
|
||||
|
||||
let { icon: Icon, title, description, tone = 'primary', action }: Props = $props();
|
||||
|
||||
const toneClasses: Record<Tone, string> = {
|
||||
primary: 'bg-primary/10 text-primary',
|
||||
blue: 'bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400',
|
||||
yellow: 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/20 dark:text-yellow-400',
|
||||
purple: 'bg-purple-100 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400',
|
||||
indigo: 'bg-indigo-100 text-indigo-600 dark:bg-indigo-900/20 dark:text-indigo-400',
|
||||
red: 'bg-red-100 text-red-600 dark:bg-red-900/20 dark:text-red-400',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full {toneClasses[tone]}"
|
||||
>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-lg font-semibold">{title}</h2>
|
||||
{#if description}
|
||||
<p class="text-sm text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if action}
|
||||
<div class="shrink-0">{@render action()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
<!--
|
||||
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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { MagnifyingGlass, X } from '@mana/shared-icons';
|
||||
import { categories, searchSettings, type CategoryId, type SearchEntry } from './searchIndex';
|
||||
|
||||
interface Props {
|
||||
activeCategory: CategoryId;
|
||||
onSelect: (id: CategoryId) => void;
|
||||
onJump: (entry: SearchEntry) => void;
|
||||
}
|
||||
|
||||
let { activeCategory, onSelect, onJump }: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let results = $derived(searchSettings(query));
|
||||
let highlightedCategoryIds = $derived(new Set(results.map((r) => r.category)));
|
||||
|
||||
function clearSearch() {
|
||||
query = '';
|
||||
}
|
||||
|
||||
function handleResultClick(entry: SearchEntry) {
|
||||
onJump(entry);
|
||||
clearSearch();
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="settings-sidebar" aria-label="Einstellungs-Kategorien">
|
||||
<!-- Search -->
|
||||
<div class="search-wrapper">
|
||||
<span class="search-icon">
|
||||
<MagnifyingGlass size={16} />
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Einstellungen suchen…"
|
||||
bind:value={query}
|
||||
class="search-input"
|
||||
aria-label="Einstellungen durchsuchen"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') clearSearch();
|
||||
else if (e.key === 'Enter' && results[0]) handleResultClick(results[0]);
|
||||
}}
|
||||
/>
|
||||
{#if query}
|
||||
<button type="button" class="clear-btn" onclick={clearSearch} aria-label="Suche leeren">
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if query && results.length > 0}
|
||||
<ul class="results-list">
|
||||
{#each results as entry (entry.label)}
|
||||
<li>
|
||||
<button type="button" class="result-btn" onclick={() => handleResultClick(entry)}>
|
||||
<span class="result-label">{entry.label}</span>
|
||||
<span class="result-hint">
|
||||
{categories.find((c) => c.id === entry.category)?.label}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if query}
|
||||
<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}
|
||||
{@const isActive = activeCategory === cat.id}
|
||||
{@const dim = query.length > 0 && !highlightedCategoryIds.has(cat.id)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onclick={() => onSelect(cat.id)}
|
||||
class="chip-btn"
|
||||
class:active={isActive}
|
||||
class:dim
|
||||
>
|
||||
<Icon size={16} />
|
||||
{cat.label}
|
||||
</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>
|
||||
.settings-sidebar {
|
||||
display: flex;
|
||||
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 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
pointer-events: none;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 2.25rem;
|
||||
background: hsl(var(--color-card));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.search-input::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.15);
|
||||
}
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: none;
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.clear-btn:hover {
|
||||
background: hsl(var(--color-muted-foreground) / 0.2);
|
||||
}
|
||||
|
||||
/* ── Search results dropdown ────────────────────────────────────── */
|
||||
.results-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
background: hsl(var(--color-card));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.875rem;
|
||||
box-shadow: 0 4px 14px hsl(0 0% 0% / 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.result-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.5rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.result-btn:hover {
|
||||
background: hsl(var(--color-surface-hover));
|
||||
}
|
||||
.result-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.result-hint {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.no-results {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* ── Mobile chips ───────────────────────────────────────────────── */
|
||||
.chip-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding: 0 1rem 0.5rem;
|
||||
margin: 0 -1rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.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;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: hsl(var(--color-card));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.chip-btn:hover {
|
||||
background: hsl(var(--color-surface-hover));
|
||||
}
|
||||
.chip-btn.active {
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
border-color: hsl(var(--color-primary) / 0.35);
|
||||
color: hsl(var(--color-primary));
|
||||
box-shadow: inset 0 0 0 1px hsl(var(--color-primary) / 0.2);
|
||||
}
|
||||
.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>
|
||||
215
apps/mana/apps/web/src/lib/components/settings/searchIndex.ts
Normal file
215
apps/mana/apps/web/src/lib/components/settings/searchIndex.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/**
|
||||
* settings/searchIndex — single source of truth for the settings sidebar
|
||||
* categories and the in-page search index. Editing a single entry here
|
||||
* updates both the navigation and the search results.
|
||||
*/
|
||||
import type { Component } from 'svelte';
|
||||
import { User, Gear, Robot, ShieldCheck, CurrencyCircleDollar, Cloud } from '@mana/shared-icons';
|
||||
|
||||
export type CategoryId = 'profile' | 'general' | 'ai' | 'security' | 'credits' | 'data';
|
||||
|
||||
export interface Category {
|
||||
id: CategoryId;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: Component;
|
||||
/** Anchor ids in this category — used for hash-based deep-links. */
|
||||
anchors: string[];
|
||||
}
|
||||
|
||||
export const categories: Category[] = [
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Profil',
|
||||
description: 'Persönliche Daten & Konto',
|
||||
icon: User,
|
||||
anchors: ['profile', 'account'],
|
||||
},
|
||||
{
|
||||
id: 'general',
|
||||
label: 'Allgemein',
|
||||
description: 'Theme, Sprache, Benachrichtigungen',
|
||||
icon: Gear,
|
||||
anchors: ['global'],
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
label: 'KI',
|
||||
description: 'Compute-Backend & Modelle',
|
||||
icon: Robot,
|
||||
anchors: ['ai-options'],
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
label: 'Sicherheit',
|
||||
description: 'Passkeys, 2FA & Sitzungen',
|
||||
icon: ShieldCheck,
|
||||
anchors: ['passkeys', 'sessions', 'two-factor', 'security-log'],
|
||||
},
|
||||
{
|
||||
id: 'credits',
|
||||
label: 'Credits',
|
||||
description: 'Guthaben & Transaktionen',
|
||||
icon: CurrencyCircleDollar,
|
||||
anchors: ['credits'],
|
||||
},
|
||||
{
|
||||
id: 'data',
|
||||
label: 'Daten & Sync',
|
||||
description: 'Cloud-Sync, Export & DSGVO',
|
||||
icon: Cloud,
|
||||
anchors: ['cloud-sync', 'my-data'],
|
||||
},
|
||||
];
|
||||
|
||||
export interface SearchEntry {
|
||||
/** Display label shown in the result list */
|
||||
label: string;
|
||||
/** Extra search keywords (the label is always searched too) */
|
||||
keywords?: string[];
|
||||
category: CategoryId;
|
||||
anchor: string;
|
||||
}
|
||||
|
||||
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',
|
||||
keywords: ['dark', 'light', 'farbe', 'design'],
|
||||
category: 'general',
|
||||
anchor: 'global',
|
||||
},
|
||||
{
|
||||
label: 'Sprache',
|
||||
keywords: ['language', 'i18n', 'deutsch', 'english'],
|
||||
category: 'general',
|
||||
anchor: 'global',
|
||||
},
|
||||
{
|
||||
label: 'Benachrichtigungen',
|
||||
keywords: ['notification', 'sound'],
|
||||
category: 'general',
|
||||
anchor: 'global',
|
||||
},
|
||||
|
||||
// AI
|
||||
{
|
||||
label: 'KI-Optionen',
|
||||
keywords: ['llm', 'ai', 'compute'],
|
||||
category: 'ai',
|
||||
anchor: 'ai-options',
|
||||
},
|
||||
{
|
||||
label: 'Browser-Modell (Gemma)',
|
||||
keywords: ['gemma', 'webgpu', 'lokal', 'offline'],
|
||||
category: 'ai',
|
||||
anchor: 'ai-options',
|
||||
},
|
||||
{
|
||||
label: 'Mana-Server (KI)',
|
||||
keywords: ['server', 'self-hosted'],
|
||||
category: 'ai',
|
||||
anchor: 'ai-options',
|
||||
},
|
||||
{
|
||||
label: 'Cloud-KI (Gemini)',
|
||||
keywords: ['google', 'cloud', 'gemini'],
|
||||
category: 'ai',
|
||||
anchor: 'ai-options',
|
||||
},
|
||||
|
||||
// Security
|
||||
{
|
||||
label: 'Passkeys',
|
||||
keywords: ['webauthn', 'fido', 'biometrie'],
|
||||
category: 'security',
|
||||
anchor: 'passkeys',
|
||||
},
|
||||
{
|
||||
label: 'Aktive Sessions',
|
||||
keywords: ['logout', 'gerät', 'device'],
|
||||
category: 'security',
|
||||
anchor: 'sessions',
|
||||
},
|
||||
{
|
||||
label: 'Zwei-Faktor (2FA)',
|
||||
keywords: ['totp', '2fa', 'mfa'],
|
||||
category: 'security',
|
||||
anchor: 'two-factor',
|
||||
},
|
||||
{
|
||||
label: 'Sicherheits-Log',
|
||||
keywords: ['audit', 'history', 'verlauf'],
|
||||
category: 'security',
|
||||
anchor: 'security-log',
|
||||
},
|
||||
|
||||
// Credits
|
||||
{
|
||||
label: 'Credits-Guthaben',
|
||||
keywords: ['balance', 'geld'],
|
||||
category: 'credits',
|
||||
anchor: 'credits',
|
||||
},
|
||||
{
|
||||
label: 'Credits kaufen',
|
||||
keywords: ['buy', 'pakete', 'kaufen'],
|
||||
category: 'credits',
|
||||
anchor: 'credits',
|
||||
},
|
||||
{ label: 'Transaktionen', keywords: ['history'], category: 'credits', anchor: 'credits' },
|
||||
|
||||
// Data
|
||||
{
|
||||
label: 'Cloud Sync',
|
||||
keywords: ['sync', 'backup', 'geräte'],
|
||||
category: 'data',
|
||||
anchor: 'cloud-sync',
|
||||
},
|
||||
{
|
||||
label: 'Daten exportieren',
|
||||
keywords: ['export', 'dsgvo', 'gdpr', 'json'],
|
||||
category: 'data',
|
||||
anchor: 'my-data',
|
||||
},
|
||||
{
|
||||
label: 'Konto löschen',
|
||||
keywords: ['delete', 'gdpr', 'dsgvo'],
|
||||
category: 'data',
|
||||
anchor: 'my-data',
|
||||
},
|
||||
];
|
||||
|
||||
/** Tiny case-insensitive ranker — exact > prefix > contains. */
|
||||
export function searchSettings(query: string, limit = 8): SearchEntry[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const results: { entry: SearchEntry; score: number }[] = [];
|
||||
for (const entry of searchIndex) {
|
||||
const haystacks = [
|
||||
entry.label.toLowerCase(),
|
||||
...(entry.keywords ?? []).map((k) => k.toLowerCase()),
|
||||
];
|
||||
let score = 0;
|
||||
for (const h of haystacks) {
|
||||
if (h === q) score = Math.max(score, 100);
|
||||
else if (h.startsWith(q)) score = Math.max(score, 50);
|
||||
else if (h.includes(q)) score = Math.max(score, 20);
|
||||
}
|
||||
if (score > 0) results.push({ entry, score });
|
||||
}
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
return results.slice(0, limit).map((r) => r.entry);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<script lang="ts">
|
||||
import AiSettings from '../AiSettings.svelte';
|
||||
import SettingsPanel from '../SettingsPanel.svelte';
|
||||
</script>
|
||||
|
||||
<SettingsPanel padded={false}>
|
||||
<AiSettings />
|
||||
</SettingsPanel>
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { CurrencyCircleDollar } from '@mana/shared-icons';
|
||||
import { creditsService } from '$lib/api/credits';
|
||||
import type { CreditBalance } from '$lib/api/credits';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import SettingsPanel from '../SettingsPanel.svelte';
|
||||
import SettingsSectionHeader from '../SettingsSectionHeader.svelte';
|
||||
|
||||
let creditBalance = $state<CreditBalance | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) return;
|
||||
try {
|
||||
creditBalance = await creditsService.getBalance();
|
||||
} catch (e) {
|
||||
console.error('CreditsSection load failed:', e);
|
||||
}
|
||||
});
|
||||
|
||||
function formatCredits(amount: number): string {
|
||||
return amount.toLocaleString('de-DE');
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsPanel id="credits">
|
||||
<SettingsSectionHeader
|
||||
icon={CurrencyCircleDollar}
|
||||
title="Credits"
|
||||
description="Dein Guthaben für Mana Apps"
|
||||
tone="yellow"
|
||||
>
|
||||
{#snippet action()}
|
||||
<a href="/credits" class="text-sm text-primary hover:underline">Alle Details</a>
|
||||
{/snippet}
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-lg bg-surface-hover p-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">Verfügbar</p>
|
||||
<p class="text-2xl font-bold text-primary">
|
||||
{creditBalance ? formatCredits(creditBalance.balance) : '...'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-surface-hover p-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">Gratis heute</p>
|
||||
<p class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{creditBalance
|
||||
? `${creditBalance.freeCreditsRemaining}/${creditBalance.dailyFreeCredits}`
|
||||
: '...'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-surface-hover p-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">Gesamt verbraucht</p>
|
||||
<p class="text-2xl font-bold">
|
||||
{creditBalance ? formatCredits(creditBalance.totalSpent) : '...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<a
|
||||
href="/credits?tab=packages"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Credits kaufen
|
||||
</a>
|
||||
<a
|
||||
href="/credits?tab=transactions"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
Transaktionen
|
||||
</a>
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<script lang="ts">
|
||||
import { Cloud, FileText, CaretRight } from '@mana/shared-icons';
|
||||
import SettingsPanel from '../SettingsPanel.svelte';
|
||||
import SettingsSectionHeader from '../SettingsSectionHeader.svelte';
|
||||
</script>
|
||||
|
||||
<SettingsPanel id="cloud-sync">
|
||||
<SettingsSectionHeader
|
||||
icon={Cloud}
|
||||
title="Cloud Sync"
|
||||
description="Synchronisiere deine Daten über alle Geräte"
|
||||
tone="blue"
|
||||
>
|
||||
{#snippet action()}
|
||||
<a href="/settings/sync" class="text-sm text-primary hover:underline">Einstellungen</a>
|
||||
{/snippet}
|
||||
</SettingsSectionHeader>
|
||||
</SettingsPanel>
|
||||
|
||||
<SettingsPanel id="my-data">
|
||||
<SettingsSectionHeader
|
||||
icon={FileText}
|
||||
title="Meine Daten (DSGVO)"
|
||||
description="Datenschutz und Datenexport"
|
||||
tone="purple"
|
||||
/>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between border-b border-border py-3">
|
||||
<div>
|
||||
<p class="font-medium">Daten ansehen & exportieren</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Sieh alle deine gespeicherten Daten ein und exportiere sie als JSON
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/settings/my-data"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Meine Daten
|
||||
<CaretRight size={16} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/10"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-red-600 dark:text-red-400">Konto löschen</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Das Löschen deines Kontos kann nicht rückgängig gemacht werden.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/settings/my-data"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700"
|
||||
>
|
||||
Verwalten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<script lang="ts">
|
||||
import { GlobalSettingsSection } from '@mana/shared-ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import SettingsPanel from '../SettingsPanel.svelte';
|
||||
|
||||
onMount(() => {
|
||||
void userSettings.load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<SettingsPanel id="global" padded={false}>
|
||||
<GlobalSettingsSection {userSettings} appId="mana" />
|
||||
</SettingsPanel>
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { PasskeyManager, TwoFactorSetup, AuditLog, SessionManager } from '@mana/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import SettingsPanel from '../SettingsPanel.svelte';
|
||||
|
||||
let passkeys = $state<any[]>([]);
|
||||
let sessions = $state<any[]>([]);
|
||||
let sessionsLoading = $state(false);
|
||||
let securityEvents = $state<any[]>([]);
|
||||
let securityEventsLoading = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) return;
|
||||
try {
|
||||
passkeys = await authStore.listPasskeys();
|
||||
sessionsLoading = true;
|
||||
sessions = await authStore.listSessions();
|
||||
sessionsLoading = false;
|
||||
securityEventsLoading = true;
|
||||
securityEvents = await authStore.getSecurityEvents();
|
||||
securityEventsLoading = false;
|
||||
} catch (e) {
|
||||
console.error('SecuritySection load failed:', e);
|
||||
sessionsLoading = false;
|
||||
securityEventsLoading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<SettingsPanel id="passkeys">
|
||||
<PasskeyManager
|
||||
{passkeys}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onRegister={(name) => authStore.registerPasskey(name)}
|
||||
onDelete={(id) => authStore.deletePasskey(id)}
|
||||
onRename={(id, name) => authStore.renamePasskey(id, name)}
|
||||
onRefresh={async () => {
|
||||
passkeys = await authStore.listPasskeys();
|
||||
}}
|
||||
primaryColor="#6366f1"
|
||||
/>
|
||||
</SettingsPanel>
|
||||
|
||||
<SettingsPanel id="sessions">
|
||||
<SessionManager
|
||||
{sessions}
|
||||
loading={sessionsLoading}
|
||||
onRevoke={(id) => authStore.revokeSession(id)}
|
||||
onRefresh={async () => {
|
||||
sessionsLoading = true;
|
||||
sessions = await authStore.listSessions();
|
||||
sessionsLoading = false;
|
||||
}}
|
||||
primaryColor="#6366f1"
|
||||
/>
|
||||
</SettingsPanel>
|
||||
|
||||
<SettingsPanel id="two-factor">
|
||||
<TwoFactorSetup
|
||||
enabled={!!authStore.user?.twoFactorEnabled}
|
||||
onEnable={(password) => authStore.enableTwoFactor(password)}
|
||||
onDisable={(password) => authStore.disableTwoFactor(password)}
|
||||
onGenerateBackupCodes={(password) => authStore.generateBackupCodes(password)}
|
||||
primaryColor="#6366f1"
|
||||
/>
|
||||
</SettingsPanel>
|
||||
|
||||
<SettingsPanel id="security-log">
|
||||
<AuditLog
|
||||
events={securityEvents}
|
||||
loading={securityEventsLoading}
|
||||
onRefresh={async () => {
|
||||
securityEventsLoading = true;
|
||||
securityEvents = await authStore.getSecurityEvents();
|
||||
securityEventsLoading = false;
|
||||
}}
|
||||
primaryColor="#6366f1"
|
||||
/>
|
||||
</SettingsPanel>
|
||||
|
|
@ -184,10 +184,10 @@
|
|||
updateLlmSettings({ allowedTiers: next });
|
||||
}
|
||||
|
||||
const TIER_TOGGLE_LIST: Array<{ tier: LlmTier; shortLabel: string }> = [
|
||||
{ tier: 'browser', shortLabel: 'Browser (Gemma 4)' },
|
||||
{ tier: 'mana-server', shortLabel: 'Server (Gemma 4)' },
|
||||
{ tier: 'cloud', shortLabel: 'Cloud (Gemini)' },
|
||||
const TIER_TOGGLE_LIST: Array<{ tier: LlmTier; shortLabel: string; icon: string }> = [
|
||||
{ tier: 'browser', shortLabel: 'Browser (Gemma 4)', icon: 'cpu' },
|
||||
{ tier: 'mana-server', shortLabel: 'Server (Gemma 4)', icon: 'server' },
|
||||
{ tier: 'cloud', shortLabel: 'Cloud (Gemini)', icon: 'cloud' },
|
||||
];
|
||||
|
||||
let aiTierItems = $derived<PillDropdownItem[]>([
|
||||
|
|
@ -195,6 +195,7 @@
|
|||
...TIER_TOGGLE_LIST.filter((t) => t.tier !== 'browser' || webgpuSupported).map((t) => ({
|
||||
id: `ai-tier-${t.tier}`,
|
||||
label: t.shortLabel,
|
||||
icon: t.icon,
|
||||
active: llmSettings.allowedTiers.includes(t.tier),
|
||||
onClick: () => toggleAiTier(t.tier),
|
||||
})),
|
||||
|
|
@ -209,6 +210,7 @@
|
|||
: localLlmStatus.current.state === 'downloading'
|
||||
? `Lade… ${((localLlmStatus.current as { progress: number }).progress * 100).toFixed(0)}%`
|
||||
: 'Modell laden (~500 MB)',
|
||||
icon: localLlmStatus.current.state === 'ready' ? 'check' : 'download',
|
||||
disabled: localLlmStatus.current.state === 'ready',
|
||||
onClick:
|
||||
localLlmStatus.current.state !== 'ready' ? () => void loadLocalLlm() : undefined,
|
||||
|
|
@ -221,7 +223,7 @@
|
|||
id: 'ai-settings',
|
||||
label: 'KI-Einstellungen',
|
||||
icon: 'settings',
|
||||
onClick: () => goto('/settings'),
|
||||
onClick: () => goto('/settings#ai-options'),
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
@ -238,6 +240,18 @@
|
|||
return first ? first.shortLabel.split(' (')[0] : 'KI';
|
||||
});
|
||||
|
||||
let currentAiTierIcon = $derived.by(() => {
|
||||
const active = llmSettings.allowedTiers;
|
||||
if (active.length === 0) return 'power';
|
||||
const sorted = [...active].sort(
|
||||
(a, b) =>
|
||||
TIER_TOGGLE_LIST.findIndex((t) => t.tier === a) -
|
||||
TIER_TOGGLE_LIST.findIndex((t) => t.tier === b)
|
||||
);
|
||||
const first = TIER_TOGGLE_LIST.find((t) => t.tier === sorted[0]);
|
||||
return first ? first.icon : 'cpu';
|
||||
});
|
||||
|
||||
// ── Sync status dropdown ────────────────────────────────
|
||||
let syncStatusItems = $derived.by(() => {
|
||||
const items: import('@mana/shared-ui').PillDropdownItem[] = [];
|
||||
|
|
@ -816,6 +830,7 @@
|
|||
showAiTierSelector={true}
|
||||
{aiTierItems}
|
||||
{currentAiTierLabel}
|
||||
{currentAiTierIcon}
|
||||
showSyncStatus={authStore.isAuthenticated}
|
||||
{syncStatusItems}
|
||||
{currentSyncLabel}
|
||||
|
|
|
|||
|
|
@ -1,433 +1,78 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import { Button, Input, Card, PageHeader, GlobalSettingsSection } from '@mana/shared-ui';
|
||||
import { PasskeyManager, TwoFactorSetup, AuditLog, SessionManager } from '@mana/shared-auth-ui';
|
||||
import AiSettings from '$lib/components/settings/AiSettings.svelte';
|
||||
import {
|
||||
User,
|
||||
CurrencyCircleDollar,
|
||||
ShieldCheck,
|
||||
FileText,
|
||||
CaretRight,
|
||||
} from '@mana/shared-icons';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { creditsService } from '$lib/api/credits';
|
||||
import type { CreditBalance } from '$lib/api/credits';
|
||||
import { profileService } from '$lib/api/profile';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { PageHeader } from '@mana/shared-ui';
|
||||
import { APP_VERSION } from '$lib/version';
|
||||
import { ManaEvents } from '@mana/shared-utils/analytics';
|
||||
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 loading = $state(true);
|
||||
let passkeys = $state<any[]>([]);
|
||||
let savingProfile = $state(false);
|
||||
let profileSuccess = $state(false);
|
||||
let profileError = $state<string | null>(null);
|
||||
let activeCategory = $state<CategoryId>('profile');
|
||||
let mounted = $state(false);
|
||||
|
||||
// Form state
|
||||
let firstName = $state('');
|
||||
let lastName = $state('');
|
||||
|
||||
// Credits data
|
||||
let creditBalance = $state<CreditBalance | null>(null);
|
||||
|
||||
// Security events
|
||||
let securityEvents = $state<any[]>([]);
|
||||
let securityEventsLoading = $state(false);
|
||||
|
||||
// Sessions
|
||||
let sessions = $state<any[]>([]);
|
||||
let sessionsLoading = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
try {
|
||||
creditBalance = await creditsService.getBalance();
|
||||
passkeys = await authStore.listPasskeys();
|
||||
// Load user settings from server
|
||||
await userSettings.load();
|
||||
// Load security events
|
||||
securityEventsLoading = true;
|
||||
securityEvents = await authStore.getSecurityEvents();
|
||||
securityEventsLoading = false;
|
||||
// Load sessions
|
||||
sessionsLoading = true;
|
||||
sessions = await authStore.listSessions();
|
||||
sessionsLoading = false;
|
||||
} catch (e) {
|
||||
console.error('Failed to load data:', e);
|
||||
}
|
||||
}
|
||||
loading = false;
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
function formatCredits(amount: number): string {
|
||||
return amount.toLocaleString('de-DE');
|
||||
}
|
||||
// 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' });
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
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>
|
||||
<div class="mx-auto w-full max-w-4xl px-4 sm:px-6">
|
||||
<PageHeader
|
||||
title={$_('common.settings')}
|
||||
description="Verwalte deine Kontoeinstellungen und Präferenzen"
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
></div>
|
||||
<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>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Profile Section -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary"
|
||||
>
|
||||
<User size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Profil</h2>
|
||||
<p class="text-sm text-muted-foreground">Deine persönlichen Informationen</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Global Settings Section (synced across all apps) -->
|
||||
<GlobalSettingsSection {userSettings} appId="mana" />
|
||||
|
||||
<!-- Cloud Sync Section -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
<span class="text-lg">☁️</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Cloud Sync</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Synchronisiere deine Daten über alle Geräte
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/settings/sync" class="text-sm text-primary hover:underline">
|
||||
Einstellungen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Credits Section -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400"
|
||||
>
|
||||
<CurrencyCircleDollar size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Credits</h2>
|
||||
<p class="text-sm text-muted-foreground">Dein Guthaben für Mana Apps</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/credits" class="text-sm text-primary hover:underline">Alle Details</a>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-lg bg-surface-hover p-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">Verfügbar</p>
|
||||
<p class="text-2xl font-bold text-primary">
|
||||
{creditBalance ? formatCredits(creditBalance.balance) : '...'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-surface-hover p-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">Gratis heute</p>
|
||||
<p class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{creditBalance
|
||||
? `${creditBalance.freeCreditsRemaining}/${creditBalance.dailyFreeCredits}`
|
||||
: '...'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-surface-hover p-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">Gesamt verbraucht</p>
|
||||
<p class="text-2xl font-bold">
|
||||
{creditBalance ? formatCredits(creditBalance.totalSpent) : '...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<a
|
||||
href="/credits?tab=packages"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Credits kaufen
|
||||
</a>
|
||||
<a
|
||||
href="/credits?tab=transactions"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
Transaktionen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Account Section -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
<ShieldCheck size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Konto</h2>
|
||||
<p class="text-sm text-muted-foreground">Konto- und Sicherheitsinformationen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between py-3 border-b border-border">
|
||||
<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 py-3 border-b border-border">
|
||||
<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 text-xs font-mono">
|
||||
{authStore.user?.id?.slice(0, 8) || '...'}...
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Passkeys Section -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<PasskeyManager
|
||||
{passkeys}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onRegister={(name) => authStore.registerPasskey(name)}
|
||||
onDelete={(id) => authStore.deletePasskey(id)}
|
||||
onRename={(id, name) => authStore.renamePasskey(id, name)}
|
||||
onRefresh={async () => {
|
||||
passkeys = await authStore.listPasskeys();
|
||||
}}
|
||||
primaryColor="#6366f1"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Sessions Section -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<SessionManager
|
||||
{sessions}
|
||||
loading={sessionsLoading}
|
||||
onRevoke={(id) => authStore.revokeSession(id)}
|
||||
onRefresh={async () => {
|
||||
sessionsLoading = true;
|
||||
sessions = await authStore.listSessions();
|
||||
sessionsLoading = false;
|
||||
}}
|
||||
primaryColor="#6366f1"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Two-Factor Authentication Section -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<TwoFactorSetup
|
||||
enabled={!!authStore.user?.twoFactorEnabled}
|
||||
onEnable={(password) => authStore.enableTwoFactor(password)}
|
||||
onDisable={(password) => authStore.disableTwoFactor(password)}
|
||||
onGenerateBackupCodes={(password) => authStore.generateBackupCodes(password)}
|
||||
primaryColor="#6366f1"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Security Log Section -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<AuditLog
|
||||
events={securityEvents}
|
||||
loading={securityEventsLoading}
|
||||
onRefresh={async () => {
|
||||
securityEventsLoading = true;
|
||||
securityEvents = await authStore.getSecurityEvents();
|
||||
securityEventsLoading = false;
|
||||
}}
|
||||
primaryColor="#6366f1"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- AI Tier Settings -->
|
||||
<Card>
|
||||
<AiSettings />
|
||||
</Card>
|
||||
|
||||
<!-- My Data & Danger Zone -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-purple-100 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400"
|
||||
>
|
||||
<FileText size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Meine Daten (DSGVO)</h2>
|
||||
<p class="text-sm text-muted-foreground">Datenschutz und Datenexport</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between py-3 border-b border-border">
|
||||
<div>
|
||||
<p class="font-medium">Daten ansehen & exportieren</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Sieh alle deine gespeicherten Daten ein und exportiere sie als JSON
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/settings/my-data"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Meine Daten
|
||||
<CaretRight size={16} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/10 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-red-600 dark:text-red-400">Konto loschen</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Das Loschen deines Kontos kann nicht ruckgangig gemacht werden.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/settings/my-data"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Verwalten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
|
||||
{/if}
|
||||
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -128,6 +128,14 @@
|
|||
help: 'M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-7v2h2v-2h-2zm2-1.645A3.502 3.502 0 0012 6.5 3.501 3.501 0 008.645 9h2.012A1.5 1.5 0 0112 8.5c.828 0 1.5.672 1.5 1.5 0 .828-.672 1.5-1.5 1.5a1 1 0 00-1 1V14h2v-.645z',
|
||||
// Mana icon (water drop)
|
||||
mana: 'M12.3 1c.03.05 7.3 9.67 7.3 13.7 0 4.03-3.27 7.3-7.3 7.3S5 18.73 5 14.7C5 10.66 12.3 1 12.3 1zm0 6.4c-.02.03-3.65 4.83-3.65 6.84 0 2.02 1.64 3.65 3.65 3.65s3.65-1.64 3.65-3.65c0-2.01-3.62-6.81-3.65-6.84z',
|
||||
// Compute / AI tier icons
|
||||
cpu: 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z',
|
||||
server:
|
||||
'M5 12V7a2 2 0 012-2h10a2 2 0 012 2v5M5 12h14M5 12v5a2 2 0 002 2h10a2 2 0 002-2v-5M9 8h.01M9 16h.01',
|
||||
cloud:
|
||||
'M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z',
|
||||
power: 'M12 3v9m6.364-6.364a9 9 0 11-12.728 0',
|
||||
download: 'M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3',
|
||||
};
|
||||
|
||||
function getIcon(iconName: string) {
|
||||
|
|
|
|||
|
|
@ -247,6 +247,8 @@
|
|||
aiTierItems?: PillDropdownItem[];
|
||||
/** Current AI tier label, e.g. "Browser" or "Server" */
|
||||
currentAiTierLabel?: string;
|
||||
/** Current AI tier icon name (passed to the dropdown trigger) */
|
||||
currentAiTierIcon?: string;
|
||||
/** Show sync status dropdown */
|
||||
showSyncStatus?: boolean;
|
||||
/** Sync status dropdown items */
|
||||
|
|
@ -348,6 +350,7 @@
|
|||
showAiTierSelector = false,
|
||||
aiTierItems = [],
|
||||
currentAiTierLabel = 'KI',
|
||||
currentAiTierIcon = 'cpu',
|
||||
showSyncStatus = false,
|
||||
syncStatusItems = [],
|
||||
currentSyncLabel = 'Sync',
|
||||
|
|
@ -675,7 +678,7 @@
|
|||
items={aiTierItems}
|
||||
direction={dropdownDirection}
|
||||
label={currentAiTierLabel}
|
||||
icon="cpu"
|
||||
icon={currentAiTierIcon}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue