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:
Till JS 2026-04-11 16:40:57 +02:00
parent 3a93c56fe5
commit 21360d9c18
15 changed files with 1144 additions and 420 deletions

View file

@ -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"

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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);
}

View file

@ -0,0 +1,8 @@
<script lang="ts">
import AiSettings from '../AiSettings.svelte';
import SettingsPanel from '../SettingsPanel.svelte';
</script>
<SettingsPanel padded={false}>
<AiSettings />
</SettingsPanel>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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}

View file

@ -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>

View file

@ -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) {

View file

@ -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}