style(settings): rework "Daten & Sync" tab to match other sections

Sync and MyData panels now follow the GeneralSection/SecuritySection
pattern — scoped CSS with theme tokens, Phosphor icons, .rows/.row
layout, action-snippet headers. MyData splits into seven focused
SettingsPanels (Profil, Auth, Credits, Projektdaten, Aufbewahrung,
Backup, Gefahrenzone). Projektdaten renders as an edge-to-edge compact
table that pulls app icons from the workbench app-registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-18 18:51:19 +02:00
parent 8dd3dbc9e5
commit cce296a527
4 changed files with 1196 additions and 567 deletions

View file

@ -42,9 +42,18 @@ export const categories: Category[] = [
{
id: 'data',
label: 'Daten & Sync',
description: 'Cloud-Sync, Export & DSGVO',
description: 'Cloud-Sync, Export, Backup & DSGVO',
icon: Cloud,
anchors: ['cloud-sync', 'my-data'],
anchors: [
'cloud-sync',
'my-data',
'auth-data',
'credits-data',
'project-data',
'retention',
'backup',
'danger-zone',
],
},
];
@ -139,7 +148,7 @@ export const searchIndex: SearchEntry[] = [
// Data
{
label: 'Cloud Sync',
keywords: ['sync', 'backup', 'geräte'],
keywords: ['sync', 'geräte'],
category: 'data',
anchor: 'cloud-sync',
},
@ -150,10 +159,40 @@ export const searchIndex: SearchEntry[] = [
anchor: 'my-data',
},
{
label: 'Konto löschen',
keywords: ['delete', 'gdpr', 'dsgvo'],
label: 'Authentifizierung',
keywords: ['sessions', '2fa', 'login'],
category: 'data',
anchor: 'my-data',
anchor: 'auth-data',
},
{
label: 'Credits & Transaktionen',
keywords: ['guthaben', 'transaktionen'],
category: 'data',
anchor: 'credits-data',
},
{
label: 'Projektdaten',
keywords: ['projekte', 'apps', 'statistik'],
category: 'data',
anchor: 'project-data',
},
{
label: 'Aufbewahrungsfristen',
keywords: ['retention', 'dsgvo', 'fristen'],
category: 'data',
anchor: 'retention',
},
{
label: 'Backup & Wiederherstellung',
keywords: ['backup', 'restore', 'import', 'archiv', '.mana'],
category: 'data',
anchor: 'backup',
},
{
label: 'Konto löschen',
keywords: ['delete', 'gdpr', 'dsgvo', 'gefahrenzone'],
category: 'data',
anchor: 'danger-zone',
},
];

View file

@ -1,27 +1,7 @@
<script lang="ts">
import { Cloud, FileText } from '@mana/shared-icons';
import SettingsPanel from '../SettingsPanel.svelte';
import SettingsSectionHeader from '../SettingsSectionHeader.svelte';
import SyncSection from './SyncSection.svelte';
import MyDataSection from './MyDataSection.svelte';
</script>
<SettingsPanel id="cloud-sync">
<SettingsSectionHeader
icon={Cloud}
title="Cloud Sync"
description="Synchronisiere deine Daten über alle Geräte"
tone="blue"
/>
<SyncSection />
</SettingsPanel>
<SettingsPanel id="my-data">
<SettingsSectionHeader
icon={FileText}
title="Meine Daten (DSGVO)"
description="Datenschutz und Datenexport"
tone="purple"
/>
<MyDataSection />
</SettingsPanel>
<SyncSection />
<MyDataSection />

View file

@ -1,10 +1,12 @@
<script lang="ts">
import { Card } from '@mana/shared-ui';
import { onMount } from 'svelte';
import { CloudCheck, Pause, Cloud } from '@mana/shared-icons';
import { syncBilling } from '$lib/stores/sync-billing.svelte';
import { creditsService, type CreditBalance } from '$lib/api/credits';
import type { BillingInterval } from '$lib/api/sync';
import { toast } from '$lib/stores/toast.svelte';
import { onMount } from 'svelte';
import SettingsPanel from '../SettingsPanel.svelte';
import SettingsSectionHeader from '../SettingsSectionHeader.svelte';
const SYNC_PRICES: Record<BillingInterval, number> = {
monthly: 30,
@ -18,11 +20,22 @@
yearly: 'Jährlich',
};
const INTERVAL_HINTS: Record<BillingInterval, string> = {
monthly: 'jeden Monat abgerechnet · ~1 Credit/Tag',
quarterly: 'alle 3 Monate abgerechnet',
yearly: 'einmal jährlich abgerechnet',
};
let balance = $state<CreditBalance | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let selectedInterval = $state<BillingInterval>('monthly');
const insufficientCredits = $derived(
balance !== null && balance.balance < SYNC_PRICES[selectedInterval]
);
const intervalChanged = $derived(syncBilling.active && selectedInterval !== syncBilling.interval);
onMount(async () => {
await Promise.all([syncBilling.load(), loadBalance()]);
selectedInterval = syncBilling.interval;
@ -94,165 +107,334 @@
}
</script>
<div>
<SettingsPanel id="cloud-sync">
<SettingsSectionHeader
icon={Cloud}
title="Cloud Sync"
description="Synchronisiere deine Daten über alle Geräte"
tone="blue"
/>
{#if syncBilling.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="spinner-wrap">
<div class="spinner"></div>
</div>
{:else}
<div class="grid gap-6 lg:grid-cols-2">
<!-- Status Card -->
<Card>
<div class="p-6">
<h2 class="text-lg font-semibold mb-4">Status</h2>
<div class="flex items-center gap-3 mb-6">
<div
class="flex h-12 w-12 items-center justify-center rounded-full {syncBilling.active
? 'bg-green-100 dark:bg-green-900/20'
: syncBilling.paused
? 'bg-amber-100 dark:bg-amber-900/20'
: 'bg-surface'}"
>
<span class="text-2xl"
>{syncBilling.active ? '🔄' : syncBilling.paused ? '⏸️' : '☁️'}</span
>
</div>
<div>
<p class="text-xl font-bold">
{#if syncBilling.active}
Aktiv
{:else if syncBilling.paused}
Pausiert
{:else}
Inaktiv
{/if}
</p>
{#if syncBilling.active && syncBilling.nextChargeAt}
<p class="text-sm text-muted-foreground">
Nächste Abbuchung: {formatDate(syncBilling.nextChargeAt)}
</p>
{:else if syncBilling.paused}
<p class="text-sm text-amber-600 dark:text-amber-400">
Credits reichen nicht aus — lade Credits auf um fortzufahren
</p>
{:else}
<p class="text-sm text-muted-foreground">
Deine Daten sind nur lokal auf diesem Gerät gespeichert
</p>
{/if}
</div>
</div>
{#if balance}
<div class="rounded-lg bg-surface p-4 mb-6">
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Verfügbare Credits</span>
<span class="text-lg font-bold text-primary">{formatCredits(balance.balance)}</span>
</div>
</div>
{/if}
{#if error}
<div class="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4">
<p class="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
{/if}
{#if syncBilling.active}
<button
onclick={handleDeactivate}
disabled={loading}
class="w-full rounded-lg border border-red-300 dark:border-red-700 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors disabled:opacity-50"
>
{loading ? 'Wird deaktiviert...' : 'Cloud Sync deaktivieren'}
</button>
{:else}
<button
onclick={handleActivate}
disabled={loading ||
(balance !== null && balance.balance < SYNC_PRICES[selectedInterval])}
class="w-full rounded-lg bg-primary py-3 font-semibold text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{#if loading}
Wird aktiviert...
{:else}
Cloud Sync aktivieren ({SYNC_PRICES[selectedInterval]} Credits)
{/if}
</button>
{#if balance !== null && balance.balance < SYNC_PRICES[selectedInterval]}
<p class="mt-2 text-center text-sm text-amber-600 dark:text-amber-400">
Nicht genügend Credits.
<a href="/?app=credits&tab=packages" class="underline hover:no-underline"
>Aufladen</a
>
</p>
<div class="rows">
<div class="row">
<div class="row-info">
<p class="row-title">Status</p>
<p class="row-desc">
{#if syncBilling.active && syncBilling.nextChargeAt}
Nächste Abbuchung am {formatDate(syncBilling.nextChargeAt)}
{:else if syncBilling.active}
Synchronisiert verschlüsselt über alle Geräte
{:else if syncBilling.paused}
Credits reichen nicht aus — lade Credits auf um fortzufahren
{:else}
Deine Daten sind nur lokal auf diesem Gerät gespeichert
{/if}
{/if}
</p>
</div>
</Card>
<span class="badge" class:active={syncBilling.active} class:paused={syncBilling.paused}>
{#if syncBilling.active}
<CloudCheck size={14} weight="fill" />
Aktiv
{:else if syncBilling.paused}
<Pause size={14} weight="fill" />
Pausiert
{:else}
<Cloud size={14} />
Inaktiv
{/if}
</span>
</div>
<!-- Interval Selection Card -->
<Card>
<div class="p-6">
<h2 class="text-lg font-semibold mb-4">Abrechnungsintervall</h2>
<div class="space-y-3">
{#each ['monthly', 'quarterly', 'yearly'] as const as iv}
<label
class="flex items-center justify-between rounded-lg border p-4 cursor-pointer transition-colors {selectedInterval ===
iv
? 'border-primary bg-primary/5'
: 'border-border hover:bg-surface'}"
>
<div class="flex items-center gap-3">
<input
type="radio"
name="interval"
value={iv}
checked={selectedInterval === iv}
onchange={() => (selectedInterval = iv)}
class="h-4 w-4 text-primary"
/>
<div>
<p class="font-medium">{INTERVAL_LABELS[iv]}</p>
<p class="text-sm text-muted-foreground">
{iv === 'monthly'
? '~1 Credit/Tag'
: iv === 'quarterly'
? '3 Monate'
: '12 Monate'}
</p>
</div>
</div>
<span class="text-lg font-bold text-primary">{SYNC_PRICES[iv]}</span>
</label>
{/each}
{#if balance}
<div class="row">
<div class="row-info">
<p class="row-title">Verfügbare Credits</p>
<p class="row-desc">
<a class="inline-link" href="/?app=credits&tab=packages">Credits aufladen</a>
</p>
</div>
<span class="value">{formatCredits(balance.balance)}</span>
</div>
{/if}
{#if syncBilling.active && selectedInterval !== syncBilling.interval}
<div class="row">
<div class="row-info">
<p class="row-title">Abrechnungsintervall</p>
<p class="row-desc">
{SYNC_PRICES[selectedInterval]} Credits · {INTERVAL_HINTS[selectedInterval]}
</p>
</div>
<div class="btn-group">
{#each ['monthly', 'quarterly', 'yearly'] as const as iv}
<button
onclick={handleChangeInterval}
disabled={loading}
class="mt-4 w-full rounded-lg bg-surface py-2 font-medium text-foreground hover:bg-surface-hover border border-border transition-colors disabled:opacity-50"
class="choice-btn"
class:active={selectedInterval === iv}
onclick={() => (selectedInterval = iv)}
>
{loading ? 'Wird geändert...' : `Auf ${INTERVAL_LABELS[selectedInterval]} wechseln`}
{iv === 'monthly' ? 'Monat' : iv === 'quarterly' ? 'Quartal' : 'Jahr'}
</button>
<p class="mt-2 text-center text-xs text-muted-foreground">
Änderung gilt ab der nächsten Abbuchung
</p>
{/if}
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-950/30 p-4">
<p class="text-sm text-blue-800 dark:text-blue-200">
Cloud Sync synchronisiert deine Daten verschlüsselt über alle Geräte. Deine lokalen
Daten bleiben immer erhalten — auch wenn Sync pausiert oder deaktiviert wird.
</p>
</div>
{/each}
</div>
</Card>
</div>
</div>
{#if error}
<p class="error-text">{error}</p>
{/if}
<div class="actions">
{#if syncBilling.active}
{#if intervalChanged}
<button class="btn-secondary" onclick={handleChangeInterval} disabled={loading}>
{loading ? 'Wird geändert…' : `Auf ${INTERVAL_LABELS[selectedInterval]} wechseln`}
</button>
<p class="muted-hint">Änderung gilt ab der nächsten Abbuchung</p>
{/if}
<button class="btn-danger" onclick={handleDeactivate} disabled={loading}>
{loading ? 'Wird deaktiviert…' : 'Cloud Sync deaktivieren'}
</button>
{:else}
<button
class="btn-primary"
onclick={handleActivate}
disabled={loading || insufficientCredits}
>
{#if loading}
Wird aktiviert…
{:else}
Aktivieren · {SYNC_PRICES[selectedInterval]} Credits
{/if}
</button>
{#if insufficientCredits}
<p class="warn-hint">
Nicht genügend Credits. <a href="/?app=credits&tab=packages">Aufladen</a>
</p>
{/if}
{/if}
</div>
<p class="footnote">
Cloud Sync synchronisiert deine Daten verschlüsselt über alle Geräte. Lokale Daten bleiben
immer erhalten — auch wenn Sync pausiert oder deaktiviert wird.
</p>
{/if}
</div>
</SettingsPanel>
<style>
.rows {
display: flex;
flex-direction: column;
}
.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;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.inline-link {
color: hsl(var(--color-primary));
text-decoration: none;
}
.inline-link:hover {
text-decoration: underline;
}
.btn-group {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.choice-btn {
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
font-weight: 500;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: all 0.15s;
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.choice-btn:hover {
opacity: 0.8;
}
.choice-btn.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 9999px;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
flex-shrink: 0;
}
.badge.active {
background: hsl(142 76% 36% / 0.12);
color: hsl(142 71% 32%);
}
.badge.paused {
background: hsl(38 92% 50% / 0.15);
color: hsl(32 80% 38%);
}
.value {
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--color-foreground));
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1.25rem;
}
.btn-primary,
.btn-secondary,
.btn-danger {
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 600;
border-radius: 0.5rem;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-secondary {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.btn-secondary:hover:not(:disabled) {
background: hsl(var(--color-surface-hover));
}
.btn-danger {
background: transparent;
border-color: hsl(0 84% 60% / 0.4);
color: hsl(0 72% 50%);
}
.btn-danger:hover:not(:disabled) {
background: hsl(0 84% 60% / 0.08);
}
.btn-primary:disabled,
.btn-secondary:disabled,
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-text {
margin: 0.75rem 0 0;
font-size: 0.8125rem;
color: hsl(0 72% 50%);
}
.warn-hint {
margin: 0;
font-size: 0.75rem;
color: hsl(32 80% 38%);
text-align: center;
}
.warn-hint a {
color: inherit;
text-decoration: underline;
}
.muted-hint {
margin: -0.25rem 0 0;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
}
.footnote {
margin: 1.25rem 0 0;
padding-top: 1rem;
border-top: 1px solid hsl(var(--color-border));
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.5;
}
.spinner-wrap {
display: flex;
justify-content: center;
padding: 2.5rem 0;
}
.spinner {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 3px solid hsl(var(--color-primary));
border-top-color: transparent;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>