feat(auth,settings,tasks): add first-boot setup, real login/invites, full-page settings & admin views, multi-channel alarms, and fix null slice crashes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s

This commit is contained in:
sandrodach committed 2026-10-24 22:59:48 +02:00
1 parent 50ac0c8f64
commit 7802eb2cf0
17 files changed
+4391 -1368

No files matched your search

@@ -0,0 +1,726 @@
import React, { useEffect, useState } from 'react';
import {
User as UserIcon,
Bell,
Key,
Plus,
Trash2,
Terminal,
Download,
ArrowLeft,
Check,
Mail,
MessageSquare,
Globe,
Send,
AlertCircle,
} from 'lucide-react';
import { APIKey, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
interface UserSettingsViewProps {
currentUser: User;
onUserUpdated: (u: User) => void;
onBack: () => void;
}
type UserSettingsSection = 'profile' | 'alarms' | 'webhooks';
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
currentUser,
onUserUpdated,
onBack,
}) => {
const [section, setSection] = useState<UserSettingsSection>('profile');
// --- Profile State ---
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
const [email, setEmail] = useState(currentUser.email || '');
const [newPassword, setNewPassword] = useState('');
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingProfile, setSavingProfile] = useState(false);
// --- Multi-Channel Alarms State ---
const [alarmChannels, setAlarmChannels] = useState<string[]>(
Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []
);
const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || '');
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || '');
const [customWebhookUrl, setCustomWebhookUrl] = useState(currentUser.customWebhookUrl || '');
const [notifyBeforeMinutes, setNotifyBeforeMinutes] = useState<number>(
currentUser.notifyBeforeMinutes ?? 30
);
const [alarmMsg, setAlarmMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingAlarms, setSavingAlarms] = useState(false);
const [testingChannel, setTestingChannel] = useState<string | null>(null);
// --- Webhooks & API Keys State ---
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedLabel, setCopiedLabel] = useState<string | null>(null);
useEffect(() => {
setDisplayName(currentUser.displayName || '');
setEmail(currentUser.email || '');
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
setCustomWebhookUrl(currentUser.customWebhookUrl || '');
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
}, [currentUser]);
useEffect(() => {
loadKeys();
}, []);
const loadKeys = async () => {
try {
const keys = await api.getAPIKeys();
setApiKeys(keys);
} catch {
// ignore
}
};
const toggleAlarmChannel = (ch: string) => {
setAlarmChannels((prev) =>
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
);
};
// --- Handlers: Profile ---
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
setProfileMsg(null);
setSavingProfile(true);
try {
const patch: any = {
displayName: displayName.trim(),
email: email.trim(),
};
if (newPassword.trim()) {
patch.password = newPassword.trim();
}
const updated = await api.updateSettings(patch);
onUserUpdated(updated);
setNewPassword('');
setProfileMsg({ type: 'ok', text: 'Profil und Anmeldedaten erfolgreich gespeichert.' });
} catch (err: any) {
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
} finally {
setSavingProfile(false);
}
};
// --- Handlers: Alarms ---
const handleSaveAlarms = async (e: React.FormEvent) => {
e.preventDefault();
setAlarmMsg(null);
setSavingAlarms(true);
try {
const updated = await api.updateSettings({
alarmChannels,
alarmEmail: alarmEmail.trim(),
discordWebhookUrl: discordWebhookUrl.trim(),
customWebhookUrl: customWebhookUrl.trim(),
notifyBeforeMinutes: Number(notifyBeforeMinutes),
});
onUserUpdated(updated);
setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' });
} catch (err: any) {
setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' });
} finally {
setSavingAlarms(false);
}
};
const handleTestChannel = async (channel: 'email' | 'discord' | 'webhook') => {
setAlarmMsg(null);
let target = '';
if (channel === 'email') {
target = (alarmEmail || currentUser.email || '').trim();
if (!target) {
setAlarmMsg({ type: 'err', text: 'Bitte gib zuerst eine Empfänger-E-Mail-Adresse an.' });
return;
}
} else if (channel === 'discord') {
target = discordWebhookUrl.trim();
if (!target) {
setAlarmMsg({ type: 'err', text: 'Bitte gib zuerst eine Discord Webhook URL ein.' });
return;
}
} else if (channel === 'webhook') {
target = customWebhookUrl.trim();
if (!target) {
setAlarmMsg({ type: 'err', text: 'Bitte gib zuerst eine eigene Webhook URL ein.' });
return;
}
}
setTestingChannel(channel);
try {
await api.testAlarmChannel(channel, target);
const label =
channel === 'email' ? 'E-Mail' : channel === 'discord' ? 'Discord' : 'Eigener Webhook';
setAlarmMsg({
type: 'ok',
text: `Test-Alarm über "${label}" wurde erfolgreich vom Server versendet!`,
});
} catch (err: any) {
setAlarmMsg({
type: 'err',
text: `Test fehlgeschlagen: ${err.message || 'Unbekannter Fehler'}`,
});
} finally {
setTestingChannel(null);
}
};
// --- Handlers: Webhooks & Export ---
const handleCreateAPIKey = async (e: React.FormEvent) => {
e.preventDefault();
triggerHaptic(15);
try {
const created = await api.createAPIKey(newKeyName.trim() || 'Eigenes Skript / Webhook');
if (created.rawKey) {
setNewlyCreatedKey(created.rawKey);
}
setNewKeyName('');
loadKeys();
} catch (err: any) {
alert(err.message);
}
};
const handleDeleteAPIKey = async (id: string) => {
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
await api.deleteAPIKey(id);
loadKeys();
};
const handleCopy = (text: string, label: string) => {
navigator.clipboard.writeText(text);
setCopiedLabel(label);
setTimeout(() => setCopiedLabel(null), 2000);
};
const handleExportData = async () => {
const data = await api.exportAll();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
const baseUrl = window.location.origin;
const activeKeyPlaceholder = newlyCreatedKey || apiKeys[0]?.keyPrefix + '••••••••' || 'DEIN_API_KEY';
const curlTaskExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
-H "X-API-Key: ${activeKeyPlaceholder}" \\
-H "Content-Type: application/json" \\
-d '{"content": "Server-Alarm prüfen #monitoring", "priority": "urgent", "target": "task"}'`;
const roleBadgeLabel =
currentUser.role === 'admin'
? 'Server-Admin'
: currentUser.role === 'team_admin'
? 'Team-Admin'
: 'Mitglied';
return (
<div className="w-full space-y-4">
{/* Top Page Header */}
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
title="Zurück zur Übersicht"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Zurück</span>
</button>
<div>
<h1 className="text-base font-bold text-white tracking-tight">Kontoeinstellungen</h1>
<p className="text-xs text-zinc-400">
Persönliches Profil, Aufgaben-Alarme, Private Webhooks & Daten-Backup
</p>
</div>
</div>
<span className="px-2.5 py-1 bg-panel border border-border text-xs text-zinc-300 font-medium">
Rolle: <strong className="text-accent">{roleBadgeLabel}</strong>
</span>
</div>
{/* Main 2-Column Layout */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-start">
{/* Left Navigation Sidebar */}
<aside className="bg-panel border border-border divide-y divide-border">
<button
onClick={() => setSection('profile')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'profile'
? 'bg-panel-elevated text-white border-l-2 border-l-accent'
: 'text-zinc-400 hover:text-white hover:bg-white/[0.02]'
}`}
>
<UserIcon className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Profil & Sicherheit</p>
<p className="text-[10px] text-zinc-500">Name, E-Mail & Passwort</p>
</div>
</button>
<button
onClick={() => setSection('alarms')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'alarms'
? 'bg-panel-elevated text-white border-l-2 border-l-accent'
: 'text-zinc-400 hover:text-white hover:bg-white/[0.02]'
}`}
>
<Bell className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Alarme</p>
<p className="text-[10px] text-zinc-500">E-Mail, Discord & Webhooks</p>
</div>
</button>
<button
onClick={() => setSection('webhooks')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'webhooks'
? 'bg-panel-elevated text-white border-l-2 border-l-accent'
: 'text-zinc-400 hover:text-white hover:bg-white/[0.02]'
}`}
>
<Key className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Private Webhooks & Export</p>
<p className="text-[10px] text-zinc-500">API-Keys, Curl & JSON-Backup</p>
</div>
</button>
</aside>
{/* Right Content Area */}
<div className="md:col-span-3 bg-panel border border-border p-5 text-xs">
{/* ================================================================= */}
{/* SECTION 1: PROFIL & SICHERHEIT */}
{/* ================================================================= */}
{section === 'profile' && (
<div className="space-y-5 max-w-2xl">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-white">Profil & Anmeldedaten</h2>
<p className="text-zinc-400 mt-0.5">
Verwalte deinen Anzeigenamen, deine E-Mail-Adresse und dein persönliches Passwort.
</p>
</div>
{profileMsg && (
<div
className={`p-3 border flex items-center gap-2 ${
profileMsg.type === 'ok'
? 'bg-blue-950/30 border-accent text-blue-200'
: 'bg-red-950/30 border-red-500 text-red-200'
}`}
>
{profileMsg.type === 'ok' ? (
<Check className="w-4 h-4 text-accent flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
)}
<span>{profileMsg.text}</span>
</div>
)}
<form onSubmit={handleSaveProfile} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-zinc-300 font-medium mb-1">Anzeigename</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
className="w-full h-9 px-3 input-field text-white"
/>
</div>
<div>
<label className="block text-zinc-300 font-medium mb-1">E-Mail-Adresse (Login)</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full h-9 px-3 input-field text-white"
/>
</div>
</div>
<div>
<label className="block text-zinc-300 font-medium mb-1">
Neues Passwort (optional — leer lassen für keine Änderung)
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
className="w-full h-9 px-3 input-field text-white"
/>
</div>
<div className="pt-2 border-t border-border flex justify-end">
<button
type="submit"
disabled={savingProfile}
className="btn-primary h-9 px-5 text-xs font-semibold"
>
{savingProfile ? 'Speichere…' : 'Profil speichern'}
</button>
</div>
</form>
</div>
)}
{/* ================================================================= */}
{/* SECTION 2: ALARME (MULTI-CHANNEL: EMAIL, DISCORD, CUSTOM WEBHOOK) */}
{/* ================================================================= */}
{section === 'alarms' && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-white">Aufgaben-Alarme & Benachrichtigungsziele</h2>
<p className="text-zinc-400 mt-0.5">
Wähle aus, über welche Kanäle du benachrichtigt werden möchtest, sobald eine Aufgabe
mit Fälligkeitsdatum ansteht. Mehrere Ziele können gleichzeitig aktiv sein.
</p>
</div>
{alarmMsg && (
<div
className={`p-3 border flex items-center gap-2 ${
alarmMsg.type === 'ok'
? 'bg-blue-950/30 border-accent text-blue-200'
: 'bg-red-950/30 border-red-500 text-red-200'
}`}
>
{alarmMsg.type === 'ok' ? (
<Check className="w-4 h-4 text-accent flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
)}
<span>{alarmMsg.text}</span>
</div>
)}
<form onSubmit={handleSaveAlarms} className="space-y-4">
{/* Lead Time Selector */}
<div className="p-3.5 bg-canvas border border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<label className="font-semibold text-white block">
Vorwarnzeit vor Fälligkeit
</label>
<p className="text-[11px] text-zinc-400">
Wann soll MindSift den Alarm vor dem Fälligkeitszeitpunkt auslösen?
</p>
</div>
<select
value={notifyBeforeMinutes}
onChange={(e) => setNotifyBeforeMinutes(Number(e.target.value))}
className="h-9 px-3 input-field text-white bg-panel min-w-[200px]"
>
<option value={0}>Pünktlich bei Fälligkeit (0 Min.)</option>
<option value={15}>15 Minuten vorher</option>
<option value={30}>30 Minuten vorher</option>
<option value={60}>1 Stunde vorher</option>
<option value={120}>2 Stunden vorher</option>
<option value={1440}>1 Tag vorher (24 Std.)</option>
</select>
</div>
{/* Channel 1: E-Mail */}
<div
className={`p-4 border transition-colors space-y-3 ${
alarmChannels.includes('email')
? 'bg-canvas border-accent'
: 'bg-canvas/60 border-border'
}`}
>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={alarmChannels.includes('email')}
onChange={() => toggleAlarmChannel('email')}
className="w-4 h-4 accent-blue-600"
/>
<Mail className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-white block">Ziel: E-Mail</span>
<span className="text-[11px] text-zinc-400">
Sendet eine E-Mail über den konfigurierten SMTP-Server der Instanz.
</span>
</div>
</label>
<button
type="button"
onClick={() => handleTestChannel('email')}
disabled={testingChannel === 'email'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'email' ? 'Sende…' : 'E-Mail testen'}</span>
</button>
</div>
<div className="pl-6">
<label className="block text-zinc-400 mb-1">
Abweichende Empfänger-E-Mail (optional, Standard: {currentUser.email})
</label>
<input
type="email"
value={alarmEmail}
onChange={(e) => setAlarmEmail(e.target.value)}
placeholder={currentUser.email}
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
</div>
{/* Channel 2: Discord Webhook */}
<div
className={`p-4 border transition-colors space-y-3 ${
alarmChannels.includes('discord')
? 'bg-canvas border-accent'
: 'bg-canvas/60 border-border'
}`}
>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={alarmChannels.includes('discord')}
onChange={() => toggleAlarmChannel('discord')}
className="w-4 h-4 accent-blue-600"
/>
<MessageSquare className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-white block">Ziel: Discord Webhook</span>
<span className="text-[11px] text-zinc-400">
Sendet eine formatierte Embed-Benachrichtigung direkt in deinen Discord-Kanal.
</span>
</div>
</label>
<button
type="button"
onClick={() => handleTestChannel('discord')}
disabled={testingChannel === 'discord'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'discord' ? 'Sende…' : 'Discord testen'}</span>
</button>
</div>
<div className="pl-6">
<label className="block text-zinc-400 mb-1">Discord Webhook URL</label>
<input
type="url"
value={discordWebhookUrl}
onChange={(e) => setDiscordWebhookUrl(e.target.value)}
placeholder="https://discord.com/api/webhooks/123456789/token..."
className="w-full h-8 px-2.5 input-field text-white font-mono"
/>
</div>
</div>
{/* Channel 3: Custom HTTP Webhook */}
<div
className={`p-4 border transition-colors space-y-3 ${
alarmChannels.includes('webhook')
? 'bg-canvas border-accent'
: 'bg-canvas/60 border-border'
}`}
>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={alarmChannels.includes('webhook')}
onChange={() => toggleAlarmChannel('webhook')}
className="w-4 h-4 accent-blue-600"
/>
<Globe className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-white block">
Ziel: Eigener Webhook (HTTP POST JSON)
</span>
<span className="text-[11px] text-zinc-400">
Für eigene Empfänger-Skripte, Ntfy, Gotify, Slack, Mattermost oder Automatisierungen.
</span>
</div>
</label>
<button
type="button"
onClick={() => handleTestChannel('webhook')}
disabled={testingChannel === 'webhook'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'webhook' ? 'Sende…' : 'Webhook testen'}</span>
</button>
</div>
<div className="pl-6">
<label className="block text-zinc-400 mb-1">Ziel-URL (empfängt JSON-Payload per POST)</label>
<input
type="url"
value={customWebhookUrl}
onChange={(e) => setCustomWebhookUrl(e.target.value)}
placeholder="https://dein-server.local/webhook/mindsift-alarm"
className="w-full h-8 px-2.5 input-field text-white font-mono"
/>
</div>
</div>
<div className="pt-2 border-t border-border flex justify-end">
<button
type="submit"
disabled={savingAlarms}
className="btn-primary h-9 px-5 text-xs font-semibold"
>
{savingAlarms ? 'Speichere…' : 'Alarm-Einstellungen speichern'}
</button>
</div>
</form>
</div>
)}
{/* ================================================================= */}
{/* SECTION 3: PRIVATE WEBHOOKS & EXPORT */}
{/* ================================================================= */}
{section === 'webhooks' && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-white">Private Webhooks (Eingehend) & Daten-Export</h2>
<p className="text-zinc-400 mt-0.5">
Erstelle persönliche API-Schlüssel, um aus externen Skripten, Server-Monitoring oder
Discord-Bots automatisch Aufgaben und Gedanken in dein MindSift einzuspeisen.
</p>
</div>
{/* Create API Key */}
<form onSubmit={handleCreateAPIKey} className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…"
className="flex-1 h-9 px-3 input-field text-white"
/>
<button
type="submit"
className="btn-primary h-9 px-4 text-xs font-semibold flex items-center justify-center gap-1.5"
>
<Plus className="w-3.5 h-3.5" />
<span>Neuen API-Key erzeugen</span>
</button>
</form>
{newlyCreatedKey && (
<div className="p-3 bg-accent/15 border border-accent flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-[11px] text-accent font-bold">
Neuer API-Schlüssel erstellt (bitte jetzt kopieren, wird nur einmal im Klartext angezeigt):
</p>
<code className="text-xs font-mono text-white break-all">{newlyCreatedKey}</code>
</div>
<button
onClick={() => handleCopy(newlyCreatedKey, 'key')}
className="btn-primary h-8 px-3 text-xs flex-shrink-0"
>
{copiedLabel === 'key' ? 'Kopiert!' : 'Key kopieren'}
</button>
</div>
)}
{/* Existing Keys List */}
<div className="border border-border divide-y divide-border bg-canvas">
{apiKeys.length === 0 ? (
<div className="p-4 text-center text-zinc-500">
Noch keine persönlichen API-Schlüssel vorhanden.
</div>
) : (
apiKeys.map((k) => (
<div key={k.id} className="p-3 flex items-center justify-between gap-3">
<div>
<p className="font-semibold text-white">{k.name}</p>
<p className="text-[11px] text-zinc-500 font-mono">
Präfix: {k.keyPrefix}•••••••• · Erstellt: {new Date(k.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={() => handleDeleteAPIKey(k.id)}
className="btn-icon btn-icon-danger"
title="Schlüssel widerrufen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
{/* Curl Example */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-semibold text-zinc-300 flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>Curl-Beispiel: Direkt als Aufgabe einspeisen</span>
</span>
<button
onClick={() => handleCopy(curlTaskExample, 'curl')}
className="btn-action h-7 px-2.5 text-[11px]"
>
{copiedLabel === 'curl' ? 'Kopiert!' : 'Befehl kopieren'}
</button>
</div>
<pre className="p-3 bg-canvas border border-border text-[11px] font-mono text-zinc-300 overflow-x-auto whitespace-pre">
{curlTaskExample}
</pre>
</div>
{/* Full Data Backup */}
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h3 className="font-bold text-white">Vollständiger Daten-Export (JSON Backup)</h3>
<p className="text-zinc-400 text-[11px]">
Lädt alle deine Gedanken, Aufgaben, Markdown-Dokumente und Listen als portable JSON-Datei herunter.
</p>
</div>
<button
onClick={handleExportData}
className="btn-action h-9 px-4 text-xs flex items-center gap-2 flex-shrink-0"
>
<Download className="w-3.5 h-3.5 text-accent" />
<span>Backup herunterladen</span>
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
};