refactor(settings): unify section styling, remove Credits tab

- AiSettings: Tailwind → scoped CSS with theme tokens; tier cards as
  buttons with subtle primary-tinted active state; Tier 0 in matching
  card; pill toggles for behavior settings; improved body text
  readability
- VaultSection: theme-token colors, SettingsSectionHeader with status
  badge action, unified button system, flat subsection structure with
  border dividers, collapsible encrypted-fields list (5 by default),
  threat-model disclosure moved above destructive actions, wizard step
  numbering; dropped stale dark-mode @media block
- Settings ListView: overflow-x: hidden so chip-row scroll stays scoped;
  hashchange + workbench:navigate-anchor effect for live deep-links
- Remove Credits tab (stub redirecting to /?app=credits) — full Credits
  module + dashboard widget already cover balance/purchases/transactions
This commit is contained in:
Till JS 2026-04-17 15:46:51 +02:00
parent 7d120225dc
commit 677123091a
5 changed files with 1055 additions and 736 deletions

View file

@ -3,13 +3,9 @@
* AiSettings — Settings-page section for the tiered LLM orchestrator.
*
* Lets the user pick which @mana/shared-llm tiers Mana is allowed to
* use. Each tier is a checkbox with an explanation of what it means
* for privacy + speed + cost. The cloud tier is gated behind an
* additional consent checkbox the first time it's enabled.
*
* Drops into the main settings page (/settings) as a self-contained
* Card section, mirroring the existing PasskeyManager / SessionManager
* pattern.
* use. Each tier is a selectable row with an explanation of what it
* means for privacy + speed + cost. The cloud tier is gated behind
* an additional consent step the first time it's enabled.
*/
import { llmSettingsState, updateLlmSettings, tierLabel, type LlmTier } from '@mana/shared-llm';
import {
@ -30,7 +26,7 @@
let loadingBrowser = $state(false);
function toggleTier(tier: LlmTier) {
if (tier === 'none') return; // 'none' is implicit, not a real toggle
if (tier === 'none') return;
const current = settings.allowedTiers;
const next = current.includes(tier) ? current.filter((t) => t !== tier) : [...current, tier];
updateLlmSettings({ allowedTiers: next });
@ -57,8 +53,6 @@
updateLlmSettings({ showSourceInUi: value });
}
// Tier card metadata — defined inline because it's specific to this UI
// and changes alongside layout updates.
type TierCard = {
tier: LlmTier;
icon: typeof Robot;
@ -99,7 +93,7 @@
bullets: [
'Direkt aus dem Browser — keine Mana-Server-Zwischenstation',
'Du zahlst beim Provider, wir sehen nichts davon',
'Schluessel werden verschluesselt in deinem Vault gespeichert',
'Schlüssel werden verschlüsselt in deinem Vault gespeichert',
],
},
{
@ -123,24 +117,28 @@
const browserCacheReady = $derived(webgpuSupported && localLlmStatus.current.state === 'ready');
</script>
<div>
<!-- Tier 0 explainer -->
<div class="mb-4 rounded-xl border border-border bg-muted/20 p-4">
<div class="flex items-start gap-3">
<CheckCircle size={20} class="mt-0.5 shrink-0 text-emerald-500" weight="fill" />
<div>
<p class="font-medium">Lokal (ohne KI) — immer aktiv</p>
<p class="mt-1 text-sm text-muted-foreground">
Mana funktioniert auch ganz ohne KI: Datum-Erkennung, Suche und einfache Klassifikation
laufen über klassische Algorithmen. Manche Funktionen sind dann begrenzt, dafür ist alles
100% offline und kostet nichts.
</p>
<div class="ai-settings">
<!-- Tier 0 — always on -->
<div class="tier-row tier0 enabled" aria-disabled="true">
<div class="tier-icon">
<CheckCircle size={20} weight="fill" />
</div>
<div class="tier-body">
<div class="tier-title-line">
<span class="tier-title">Lokal (ohne KI)</span>
<span class="tier-badge tier-badge-always">immer aktiv</span>
</div>
<p class="tier-subtitle">Basis-Funktionen ohne jede KI</p>
<p class="tier0-desc">
Mana funktioniert auch ganz ohne KI: Datum-Erkennung, Suche und einfache Klassifikation
laufen über klassische Algorithmen. Manche Funktionen sind dann begrenzt, dafür ist alles
100% offline und kostet nichts.
</p>
</div>
</div>
<!-- Tier 1-3 toggleable cards -->
<div class="space-y-3">
<!-- Tier 13 selectable rows -->
<div class="tier-list">
{#each tierCards as card}
{@const enabled = isEnabled(card.tier)}
{@const tierBlocked = card.tier === 'browser' && !webgpuSupported}
@ -148,189 +146,518 @@
type="button"
onclick={() => !tierBlocked && toggleTier(card.tier)}
disabled={tierBlocked}
class="w-full rounded-xl border p-4 text-left transition-all {enabled
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
: 'border-border bg-card hover:border-primary/40'} {tierBlocked
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer'}"
class="tier-row"
class:enabled
class:blocked={tierBlocked}
>
<div class="flex items-start gap-3">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg {enabled
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'}"
>
<card.icon size={20} />
<div class="tier-icon">
<card.icon size={20} />
</div>
<div class="tier-body">
<div class="tier-title-line">
<span class="tier-title">{card.title}</span>
{#if enabled}
<span class="tier-badge">aktiv</span>
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<div class="text-base font-semibold">{card.title}</div>
{#if enabled}
<span
class="rounded-full bg-primary px-2 py-0.5 text-[10px] font-medium text-primary-foreground"
>aktiv</span
<p class="tier-subtitle">{card.subtitle}</p>
<ul class="tier-bullets">
{#each card.bullets as bullet}
<li>{bullet}</li>
{/each}
</ul>
{#if card.warning}
<p class="tier-warning">
<Warning size={12} weight="fill" />
<span>{card.warning}</span>
</p>
{/if}
{#if card.tier === 'browser' && enabled && webgpuSupported}
<div class="tier-extra">
{#if browserCacheReady}
<span class="status-ok">✓ Modell geladen</span>
{:else if localLlmStatus.current.state === 'downloading'}
<span class="status-muted">
Lade {defaultModelInfo.displayName} ({(
localLlmStatus.current.progress * 100
).toFixed(0)}%)…
</span>
{:else}
<!-- svelte-ignore node_invalid_placement_ssr -->
<button
type="button"
onclick={(e) => {
e.stopPropagation();
loadBrowserModel();
}}
disabled={loadingBrowser}
class="action-btn"
>
{loadingBrowser
? 'Lade…'
: `Modell laden (~${defaultModelInfo.downloadSizeMb} MB)`}
</button>
{/if}
</div>
<p class="mt-0.5 text-sm text-muted-foreground">{card.subtitle}</p>
<ul class="mt-2 space-y-1 text-xs text-muted-foreground">
{#each card.bullets as bullet}
<li class="flex items-start gap-1.5">
<span class="mt-0.5 text-primary"></span>
<span>{bullet}</span>
</li>
{/each}
</ul>
{#if card.warning}
<div
class="mt-2 flex items-start gap-1.5 rounded-md bg-amber-500/10 p-2 text-xs text-amber-700 dark:text-amber-400"
>
<Warning size={12} weight="fill" class="mt-0.5 shrink-0" />
<span>{card.warning}</span>
</div>
{/if}
{/if}
<!-- Per-tier extras -->
{#if card.tier === 'browser' && enabled && webgpuSupported}
<div class="mt-3 flex items-center gap-2 text-xs">
{#if browserCacheReady}
<span class="text-emerald-500">✓ Modell geladen</span>
{:else if localLlmStatus.current.state === 'downloading'}
<span class="text-muted-foreground">
Lade {defaultModelInfo.displayName} ({(
localLlmStatus.current.progress * 100
).toFixed(0)}%)…
</span>
{:else}
<!-- svelte-ignore node_invalid_placement_ssr -->
<button
type="button"
onclick={(e) => {
e.stopPropagation();
loadBrowserModel();
}}
disabled={loadingBrowser}
class="rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50"
>
{loadingBrowser
? 'Lade…'
: `Modell laden (~${defaultModelInfo.downloadSizeMb} MB)`}
</button>
{/if}
</div>
{/if}
{#if card.tier === 'browser' && tierBlocked}
<p class="tier-blocked-note">
WebGPU nicht verfügbar in deinem Browser. Funktioniert in Chrome/Edge 113+ oder Safari
18+.
</p>
{/if}
{#if card.tier === 'browser' && tierBlocked}
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
WebGPU nicht verfügbar in deinem Browser. Funktioniert in Chrome/Edge 113+ oder
Safari 18+.
{#if card.tier === 'byok' && enabled}
<div
class="tier-extra"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="presentation"
>
<ByokKeysManager />
</div>
{/if}
{#if card.tier === 'cloud' && enabled && !settings.cloudConsentGiven}
<div
class="tier-consent"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="presentation"
>
<p class="consent-title">Bestätigung erforderlich</p>
<p class="consent-desc">
Cloud-Anfragen senden deine Inhalte an Google. Bitte bestätige, dass du das
verstanden hast und akzeptierst.
</p>
{/if}
{#if card.tier === 'byok' && enabled}
<div
class="mt-3"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="presentation"
<!-- svelte-ignore node_invalid_placement_ssr -->
<button
type="button"
onclick={(e) => {
e.stopPropagation();
setCloudConsent(true);
}}
class="consent-btn"
>
<ByokKeysManager />
</div>
{/if}
Verstanden, Cloud aktivieren
</button>
</div>
{/if}
{#if card.tier === 'cloud' && enabled && !settings.cloudConsentGiven}
<div
class="mt-3 rounded-md border border-amber-500/30 bg-amber-500/5 p-3"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="presentation"
{#if card.tier === 'cloud' && enabled && settings.cloudConsentGiven}
<div
class="tier-extra consent-ok"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="presentation"
>
<span class="status-ok">✓ Cloud-Zustimmung erteilt</span>
<!-- svelte-ignore node_invalid_placement_ssr -->
<button
type="button"
onclick={(e) => {
e.stopPropagation();
setCloudConsent(false);
}}
class="link-btn"
>
<p class="text-xs font-medium text-amber-700 dark:text-amber-400">
Bestätigung erforderlich
</p>
<p class="mt-1 text-xs text-muted-foreground">
Cloud-Anfragen senden deine Inhalte an Google. Bitte bestätige, dass du das
verstanden hast und akzeptierst.
</p>
<!-- svelte-ignore node_invalid_placement_ssr -->
<button
type="button"
onclick={(e) => {
e.stopPropagation();
setCloudConsent(true);
}}
class="mt-2 rounded-md bg-amber-600 px-3 py-1 text-xs font-medium text-white hover:bg-amber-700"
>
Verstanden, Cloud aktivieren
</button>
</div>
{/if}
{#if card.tier === 'cloud' && enabled && settings.cloudConsentGiven}
<div class="mt-3 flex items-center justify-between gap-2 text-xs">
<span class="text-emerald-500">✓ Cloud-Zustimmung erteilt</span>
<!-- svelte-ignore node_invalid_placement_ssr -->
<button
type="button"
onclick={(e) => {
e.stopPropagation();
setCloudConsent(false);
}}
class="text-muted-foreground hover:text-foreground"
>
Zurücknehmen
</button>
</div>
{/if}
</div>
Zurücknehmen
</button>
</div>
{/if}
</div>
</button>
{/each}
</div>
<!-- Active tier chain summary -->
<div class="mt-4 rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<span class="font-medium text-foreground">Aktuelle Reihenfolge: </span>
{#if settings.allowedTiers.length === 0}
Nur lokal (ohne KI) — die meisten KI-Funktionen sind begrenzt.
{:else}
{settings.allowedTiers.map((t) => tierLabel(t)).join(' → ')} → Lokal (Fallback)
{/if}
<div class="summary-row">
<span class="summary-label">Aktuelle Reihenfolge:</span>
<span class="summary-value">
{#if settings.allowedTiers.length === 0}
Nur lokal (ohne KI) — die meisten KI-Funktionen sind begrenzt.
{:else}
{settings.allowedTiers.map((t) => tierLabel(t)).join(' → ')} → Lokal (Fallback)
{/if}
</span>
</div>
<!-- Behavior toggles -->
<div class="mt-6 space-y-3 border-t border-border pt-4">
<label class="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
checked={settings.fallbackToRulesOnError}
onchange={(e) => setFallback((e.currentTarget as HTMLInputElement).checked)}
class="mt-0.5 h-4 w-4 rounded border-border"
/>
<div>
<div class="text-sm font-medium">Bei Fehler auf "Lokal" zurückfallen</div>
<div class="text-xs text-muted-foreground">
<div class="rows">
<div class="row">
<div class="row-info">
<p class="row-title">Bei Fehler auf „Lokal" zurückfallen</p>
<p class="row-desc">
Wenn die gewählte KI-Schicht eine Anfrage nicht beantworten kann, versucht Mana es mit der
lokalen Variante (sofern verfügbar). Aus: zeigt stattdessen einen Fehler an.
</div>
</p>
</div>
</label>
<button
type="button"
class="toggle"
class:on={settings.fallbackToRulesOnError}
onclick={() => setFallback(!settings.fallbackToRulesOnError)}
aria-label="Fallback auf Lokal"
>
<span class="toggle-knob"></span>
</button>
</div>
<label class="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
checked={settings.showSourceInUi}
onchange={(e) => setShowSource((e.currentTarget as HTMLInputElement).checked)}
class="mt-0.5 h-4 w-4 rounded border-border"
/>
<div>
<div class="text-sm font-medium">Quelle bei jedem KI-Resultat anzeigen</div>
<div class="text-xs text-muted-foreground">
Zeigt unter jeder KI-generierten Antwort eine kleine Markierung wie "Auf deinem Gerät"
oder "via Google Gemini" — damit du immer siehst, wo deine Daten gerade verarbeitet
<div class="row">
<div class="row-info">
<p class="row-title">Quelle bei jedem KI-Resultat anzeigen</p>
<p class="row-desc">
Zeigt unter jeder KI-generierten Antwort eine kleine Markierung wie „Auf deinem Gerät"
oder „via Google Gemini" — damit du immer siehst, wo deine Daten gerade verarbeitet
wurden.
</div>
</p>
</div>
</label>
<button
type="button"
class="toggle"
class:on={settings.showSourceInUi}
onclick={() => setShowSource(!settings.showSourceInUi)}
aria-label="Quelle anzeigen"
>
<span class="toggle-knob"></span>
</button>
</div>
</div>
</div>
<style>
.ai-settings {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Tier 0 — always-active (same card shape, non-interactive) ───── */
.tier-row.tier0 {
cursor: default;
border-color: hsl(142 71% 45% / 0.35);
background: hsl(142 71% 45% / 0.05);
box-shadow: inset 0 0 0 1px hsl(142 71% 45% / 0.1);
}
.tier-row.tier0:hover {
border-color: hsl(142 71% 45% / 0.35);
background: hsl(142 71% 45% / 0.05);
}
.tier-row.tier0 .tier-icon {
background: hsl(142 71% 45% / 0.15);
color: hsl(142 71% 45%);
}
.tier-row.tier0 .tier-badge-always {
background: hsl(142 71% 45%);
color: white;
}
.tier0-desc {
margin: 0.5rem 0 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.6;
}
/* ── Tier selection list ────────────────────────────────────────── */
.tier-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tier-row {
display: flex;
align-items: flex-start;
gap: 0.875rem;
width: 100%;
padding: 0.875rem 1rem;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 0.875rem;
text-align: left;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s,
box-shadow 0.15s;
}
.tier-row:not(.blocked):hover {
border-color: hsl(var(--color-border-strong, var(--color-border)));
background: hsl(var(--color-surface-hover, var(--color-muted)) / 0.4);
}
.tier-row.enabled {
border-color: hsl(var(--color-primary) / 0.4);
background: hsl(var(--color-primary) / 0.04);
box-shadow: inset 0 0 0 1px hsl(var(--color-primary) / 0.15);
}
.tier-row.blocked {
opacity: 0.5;
cursor: not-allowed;
}
.tier-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.625rem;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
transition:
background 0.15s,
color 0.15s;
}
.tier-row.enabled .tier-icon {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
}
.tier-body {
min-width: 0;
flex: 1;
}
.tier-title-line {
display: flex;
align-items: center;
gap: 0.5rem;
}
.tier-title {
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.tier-badge {
padding: 0.0625rem 0.4375rem;
font-size: 0.625rem;
font-weight: 500;
border-radius: 9999px;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.tier-subtitle {
margin: 0.1875rem 0 0;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.5;
}
.tier-bullets {
margin: 0.625rem 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.3125rem;
}
.tier-bullets li {
position: relative;
padding-left: 0.9375rem;
font-size: 0.8125rem;
color: hsl(var(--color-foreground) / 0.78);
line-height: 1.55;
}
.tier-bullets li::before {
content: '•';
position: absolute;
left: 0;
color: hsl(var(--color-primary));
}
.tier-warning {
display: flex;
align-items: flex-start;
gap: 0.375rem;
margin: 0.625rem 0 0;
font-size: 0.8125rem;
line-height: 1.5;
color: hsl(35 90% 45%);
}
.tier-warning :global(svg) {
flex-shrink: 0;
margin-top: 0.125rem;
}
.tier-extra {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.625rem;
font-size: 0.75rem;
}
.tier-extra.consent-ok {
justify-content: space-between;
}
.status-ok {
color: hsl(142 71% 45%);
}
.status-muted {
color: hsl(var(--color-muted-foreground));
}
.tier-blocked-note {
margin: 0.5rem 0 0;
font-size: 0.75rem;
color: hsl(35 90% 45%);
}
.action-btn {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border: none;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
cursor: pointer;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.link-btn {
padding: 0;
border: none;
background: transparent;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
}
.link-btn:hover {
color: hsl(var(--color-foreground));
}
/* ── Cloud consent inline block ─────────────────────────────────── */
.tier-consent {
margin-top: 0.625rem;
padding: 0.625rem 0.75rem;
border-left: 3px solid hsl(35 90% 55%);
background: hsl(35 90% 55% / 0.06);
border-radius: 0 0.375rem 0.375rem 0;
}
.consent-title {
margin: 0;
font-size: 0.75rem;
font-weight: 500;
color: hsl(35 90% 40%);
}
.consent-desc {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.55;
}
.consent-btn {
margin-top: 0.5rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border: none;
border-radius: 0.5rem;
background: hsl(35 90% 50%);
color: white;
cursor: pointer;
}
.consent-btn:hover {
background: hsl(35 90% 45%);
}
/* ── Summary row ────────────────────────────────────────────────── */
.summary-row {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
font-size: 0.8125rem;
line-height: 1.5;
color: hsl(var(--color-muted-foreground));
}
.summary-label {
font-weight: 500;
color: hsl(var(--color-foreground));
}
.summary-value {
min-width: 0;
}
/* ── Behavior toggle rows (matches GeneralSection) ──────────────── */
.rows {
display: flex;
flex-direction: column;
border-top: 1px solid hsl(var(--color-border));
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid hsl(var(--color-border));
}
.row:last-child {
border-bottom: none;
}
.row-info {
min-width: 0;
flex: 1;
}
.row-title {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.row-desc {
margin: 0.1875rem 0 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.55;
}
.toggle {
position: relative;
width: 2.75rem;
height: 1.5rem;
border-radius: 9999px;
border: none;
cursor: pointer;
transition: background 0.2s;
background: hsl(var(--color-muted));
flex-shrink: 0;
}
.toggle.on {
background: hsl(var(--color-primary));
}
.toggle-knob {
position: absolute;
top: 0.125rem;
left: 0.125rem;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: white;
transition: transform 0.2s;
box-shadow: 0 1px 3px hsl(0 0% 0% / 0.15);
}
.toggle.on .toggle-knob {
transform: translateX(1.25rem);
}
</style>

View file

@ -4,9 +4,9 @@
* updates both the navigation and the search results.
*/
import type { Component } from 'svelte';
import { Gear, Robot, ShieldCheck, CurrencyCircleDollar, Cloud } from '@mana/shared-icons';
import { Gear, Robot, ShieldCheck, Cloud } from '@mana/shared-icons';
export type CategoryId = 'general' | 'ai' | 'security' | 'credits' | 'data';
export type CategoryId = 'general' | 'ai' | 'security' | 'data';
export interface Category {
id: CategoryId;
@ -39,13 +39,6 @@ export const categories: Category[] = [
icon: ShieldCheck,
anchors: ['passkeys', 'sessions', 'two-factor', 'vault', 'security-log'],
},
{
id: 'credits',
label: 'Credits',
description: 'Guthaben & Transaktionen',
icon: CurrencyCircleDollar,
anchors: ['credits'],
},
{
id: 'data',
label: 'Daten & Sync',
@ -143,21 +136,6 @@ export const searchIndex: SearchEntry[] = [
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',

View file

@ -1,75 +0,0 @@
<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="/?app=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="/?app=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="/?app=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

@ -15,7 +15,6 @@
import GeneralSection from '$lib/components/settings/sections/GeneralSection.svelte';
import AiSection from '$lib/components/settings/sections/AiSection.svelte';
import SecuritySection from '$lib/components/settings/sections/SecuritySection.svelte';
import CreditsSection from '$lib/components/settings/sections/CreditsSection.svelte';
import DataSection from '$lib/components/settings/sections/DataSection.svelte';
let activeCategory = $state<CategoryId>('general');
@ -76,8 +75,6 @@
<AiSection />
{:else if activeCategory === 'security'}
<SecuritySection />
{:else if activeCategory === 'credits'}
<CreditsSection />
{:else if activeCategory === 'data'}
<DataSection />
{/if}