Files
MindSift/frontend/src/features/settings/UserSettingsView.tsx
T
sandrodachandClaude Opus 5.5 5b636b66e2
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 2m8s
fix(push): reinstalled app replaces its old subscription; devices listed and removable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:59:40 +01:00

1993 lines
93 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useState, useRef } from 'react';
import {
User as UserIcon,
Bell,
Key,
Trash2,
Download,
ArrowLeft,
Check,
Mail,
MessageSquare,
Globe,
Send,
X,
AlertCircle,
Palette,
Upload,
Pencil,
Copy,
} from 'lucide-react';
import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, User, UserTheme } from '../../lib/types';
import { ApiKeysPanel } from './ApiKeysPanel';
import { ServerBackupsCard } from './ServerBackupsCard';
import { saveBlob } from '../../lib/download';
import { api, clearToken, triggerHaptic } from '../../lib/api';
import { passwordProblem } from '../../lib/password';
import {
AVAILABLE_THEMES,
CornerStyle,
customThemeId,
getCornerStyle,
setCornerStyle,
getFollowSystem,
setFollowSystem,
resolveTheme,
getButtonBorders,
setButtonBorders,
ButtonBorders,
getAccentColor,
setAccentColor,
THEME_FAMILIES,
ThemeId,
ThemeOption,
} from '../../lib/theme';
import themeTemplateCss from '../../themes/theme-template.css?raw';
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
import { LegalLinks } from '../../components/LegalLinks';
import { AboutModal } from '../../components/AboutModal';
import { ImportCard } from './ImportCard';
import { SessionsPanel } from './SessionsPanel';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { isJournalEnabled, setJournalEnabled } from '../../lib/journal';
import { currentPushSubscription, disablePush, enablePush, pushSupported, removePushDevice } from '../../lib/push';
import { FormSheet } from '../../components/FormSheet';
interface UserSettingsViewProps {
currentUser: User;
activeTheme: ThemeId;
themes: ThemeOption[]; // built-in + custom
customThemes: UserTheme[];
onThemeChange: (themeId: ThemeId) => void;
onCustomThemesChange: (themes: UserTheme[], activate?: ThemeId) => void;
onUserUpdated: (u: User) => void;
onBack: () => void;
}
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks';
export function buildNotificationSettings(user: User): NotificationSettings {
const hasLegacy = Array.isArray(user.alarmChannels);
const dueEmailDefault = hasLegacy ? user.alarmChannels!.includes('email') : true;
const dueDiscordDefault = hasLegacy ? user.alarmChannels!.includes('discord') : true;
const dueWebhookDefault = hasLegacy ? user.alarmChannels!.includes('webhook') : false;
const s = user.notificationSettings;
return {
paused: Boolean(s?.paused),
includeOwnWatched: Boolean(s?.includeOwnWatched),
events: {
due: {
email: s?.events?.due?.email ?? dueEmailDefault,
discord: s?.events?.due?.discord ?? dueDiscordDefault,
webhook: s?.events?.due?.webhook ?? dueWebhookDefault,
push: s?.events?.due?.push ?? false,
},
shared: {
email: s?.events?.shared?.email ?? true,
discord: s?.events?.shared?.discord ?? false,
webhook: s?.events?.shared?.webhook ?? false,
push: s?.events?.shared?.push ?? false,
},
taskDone: {
email: s?.events?.taskDone?.email ?? false,
discord: s?.events?.taskDone?.discord ?? false,
webhook: s?.events?.taskDone?.webhook ?? false,
push: s?.events?.taskDone?.push ?? false,
},
watched: {
email: s?.events?.watched?.email ?? true,
discord: s?.events?.watched?.discord ?? true,
webhook: s?.events?.watched?.webhook ?? false,
push: s?.events?.watched?.push ?? false,
},
},
dailySummary: {
enabled: s?.dailySummary?.enabled ?? false,
time: s?.dailySummary?.time || '07:00',
email: s?.dailySummary?.email ?? true,
discord: s?.dailySummary?.discord ?? false,
webhook: s?.dailySummary?.webhook ?? false,
push: s?.dailySummary?.push ?? false,
},
};
}
const NOTIFICATION_EVENT_ROWS: { key: keyof NotificationEvents; label: string }[] = [
{ key: 'due', label: 'Fällige Aufgaben (sofort)' },
{ key: 'shared', label: 'Freigabe erhalten' },
{ key: 'taskDone', label: 'Geteilte Aufgabe erledigt' },
{ key: 'watched', label: 'Beobachtete Aufgaben' },
];
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
currentUser,
activeTheme,
themes,
customThemes,
onThemeChange,
onCustomThemesChange,
onUserUpdated,
onBack,
}) => {
const [section, setSection] = useState<UserSettingsSection>('profile');
const [legal, setLegal] = useState<AuthStatus['legal']>();
// --- Danger zone ---
const [dangerAction, setDangerAction] = useState<'data' | 'account' | null>(null);
const [dangerPassword, setDangerPassword] = useState('');
const [dangerError, setDangerError] = useState<string | null>(null);
const [dangerBusy, setDangerBusy] = useState(false);
const [dangerDone, setDangerDone] = useState<string | null>(null);
const [isLastAdmin, setIsLastAdmin] = useState(false);
useEffect(() => {
api.getMe().then((me) => setIsLastAdmin(Boolean(me.isLastAdmin))).catch(() => {});
}, []);
const openDanger = (action: 'data' | 'account') => {
setDangerAction(action);
setDangerPassword('');
setDangerError(null);
setDangerDone(null);
};
const handleDangerSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setDangerBusy(true);
setDangerError(null);
try {
if (dangerAction === 'account') {
await api.deleteOwnAccount(dangerPassword);
// The account is gone: drop the session and show the login screen
clearToken();
window.location.reload();
return;
}
await api.purgeOwnData(dangerPassword);
setDangerAction(null);
setDangerDone('Alle deine Daten wurden gelöscht.');
onUserUpdated(currentUser);
} catch (err: any) {
setDangerError(err.message || 'Aktion fehlgeschlagen');
} finally {
setDangerBusy(false);
}
};
const [showAbout, setShowAbout] = useState(false);
// The public demo sends nothing and keeps the shared accounts intact (FLAW-33)
const [demoMode, setDemoMode] = useState(false);
const linkPreviewsOn = useLinkPreviewsEnabled();
const [journalOn, setJournalOn] = useState(isJournalEnabled);
const [corners, setCorners] = useState<CornerStyle>(getCornerStyle);
const [followSystem, setFollowSystemState] = useState(getFollowSystem);
const [buttonBorders, setButtonBordersState] = useState<ButtonBorders>(getButtonBorders);
const [accent, setAccent] = useState<string | null>(getAccentColor);
useEffect(() => {
api
.getAuthStatus()
.then((st) => {
setLegal(st.legal);
setDemoMode(Boolean(st.demoMode));
})
.catch(() => {});
}, []);
// --- Custom Theme Editor State ---
const [themeEditId, setThemeEditId] = useState<string | null>(null); // null = new theme
const [themeName, setThemeName] = useState('');
const [themeCss, setThemeCss] = useState('');
const [themeMsg, setThemeMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingTheme, setSavingTheme] = useState(false);
const [deletingThemeId, setDeletingThemeId] = useState<string | null>(null);
const themeFileRef = useRef<HTMLInputElement>(null);
const resetThemeEditor = () => {
setThemeEditId(null);
setThemeName('');
setThemeCss('');
};
const handleThemeFile = async (file?: File) => {
if (!file) return;
const text = await file.text();
setThemeCss(text);
if (!themeName.trim()) setThemeName(file.name.replace(/\.css$/i, '').replace(/^theme-/, ''));
setThemeMsg(null);
};
const handleDownloadTemplate = () => {
const url = URL.createObjectURL(new Blob([themeTemplateCss], { type: 'text/css' }));
const a = document.createElement('a');
a.href = url;
a.download = 'theme-template.css';
a.click();
URL.revokeObjectURL(url);
};
const handleCopyTemplate = async () => {
try {
await navigator.clipboard.writeText(themeTemplateCss);
setThemeMsg({ type: 'ok', text: 'Vorlage in die Zwischenablage kopiert.' });
} catch {
setThemeMsg({ type: 'err', text: 'Kopieren nicht möglich – nutze „Vorlage einfügen“ oder den Download.' });
}
};
// Saving activates the theme right away (injected live, no reload)
const handleSaveTheme = async (e: React.FormEvent) => {
e.preventDefault();
setSavingTheme(true);
setThemeMsg(null);
try {
const saved = await api.saveTheme({ id: themeEditId || undefined, name: themeName.trim(), css: themeCss });
const next = themeEditId
? customThemes.map((t) => (t.id === saved.id ? saved : t))
: [...customThemes, saved];
onCustomThemesChange(next, customThemeId(saved));
setThemeMsg({ type: 'ok', text: `Theme „${saved.name}“ gespeichert und aktiviert.` });
resetThemeEditor();
} catch (err: any) {
setThemeMsg({ type: 'err', text: err.message || 'Theme konnte nicht gespeichert werden' });
} finally {
setSavingTheme(false);
}
};
const handleEditTheme = (t: UserTheme) => {
setThemeEditId(t.id);
setThemeName(t.name);
setThemeCss(t.css);
setThemeMsg(null);
};
const handleDeleteTheme = async (t: UserTheme) => {
try {
await api.deleteTheme(t.id);
setDeletingThemeId(null);
if (themeEditId === t.id) resetThemeEditor();
// Deleting the active theme falls back to the default one
onCustomThemesChange(
customThemes.filter((x) => x.id !== t.id),
activeTheme === customThemeId(t) ? 'dark' : undefined
);
} catch (err: any) {
setThemeMsg({ type: 'err', text: err.message || 'Theme konnte nicht gelöscht werden' });
}
};
// --- Profile State ---
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
const [email, setEmail] = useState(currentUser.email || '');
const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || '');
const [newPassword, setNewPassword] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingProfile, setSavingProfile] = useState(false);
const [revokingSessions, setRevokingSessions] = useState(false);
const [isProcessingAvatar, setIsProcessingAvatar] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// --- Multi-Channel Alarms State ---
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>(() =>
buildNotificationSettings(currentUser)
);
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);
// Web push: devices of this account and whether this browser is one of them
const [pushDevices, setPushDevices] = useState<PushDevice[]>([]);
const [pushHere, setPushHere] = useState(false);
const [pushEndpointHere, setPushEndpointHere] = useState<string | null>(null);
const [pushBusy, setPushBusy] = useState(false);
const [pushMsg, setPushMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const reloadPush = useCallback(async () => {
const [devices, sub] = await Promise.all([api.getPushDevices().catch(() => []), currentPushSubscription()]);
setPushDevices(devices);
setPushHere(Boolean(sub && devices.some((d) => d.endpoint === sub.endpoint)));
setPushEndpointHere(sub?.endpoint ?? null);
}, []);
// An old installation or a device you no longer use: remove it from the list
const removeDevice = async (endpoint: string) => {
setPushBusy(true);
setPushMsg(null);
try {
await removePushDevice(endpoint);
await reloadPush();
} catch (err) {
setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Gerät konnte nicht entfernt werden' });
} finally {
setPushBusy(false);
}
};
useEffect(() => {
void reloadPush();
}, [reloadPush]);
const togglePushHere = async (on: boolean) => {
setPushBusy(true);
setPushMsg(null);
try {
if (on) await enablePush();
else await disablePush();
await reloadPush();
setPushMsg({ type: 'ok', text: on ? 'Push ist auf diesem Gerät eingeschaltet.' : 'Push ist auf diesem Gerät ausgeschaltet.' });
} catch (err) {
setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Push konnte nicht eingeschaltet werden' });
} finally {
setPushBusy(false);
}
};
const sendTestPush = async () => {
setPushBusy(true);
setPushMsg(null);
try {
const res = await api.testPush();
setPushMsg({ type: 'ok', text: `Test-Benachrichtigung an ${res.devices} ${res.devices === 1 ? 'Gerät' : 'Geräte'} gesendet.` });
} catch (err) {
setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Test fehlgeschlagen' });
} finally {
setPushBusy(false);
}
};
useEffect(() => {
setDisplayName(currentUser.displayName || '');
setEmail(currentUser.email || '');
setAvatarUrl(currentUser.avatarUrl || '');
setNotificationSettings(buildNotificationSettings(currentUser));
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
setCustomWebhookUrl(currentUser.customWebhookUrl || '');
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
}, [currentUser]);
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord' | 'webhook' | 'push', value: boolean) => {
setNotificationSettings((prev) => ({
...prev,
events: {
...prev.events,
[eventKey]: {
...prev.events[eventKey],
[channel]: value,
},
},
}));
};
// --- Handlers: Avatar ---
const handleAvatarFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsProcessingAvatar(true);
setProfileMsg(null);
try {
const dataUrl = await resizeImageToDataUrl(file, 256);
setAvatarUrl(dataUrl);
triggerHaptic(10);
} catch (err: any) {
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Laden des Bildes' });
} finally {
setIsProcessingAvatar(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleSelectPresetAvatar = (preset: string) => {
setAvatarUrl(preset);
triggerHaptic(10);
};
const handleRemoveAvatar = () => {
setAvatarUrl('');
triggerHaptic(10);
};
// Changing the password or the login address needs the current password (FLAW-30)
const needsCurrentPassword =
newPassword.trim() !== '' || email.trim().toLowerCase() !== (currentUser.email || '').toLowerCase();
// --- Handlers: Profile ---
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
setProfileMsg(null);
setSavingProfile(true);
try {
const patch: any = {
displayName: displayName.trim(),
email: email.trim(),
avatarUrl: avatarUrl.trim(),
};
if (newPassword.trim()) {
const problem = passwordProblem(newPassword);
if (problem) {
setProfileMsg({ type: 'err', text: problem });
return;
}
// Not trimmed: login compares the password exactly as typed
patch.password = newPassword;
}
if (needsCurrentPassword) patch.currentPassword = currentPassword;
const updated = await api.updateSettings(patch);
onUserUpdated(updated);
setNewPassword('');
setCurrentPassword('');
setProfileMsg({ type: 'ok', text: 'Profil und Anmeldedaten erfolgreich gespeichert.' });
} catch (err: any) {
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
} finally {
setSavingProfile(false);
}
};
const handleRevokeAllSessions = async () => {
if (!window.confirm('Möchtest du wirklich alle aktiven Sitzungen auf allen Geräten beenden? Du wirst dabei auch auf diesem Gerät abgemeldet.')) {
return;
}
setRevokingSessions(true);
try {
await api.revokeAllSessions();
await api.logout();
window.location.reload();
} catch (err: any) {
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Beenden der Sitzungen' });
setRevokingSessions(false);
}
};
// --- Handlers: Alarms ---
const handleSaveAlarms = async (e: React.FormEvent) => {
e.preventDefault();
setAlarmMsg(null);
setSavingAlarms(true);
try {
const nextAlarmChannels = [
...(notificationSettings.events.due.email ? ['email'] : []),
...(notificationSettings.events.due.discord ? ['discord'] : []),
...(notificationSettings.events.due.webhook ? ['webhook'] : []),
];
const updated = await api.updateSettings({
alarmChannels: nextAlarmChannels,
alarmEmail: alarmEmail.trim(),
discordWebhookUrl: discordWebhookUrl.trim(),
customWebhookUrl: customWebhookUrl.trim(),
notifyBeforeMinutes: Number(notifyBeforeMinutes),
notificationSettings,
});
onUserUpdated({
...updated,
notificationSettings: updated.notificationSettings ?? notificationSettings,
});
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: Export ---
// Works on mobile browsers too: the link must be in the document and the URL alive until the click ran
const [exportingBackup, setExportingBackup] = useState(false);
const handleExportData = async () => {
setExportingBackup(true);
try {
saveBlob(await api.exportBackupZip(), `mindsift-backup-${new Date().toISOString().slice(0, 10)}.zip`);
} catch (err: any) {
setVaultError(err.message || 'Backup fehlgeschlagen');
} finally {
setExportingBackup(false);
}
};
const [exportingVault, setExportingVault] = useState(false);
const [vaultError, setVaultError] = useState<string | null>(null);
const handleExportVault = async () => {
setExportingVault(true);
setVaultError(null);
try {
saveBlob(await api.exportMarkdownVault(), `mindsift-vault-${new Date().toISOString().slice(0, 10)}.zip`);
} catch (err: any) {
setVaultError(err.message || 'Export fehlgeschlagen');
} finally {
setExportingVault(false);
}
};
const roleBadgeLabel =
currentUser.role === 'admin'
? 'Server-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-main tracking-tight">Kontoeinstellungen</h1>
<p className="text-xs text-muted">
Persönliches Profil, Design-Themes, Aufgaben-Alarme, Private Webhooks & Daten-Backup
</p>
</div>
</div>
<span className="px-2.5 py-1 bg-panel border border-border text-xs text-muted 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="panel-surface divide-y divide-border overflow-hidden">
<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-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<UserIcon className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Profil & Sicherheit</p>
<p className="text-[10px] text-faint">Name, E-Mail & Passwort</p>
</div>
</button>
<button
onClick={() => setSection('appearance')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'appearance'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<Palette className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Darstellung & Themes</p>
<p className="text-[10px] text-faint">Farben, Kontraste & Rundungen</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-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<Bell className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Alarme</p>
<p className="text-[10px] text-faint">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-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<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-faint">API-Keys, Curl & JSON-Backup</p>
</div>
</button>
</aside>
{/* Right Content Area */}
<div className="md:col-span-3 panel-surface p-5 text-xs">
{/* ================================================================= */}
{/* SECTION 1: PROFIL & SICHERHEIT */}
{/* ================================================================= */}
{section === 'profile' && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">Profil & Anmeldedaten</h2>
<p className="text-muted mt-0.5">
Verwalte deinen Anzeigenamen, deine E-Mail-Adresse und dein persönliches Passwort.
</p>
</div>
{profileMsg && (
<div
className={`flex items-center gap-2 ${
profileMsg.type === 'ok' ? 'alert-ok' : 'alert-err'
}`}
>
{profileMsg.type === 'ok' ? (
<Check className="w-4 h-4 text-status-ok flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
)}
<span>{profileMsg.text}</span>
</div>
)}
<form onSubmit={handleSaveProfile} className="space-y-5">
{/* Avatar Management */}
<div className="p-3.5 bg-panel-elevated/40 border border-border rounded-box space-y-3">
<label className="block text-muted font-medium">Profilbild</label>
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4">
<div className="relative group shrink-0">
<UserAvatar
user={{ displayName, email, avatarUrl }}
size="xl"
className="border-2 border-border/80 shadow-md ring-2 ring-primary/20"
/>
{isProcessingAvatar && (
<div className="absolute inset-0 bg-base-100/70 rounded-full flex items-center justify-center text-[10px] font-medium text-main">
Lädt…
</div>
)}
</div>
<div className="flex-1 space-y-2.5 text-center sm:text-left">
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarFileSelected}
className="hidden"
id="avatar-upload-input"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isProcessingAvatar}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
<span>Bild hochladen</span>
</button>
{avatarUrl && (
<button
type="button"
onClick={handleRemoveAvatar}
className="btn-ghost h-8 px-2.5 text-xs text-status-err hover:bg-status-err/10 flex items-center gap-1"
title="Profilbild entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Entfernen</span>
</button>
)}
</div>
{/* Emoji Presets */}
<div className="space-y-1">
<span className="text-[11px] text-muted">Oder schnelles Emoji wählen:</span>
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-1.5 pt-0.5">
{['🦊', '🐱', '🐶', '🐼', '🦁', '🦉', '🚀', '☕', '⚡', '🎨', '🌟', '🎯', '💡'].map(
(preset) => (
<button
key={preset}
type="button"
onClick={() => handleSelectPresetAvatar(preset)}
className={`w-7 h-7 rounded-full text-sm flex items-center justify-center transition-transform hover:scale-110 active:scale-95 ${
avatarUrl === preset
? 'bg-primary/20 border-2 border-primary scale-105'
: 'bg-panel hover:bg-panel-hover border border-border'
}`}
title={`Emoji ${preset}`}
>
{preset}
</button>
)
)}
</div>
</div>
<p className="text-[10px] text-faint">
Wird überall kreisförmig dargestellt (z.&nbsp;B. Menüleiste oben, Freigabelisten, Aufgaben).
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-muted 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"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
E-Mail-Adresse (Login)
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={demoMode}
title={demoMode ? 'In der Demo deaktiviert' : undefined}
className="w-full h-9 px-3 input-field disabled:opacity-60"
/>
</div>
</div>
<div>
<label className="block text-muted 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={demoMode ? 'In der Demo deaktiviert' : 'Mindestens 8 Zeichen'}
autoComplete="new-password"
disabled={demoMode}
className="w-full h-9 px-3 input-field disabled:opacity-60"
/>
</div>
{needsCurrentPassword && (
<div>
<label className="block text-muted font-medium mb-1">
Aktuelles Passwort (zur Bestätigung)
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
className="w-full h-9 px-3 input-field"
/>
</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>
{/* Sessions & Security */}
<div className="pt-5 border-t border-border space-y-3">
<div>
<h3 className="text-xs font-bold text-main">Aktive Sitzungen & Sicherheit</h3>
<p className="text-[11px] text-muted">
Geräte, auf denen du angemeldet bist. Unbenutzte Sitzungen enden nach 30 Tagen; eine Passwortänderung meldet alle anderen Geräte ab.
</p>
</div>
<SessionsPanel />
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-canvas">
<div className="text-xs">
<p className="font-semibold text-main">Alle Sitzungen sofort beenden</p>
<p className="text-[11px] text-faint">
Invalidiert alle ausgestellten Tokens auf allen Geräten und Browsern.
</p>
</div>
<button
type="button"
onClick={handleRevokeAllSessions}
disabled={demoMode || revokingSessions}
title={demoMode ? 'In der Demo deaktiviert' : undefined}
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
{revokingSessions ? 'Beende…' : 'Alle Sitzungen beenden'}
</button>
</div>
</div>
{/* Danger zone: delete own data or the whole account (password required) */}
<div className="p-4 border border-status-err/40 bg-status-err/5 rounded-card space-y-3">
<h3 className="font-bold text-status-err flex items-center gap-1.5">
<AlertCircle className="w-4 h-4" />
<span>Gefahrenzone</span>
</h3>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<p className="text-main font-medium">Alle eigenen Daten löschen</p>
<p className="text-[11px] text-muted">
Löscht deine Notizen, Ordner, Aufgaben, Gedanken und Listen. Konto, Passwort und Einstellungen bleiben.
Mit dir geteilte Inhalte anderer bleiben erhalten.
</p>
</div>
<button
type="button"
onClick={() => openDanger('data')}
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0"
>
Daten löschen
</button>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-3 border-t border-status-err/20">
<div>
<p className="text-main font-medium">Konto unwiderruflich löschen</p>
<p className="text-[11px] text-muted">
{isLastAdmin
? 'Der letzte Server-Administrator kann nicht gelöscht werden. Bitte ernenne vorher ein anderes Konto zum Administrator.'
: 'Löscht dein Konto mit allen Daten. Du wirst danach abgemeldet.'}
</p>
</div>
<button
type="button"
onClick={() => openDanger('account')}
disabled={isLastAdmin || demoMode}
title={demoMode ? 'In der Demo deaktiviert' : undefined}
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
Konto löschen
</button>
</div>
{dangerDone && <p className="text-[11px] text-status-ok">{dangerDone}</p>}
</div>
</div>
)}
{/* ================================================================= */}
{/* SECTION 2: DARSTELLUNG & THEMES (SINGLE-FILE CSS THEMING) */}
{/* ================================================================= */}
{section === 'appearance' && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">
Darstellung & Single-File CSS Themes
</h2>
<p className="text-muted mt-0.5">
Jedes Theme ist eine einzige, eigenständige CSS-Datei, in der alle Farben,
Oberflächen, Kontraste und Ecken-Rundungen in 6 festen Gruppen gebündelt sind.
</p>
</div>
<div className="p-3.5 rounded-card border border-border bg-canvas flex items-center justify-between gap-4">
<div className="text-xs">
<p className="font-semibold text-main">Link-Vorschauen anzeigen</p>
<p className="text-[11px] text-faint">
Karte mit Titel, Beschreibung und kleinem Bild über Punkten mit einer Web-Adresse. Die Seite wird
dafür vom MindSift-Server abgerufen, nicht von deinem Browser. Gilt für dieses Gerät; einzelne
Vorschauen lassen sich mit ✕ ausblenden.
</p>
</div>
<ToggleSwitch checked={linkPreviewsOn} onChange={setLinkPreviewsEnabled} title="Link-Vorschauen anzeigen" />
</div>
<div className="p-3.5 rounded-card border border-border bg-canvas flex items-center justify-between gap-4">
<div className="text-xs">
<p className="font-semibold text-main">Journal anzeigen</p>
<p className="text-[11px] text-faint">
Eine Notiz pro Tag, untereinander mit Kalender – ganz oben in der Notizen-Seitenleiste. Ausschalten
blendet es nur aus, die Tage bleiben als Notizen erhalten. Gilt für dieses Gerät.
</p>
</div>
<ToggleSwitch
checked={journalOn}
onChange={(v) => {
setJournalEnabled(v);
setJournalOn(v);
}}
title="Journal anzeigen"
/>
</div>
{/* Colour families: left light, right dark */}
<div className="space-y-2">
<div className="grid grid-cols-[1fr_minmax(0,9rem)_minmax(0,9rem)] sm:grid-cols-[1fr_12rem_12rem] items-center gap-2 px-1 text-[11px] font-semibold uppercase tracking-wider text-faint">
<span>Theme</span>
<span className="text-center">Hell</span>
<span className="text-center">Dunkel</span>
</div>
{THEME_FAMILIES.map((f) => (
<div
key={f.id}
className="grid grid-cols-[1fr_minmax(0,9rem)_minmax(0,9rem)] sm:grid-cols-[1fr_12rem_12rem] items-center gap-2 p-2 rounded-card bg-canvas"
>
<div className="min-w-0 px-1">
<p className="font-semibold text-main text-sm truncate">{f.name}</p>
<p className="text-[11px] text-muted truncate">{f.subtitle}</p>
</div>
{[f.light, f.dark].map((id) => {
const t = AVAILABLE_THEMES.find((x) => x.id === id)!;
const selected = activeTheme === id;
return (
<button
key={id}
type="button"
onClick={() => onThemeChange(resolveTheme(id))}
aria-pressed={selected}
title={t.name}
className={`h-14 rounded-card p-2 flex flex-col justify-between text-left transition-all cursor-pointer ${
selected ? 'ring-2 ring-accent' : 'hover:scale-[1.02]'
}`}
style={{ backgroundColor: t.previewBg, border: '1px solid rgba(127,127,127,0.25)' }}
>
<span className="flex items-center gap-1">
<span className="w-6 h-2.5 rounded-full" style={{ backgroundColor: t.previewPanel }} />
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: t.previewAccent }} />
</span>
<span className="text-[11px] font-semibold truncate" style={{ color: t.previewText }}>
{selected ? '✓ Aktiv' : t.mode === 'light' ? 'Hell' : 'Dunkel'}
</span>
</button>
);
})}
</div>
))}
</div>
{/* Corners for every theme (per device) */}
<div className="p-3.5 rounded-card border border-border bg-canvas flex flex-wrap items-center justify-between gap-3">
<div className="text-xs">
<p className="font-semibold text-main">Ecken</p>
<p className="text-[11px] text-faint">Abgerundet oder eckig – unabhängig vom Theme. Gilt für dieses Gerät.</p>
</div>
<div className="flex items-center bg-panel-elevated rounded-btn p-0.5 text-xs" role="radiogroup" aria-label="Ecken">
{(
[
['theme', 'Wie Theme'],
['round', 'Rund'],
['square', 'Eckig'],
] as [CornerStyle, string][]
).map(([value, label]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={corners === value}
onClick={() => {
setCornerStyle(value);
setCorners(value);
}}
className={`h-9 sm:h-7 px-3 rounded-btn transition-colors cursor-pointer ${
corners === value ? 'bg-panel text-main font-semibold shadow-sm' : 'text-muted hover:text-main'
}`}
>
{label}
</button>
))}
</div>
</div>
{/* Light/dark: fixed, or following the system inside the family */}
<div className="p-3.5 rounded-card border border-border bg-canvas flex flex-wrap items-center justify-between gap-3">
<div className="text-xs">
<p className="font-semibold text-main">Hell oder dunkel</p>
<p className="text-[11px] text-faint">„Wie System“ wechselt innerhalb der Farbfamilie mit dem Hell-/Dunkelmodus des Geräts.</p>
</div>
<div className="flex items-center bg-panel-elevated rounded-btn p-0.5 text-xs" role="radiogroup" aria-label="Hell oder dunkel">
{(
[
[false, 'Selbst wählen'],
[true, 'Wie System'],
] as [boolean, string][]
).map(([value, label]) => (
<button
key={label}
type="button"
role="radio"
aria-checked={followSystem === value}
onClick={() => {
setFollowSystem(value);
setFollowSystemState(value);
if (value) onThemeChange(resolveTheme(activeTheme));
}}
className={`h-9 sm:h-7 px-3 rounded-btn transition-colors cursor-pointer ${
followSystem === value ? 'bg-panel text-main font-semibold shadow-sm' : 'text-muted hover:text-main'
}`}
>
{label}
</button>
))}
</div>
</div>
{/* Frames around buttons */}
<div className="p-3.5 rounded-card border border-border bg-canvas flex flex-wrap items-center justify-between gap-3">
<div className="text-xs">
<p className="font-semibold text-main">Rahmen an Knöpfen</p>
<p className="text-[11px] text-faint">Ohne Rahmen sind Knöpfe nur leicht hinterlegt. Gilt für dieses Gerät.</p>
</div>
<div className="flex items-center bg-panel-elevated rounded-btn p-0.5 text-xs" role="radiogroup" aria-label="Rahmen an Knöpfen">
{(
[
['on', 'Mit Rahmen'],
['off', 'Ohne Rahmen'],
] as [ButtonBorders, string][]
).map(([value, label]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={buttonBorders === value}
onClick={() => {
setButtonBorders(value);
setButtonBordersState(value);
}}
className={`h-9 sm:h-7 px-3 rounded-btn transition-colors cursor-pointer ${
buttonBorders === value ? 'bg-panel text-main font-semibold shadow-sm' : 'text-muted hover:text-main'
}`}
>
{label}
</button>
))}
</div>
</div>
{/* Own accent colour on top of the theme */}
<div className="p-3.5 rounded-card border border-border bg-canvas space-y-2.5">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
<div>
<p className="font-semibold text-main">Akzentfarbe</p>
<p className="text-[11px] text-faint">Ersetzt die Akzentfarbe des Themes (Knöpfe, Links, Markierungen). Gilt für dieses Gerät.</p>
</div>
{accent && (
<button
type="button"
onClick={() => {
setAccentColor(null);
setAccent(null);
}}
className="btn-action h-9 sm:h-7 px-2.5 text-xs"
>
Wie Theme
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{['#2563eb', '#0f766e', '#16a34a', '#ca8a04', '#c2410c', '#dc2626', '#db2777', '#8e44c9', '#475569'].map((c) => (
<button
key={c}
type="button"
onClick={() => {
setAccentColor(c);
setAccent(c);
}}
title={c}
aria-label={`Akzentfarbe ${c}`}
className={`w-9 h-9 sm:w-7 sm:h-7 rounded-full transition-transform hover:scale-110 cursor-pointer ${
accent === c ? 'ring-2 ring-offset-2 ring-offset-canvas ring-main' : ''
}`}
style={{ backgroundColor: c }}
/>
))}
<label className="flex items-center gap-1.5 text-[11px] text-muted cursor-pointer">
<input
type="color"
value={accent || '#2563eb'}
onChange={(e) => {
setAccentColor(e.target.value);
setAccent(e.target.value);
}}
className="w-9 h-9 sm:w-7 sm:h-7 rounded-full border border-border bg-transparent p-0 cursor-pointer"
aria-label="Eigene Akzentfarbe wählen"
/>
Eigene
</label>
</div>
</div>
{themes.some((t) => t.isCustom) && <p className="font-semibold text-main text-sm pt-1">Eigene Themes</p>}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{themes.filter((t) => t.isCustom).map((t) => {
const isSelected = activeTheme === t.id;
const custom = customThemes.find((c) => customThemeId(c) === t.id);
return (
<div key={t.id} className="flex flex-col">
<button
type="button"
onClick={() => onThemeChange(t.id)}
className={`flex-1 p-4 text-left border transition-all flex flex-col justify-between gap-3 ${
isSelected
? 'bg-canvas border-accent shadow-sm'
: 'bg-canvas border-border hover:border-strong'
}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-bold text-sm text-main flex items-center gap-2">
<span>{t.name}</span>
{isSelected && <span className="badge-accent">Aktiv</span>}
</p>
<p className="text-xs text-muted mt-0.5">{t.subtitle}</p>
</div>
</div>
{/* Mini Color & Geometry Swatch */}
<div className="flex items-center justify-between pt-2 border-t border-border">
<div className="flex items-center gap-1.5">
<span
className="w-5 h-5 border border-strong inline-block"
style={{
backgroundColor: t.previewBg,
borderRadius: t.isRounded ? '6px' : '0px',
}}
title="Canvas-Hintergrund"
/>
<span
className="w-5 h-5 border border-strong inline-block"
style={{
backgroundColor: t.previewPanel,
borderRadius: t.isRounded ? '6px' : '0px',
}}
title="Panel-Oberfläche"
/>
<span
className="w-5 h-5 border border-strong inline-block"
style={{
backgroundColor: t.previewAccent,
borderRadius: t.isRounded ? '6px' : '0px',
}}
title="Akzentfarbe"
/>
<span
className="w-5 h-5 border border-strong inline-block"
style={{
backgroundColor: t.previewText,
borderRadius: t.isRounded ? '6px' : '0px',
}}
title="Textfarbe"
/>
</div>
<code className="text-[10px] font-mono text-faint">{t.file}</code>
</div>
</button>
{custom && (
<div className="flex items-center justify-end gap-1.5 px-2 py-1.5 border border-t-0 border-border bg-canvas">
{deletingThemeId === custom.id ? (
<>
<span className="flex-1 text-[11px] text-muted">Theme wirklich löschen?</span>
<button onClick={() => handleDeleteTheme(custom)} className="btn-primary h-7 px-2.5 text-[11px]">
Löschen
</button>
<button onClick={() => setDeletingThemeId(null)} className="btn-action h-7 px-2 text-[11px]">
Abbrechen
</button>
</>
) : (
<>
<button onClick={() => handleEditTheme(custom)} className="btn-icon" title="Theme bearbeiten">
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setDeletingThemeId(custom.id)}
className="btn-icon btn-icon-danger"
title="Theme löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
)}
</div>
);
})}
</div>
{/* Custom theme: upload or paste CSS, based on the template */}
<form onSubmit={handleSaveTheme} className="p-4 bg-canvas border border-border space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-semibold text-main">
{themeEditId ? 'Eigenes Theme bearbeiten' : 'Eigenes Theme hinzufügen'}
</p>
<div className="flex flex-wrap gap-1.5">
<button type="button" onClick={handleDownloadTemplate} className="btn-action h-7 px-2 text-[11px] flex items-center gap-1">
<Download className="w-3 h-3" />
<span>Vorlage herunterladen</span>
</button>
<button type="button" onClick={handleCopyTemplate} className="btn-action h-7 px-2 text-[11px] flex items-center gap-1">
<Copy className="w-3 h-3" />
<span>Vorlage kopieren</span>
</button>
<button
type="button"
onClick={() => setThemeCss(themeTemplateCss)}
className="btn-action h-7 px-2 text-[11px]"
>
Vorlage einfügen
</button>
</div>
</div>
<p className="text-muted text-[11px] leading-relaxed">
Ein Theme ist CSS mit 6 Blöcken: <strong>1. Akzentfarbe</strong>, <strong>2. Oberflächen</strong>,{' '}
<strong>3. Textfarben</strong>, <strong>4. Rahmen &amp; Schatten</strong>, <strong>5. Statusfarben</strong> und{' '}
<strong>6. Rundungen</strong>. Am einfachsten startest du mit der Vorlage und änderst die Werte. Gespeicherte
Themes liegen auf dem Server und stehen dir auf allen Geräten zur Verfügung.
</p>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={themeName}
onChange={(e) => setThemeName(e.target.value)}
placeholder="Name, z. B. Wald"
maxLength={60}
className="flex-1 min-w-0 h-8 px-2.5 input-field"
/>
<button
type="button"
onClick={() => themeFileRef.current?.click()}
className="btn-action h-8 px-3 text-xs flex items-center justify-center gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
<span>CSS-Datei hochladen</span>
</button>
<input
ref={themeFileRef}
type="file"
accept=".css,text/css"
className="hidden"
onChange={(e) => {
handleThemeFile(e.target.files?.[0]);
e.target.value = '';
}}
/>
</div>
<textarea
value={themeCss}
onChange={(e) => setThemeCss(e.target.value)}
placeholder=":root[data-theme=&quot;mein-theme&quot;] { --accent-primary: #15803d; … }"
rows={10}
spellCheck={false}
className="w-full px-2.5 py-2 input-field font-mono text-[11px] leading-relaxed resize-y"
/>
{themeMsg && (
<p className={`text-[11px] ${themeMsg.type === 'ok' ? 'text-status-ok' : 'text-status-err'}`}>
{themeMsg.text}
</p>
)}
<div className="flex justify-end gap-2">
{themeEditId && (
<button type="button" onClick={resetThemeEditor} className="btn-action h-8 px-3 text-xs">
Abbrechen
</button>
)}
<button
type="submit"
disabled={savingTheme || !themeName.trim() || !themeCss.trim()}
className="btn-primary h-8 px-4 text-xs font-semibold disabled:opacity-50"
>
{savingTheme ? 'Speichere…' : 'Speichern & aktivieren'}
</button>
</div>
</form>
</div>
)}
{/* ================================================================= */}
{/* SECTION 3: 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-main">
Aufgaben-Alarme & Benachrichtigungsziele
</h2>
<p className="text-muted 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>
{demoMode && (
<div className="flex items-center gap-2 alert-err">
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
<span>In der Demo deaktiviert: Alarme und Tests werden nicht verschickt.</span>
</div>
)}
{alarmMsg && (
<div
className={`flex items-center gap-2 ${
alarmMsg.type === 'ok' ? 'alert-ok' : 'alert-err'
}`}
>
{alarmMsg.type === 'ok' ? (
<Check className="w-4 h-4 text-status-ok flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
)}
<span>{alarmMsg.text}</span>
</div>
)}
<form onSubmit={handleSaveAlarms} className="space-y-4">
{/* Global Pause & Own Watched Toggles */}
<div className="p-3.5 bg-canvas border border-border space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-main">Alle Benachrichtigungen pausieren</p>
<p className="text-[11px] text-muted">
Pausiert vorübergehend sämtliche E-Mail- und Discord-Benachrichtigungen.
</p>
</div>
<ToggleSwitch
checked={notificationSettings.paused}
onChange={(v) => setNotificationSettings((prev) => ({ ...prev, paused: v }))}
title="Alle Benachrichtigungen pausieren"
/>
</div>
<div className="flex items-center justify-between gap-4 pt-3 border-t border-border">
<div>
<p className="font-semibold text-main">
Auch eigene Änderungen an beobachteten Aufgaben melden
</p>
<p className="text-[11px] text-muted">
Standardmäßig wirst du nur über Änderungen anderer Personen benachrichtigt.
</p>
</div>
<ToggleSwitch
checked={notificationSettings.includeOwnWatched}
onChange={(v) => setNotificationSettings((prev) => ({ ...prev, includeOwnWatched: v }))}
title="Auch eigene Änderungen an beobachteten Aufgaben melden"
/>
</div>
</div>
{/* Routing Matrix: Events x (E-Mail / Discord / own webhook) */}
{(() => {
const hasDiscordWebhook = Boolean(discordWebhookUrl.trim());
const hasCustomWebhook = Boolean(customWebhookUrl.trim());
const hasPush = pushDevices.length > 0;
return (
<>
<div className="p-4 bg-canvas border border-border space-y-3">
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-1 sm:gap-4 pb-2 border-b border-border">
<span className="font-bold text-main">Ereignis</span>
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
<Mail className="w-3.5 h-3.5 text-accent" />
<span>E-Mail</span>
</span>
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
<MessageSquare className="w-3.5 h-3.5 text-accent" />
<span>Discord</span>
</span>
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
<Globe className="w-3.5 h-3.5 text-accent" />
<span>Webhook</span>
</span>
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
<Bell className="w-3.5 h-3.5 text-accent" />
<span>Push</span>
</span>
</div>
<div className="divide-y divide-border">
{NOTIFICATION_EVENT_ROWS.map((row) => {
const routing = notificationSettings.events[row.key];
return (
<div
key={row.key}
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-1 sm:gap-4 py-2.5"
>
<span className="font-medium text-main">{row.label}</span>
<div className="w-12 sm:w-16 flex justify-center">
<ToggleSwitch
checked={routing.email}
onChange={(v) => setEventChannel(row.key, 'email', v)}
title={`${row.label} – E-Mail`}
/>
</div>
<div className="w-12 sm:w-16 flex justify-center">
<ToggleSwitch
checked={routing.discord}
disabled={!hasDiscordWebhook}
onChange={(v) => setEventChannel(row.key, 'discord', v)}
title={
hasDiscordWebhook
? `${row.label} – Discord`
: 'Bitte zuerst unten eine Discord Webhook URL eintragen'
}
/>
</div>
<div className="w-12 sm:w-16 flex justify-center">
<ToggleSwitch
checked={routing.webhook}
disabled={!hasCustomWebhook}
onChange={(v) => setEventChannel(row.key, 'webhook', v)}
title={
hasCustomWebhook
? `${row.label} – Webhook`
: 'Bitte zuerst unten eine eigene Webhook URL eintragen'
}
/>
</div>
<div className="w-12 sm:w-16 flex justify-center">
<ToggleSwitch
checked={routing.push}
disabled={!hasPush}
onChange={(v) => setEventChannel(row.key, 'push', v)}
title={hasPush ? `${row.label} – Push` : 'Bitte zuerst unten Push auf einem Gerät einschalten'}
/>
</div>
</div>
);
})}
</div>
<p className="text-[11px] text-faint pt-1">
Gebündelt: höchstens alle 5 Minuten eine Nachricht (außer fällige Aufgaben)
</p>
{!hasDiscordWebhook && (
<p className="text-[11px] text-status-warn">
Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist.
</p>
)}
{!hasCustomWebhook && (
<p className="text-[11px] text-faint">
Webhook-Schalter werden aktiv, sobald unten eine eigene Webhook-URL eingetragen ist.
</p>
)}
</div>
{/* Daily Summary */}
<div className="p-4 bg-canvas border border-border space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-bold text-main">Tageszusammenfassung</p>
<p className="text-[11px] text-muted">
Überfällige, heutige und morgige Aufgaben; nichts fällig = keine Nachricht
</p>
</div>
<ToggleSwitch
checked={notificationSettings.dailySummary.enabled}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, enabled: v },
}))
}
title="Tageszusammenfassung"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-4 pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-main font-medium">
<span>Uhrzeit:</span>
<input
type="time"
aria-label="Uhrzeit der Tageszusammenfassung"
value={notificationSettings.dailySummary.time}
onChange={(e) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, time: e.target.value },
}))
}
className="h-11 sm:h-8 px-2.5 input-field font-mono"
/>
</label>
{/* Phones: 2×2 grid, the four switches do not fit in one row */}
<div className="grid grid-cols-2 gap-x-6 gap-y-3 w-full sm:w-auto sm:flex sm:items-center sm:gap-5">
<div className="flex items-center justify-between sm:justify-start gap-2">
<span className="text-xs text-muted">E-Mail</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.email}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, email: v },
}))
}
title="Tageszusammenfassung – E-Mail"
/>
</div>
<div className="flex items-center justify-between sm:justify-start gap-2">
<span className="text-xs text-muted">Discord</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.discord}
disabled={!hasDiscordWebhook}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, discord: v },
}))
}
title={
hasDiscordWebhook
? 'Tageszusammenfassung – Discord'
: 'Bitte zuerst unten eine Discord Webhook URL eintragen'
}
/>
</div>
<div className="flex items-center justify-between sm:justify-start gap-2">
<span className="text-xs text-muted">Webhook</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.webhook}
disabled={!hasCustomWebhook}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, webhook: v },
}))
}
title={
hasCustomWebhook
? 'Tageszusammenfassung – Webhook'
: 'Bitte zuerst unten eine eigene Webhook URL eintragen'
}
/>
</div>
<div className="flex items-center justify-between sm:justify-start gap-2">
<span className="text-xs text-muted">Push</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.push}
disabled={!hasPush}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, push: v },
}))
}
title={hasPush ? 'Tageszusammenfassung – Push' : 'Bitte zuerst unten Push auf einem Gerät einschalten'}
/>
</div>
</div>
</div>
</div>
</>
);
})()}
{/* 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-main block">
Vorwarnzeit vor Fälligkeit
</label>
<p className="text-[11px] text-muted">
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 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 border-border transition-colors space-y-3 bg-canvas">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5">
<Mail className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-main block">Ziel: E-Mail</span>
<span className="text-[11px] text-muted">
Sendet eine E-Mail über den konfigurierten SMTP-Server der Instanz.
</span>
</div>
</div>
<button
type="button"
onClick={() => handleTestChannel('email')}
disabled={demoMode || testingChannel === 'email'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
<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-muted 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"
/>
</div>
</div>
{/* Channel 2: Discord Webhook */}
<div className="p-4 border border-border transition-colors space-y-3 bg-canvas">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5">
<MessageSquare className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-main block">Ziel: Discord Webhook</span>
<span className="text-[11px] text-muted">
Sendet eine formatierte Embed-Benachrichtigung direkt in deinen
Discord-Kanal.
</span>
</div>
</div>
<button
type="button"
onClick={() => handleTestChannel('discord')}
disabled={demoMode || testingChannel === 'discord'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
<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-muted 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 font-mono"
/>
</div>
</div>
{/* Channel 3: Custom HTTP Webhook */}
<div
className={`p-4 border transition-colors space-y-3 bg-canvas ${
customWebhookUrl.trim() ? 'border-accent' : 'border-border'
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5">
<Globe className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-main block">
Ziel: Eigener Webhook (HTTP POST JSON)
</span>
<span className="text-[11px] text-muted">
Für eigene Empfänger-Skripte, Ntfy, Gotify, Slack, Mattermost oder
Automatisierungen. Welche Ereignisse hierher gehen, stellst du oben in der Spalte „Webhook“ ein.
</span>
</div>
</div>
<button
type="button"
onClick={() => handleTestChannel('webhook')}
disabled={demoMode || testingChannel === 'webhook'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
<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-muted 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 font-mono"
/>
</div>
</div>
{/* Web push: this browser or installed app */}
<div className={`p-4 border transition-colors space-y-3 bg-canvas ${pushHere ? 'border-accent' : 'border-border'}`}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<Bell className="w-4 h-4 text-accent shrink-0" />
<div className="min-w-0">
<span className="font-bold text-main block">Ziel: Push-Benachrichtigungen</span>
<span className="text-[11px] text-muted">
Systembenachrichtigung auf diesem Gerät, auch wenn MindSift geschlossen ist. Auf iPhone und
iPad nur, wenn MindSift zum Home-Bildschirm hinzugefügt wurde.
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-muted">Dieses Gerät</span>
<ToggleSwitch
checked={pushHere}
disabled={pushBusy || demoMode || !pushSupported()}
onChange={(v) => void togglePushHere(v)}
title={pushSupported() ? 'Push auf diesem Gerät' : 'Dieser Browser unterstützt keine Push-Benachrichtigungen'}
/>
<button
type="button"
onClick={() => void sendTestPush()}
disabled={pushBusy || demoMode || pushDevices.length === 0}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Send className="w-3 h-3 text-accent" />
<span>Test senden</span>
</button>
</div>
</div>
{pushDevices.length > 0 && (
<div className="pl-6 space-y-1">
<p className="text-[11px] text-faint">
Aktiv auf {pushDevices.length} {pushDevices.length === 1 ? 'Gerät' : 'Geräten'}. Alte Installationen
verschwinden beim nächsten Push von selbst oder lassen sich hier entfernen.
</p>
<ul className="border border-border divide-y divide-border bg-panel">
{pushDevices.map((d) => (
<li key={d.id} className="flex items-center justify-between gap-2 pl-2.5 pr-1 py-1">
<span className="min-w-0 text-[11px] text-main truncate">
{d.device || 'Gerät'}
<span className="text-faint">
{' · seit '}
{new Date(d.createdAt).toLocaleDateString('de-DE')}
{d.endpoint === pushEndpointHere && ' · dieses Gerät'}
</span>
</span>
<button
type="button"
onClick={() => void removeDevice(d.endpoint)}
disabled={pushBusy || demoMode}
title="Gerät entfernen"
aria-label={`${d.device || 'Gerät'} entfernen`}
className="h-9 w-9 sm:h-7 sm:w-7 shrink-0 flex items-center justify-center text-faint hover:text-status-err disabled:opacity-40"
>
<X className="w-3.5 h-3.5" />
</button>
</li>
))}
</ul>
</div>
)}
{pushMsg && (
<p className={`pl-6 text-[11px] ${pushMsg.type === 'ok' ? 'text-status-ok' : 'text-status-err'}`}>{pushMsg.text}</p>
)}
</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 4: 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-main">
Private Webhooks (Eingehend) & Daten-Export
</h2>
<p className="text-muted 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>
<ApiKeysPanel />
<div className="pt-4 border-t border-border">
<ServerBackupsCard />
</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-main">Vollständiges Backup (.zip)</h3>
<p className="text-muted text-[11px]">
Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, mit den Bildern und Sprachnotizen deiner
Notizen, gepackt in eine ZIP-Datei. Über „MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.
</p>
</div>
<button
onClick={handleExportData}
disabled={exportingBackup}
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 flex-shrink-0 disabled:opacity-50"
>
<Download className="w-3.5 h-3.5 text-accent" />
<span>{exportingBackup ? 'Wird erstellt…' : 'Backup herunterladen'}</span>
</button>
</div>
{/* Obsidian-compatible Markdown vault */}
<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-main">Markdown-Archiv für Obsidian (.zip)</h3>
<p className="text-muted text-[11px]">
Alle Notizen als <code className="font-mono">.md</code>-Dateien in deiner Ordnerstruktur (mit Titel, Tags und
Datum im Kopf), Aufgaben je Liste unter <code className="font-mono">Aufgaben/</code> im Format des
Obsidian-Tasks-Plugins, Listen unter <code className="font-mono">Listen/</code>, Bilder und Sprachnotizen unter{' '}
<code className="font-mono">attachments/</code>. Mit dir geteilte Notizen sind dabei.
</p>
{vaultError && <p className="text-[11px] text-status-err mt-1">{vaultError}</p>}
</div>
<button
onClick={handleExportVault}
disabled={exportingVault}
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 flex-shrink-0 disabled:opacity-50"
>
<Download className="w-3.5 h-3.5 text-accent" />
<span>{exportingVault ? 'Wird erstellt…' : 'Markdown-Archiv herunterladen'}</span>
</button>
</div>
{/* Import: two separate paths, never guessed from the file */}
<div className="pt-4 border-t border-border space-y-3">
<div>
<h3 className="font-bold text-main">Daten importieren</h3>
<p className="text-muted text-[11px]">
Ein Import legt alles neu an und ändert nichts Bestehendes. Vor dem Import siehst du, was angelegt wird.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<ImportCard
source="mindsift"
title="MindSift-Daten"
accept=".json,.zip,application/json,application/zip"
description={
<>
Eigenes Backup zurückspielen: JSON-Backup (<code className="font-mono">.json</code>) oder Markdown-Archiv
(<code className="font-mono">.zip</code>). Notizen kommen in ihre Ordner, Aufgabenlisten mit Unteraufgaben,
Fälligkeiten und Prioritäten sowie Listen werden wiederhergestellt.
</>
}
/>
<ImportCard
source="obsidian"
title="Obsidian-Vault"
accept=".zip,application/zip"
description={
<>
Vault als <code className="font-mono">.zip</code>. Alle Notizen landen in einem eigenen Ordner mit dem Namen
des Vaults, Unterordner bleiben erhalten. Checkboxen bleiben Checkboxen in der Notiz, Frontmatter-Tags werden zu
#Hashtags. Bilder und Audiodateien, die eine Notiz einbettet (<code className="font-mono">![[bild.png]]</code>{' '}
oder <code className="font-mono">![](bild.png)</code>), werden übernommen; andere Anhänge wie PDFs nicht.
</>
}
/>
</div>
</div>
</div>
)}
</div>
</div>
{/* Footer: legal notices of this instance and project info */}
<LegalLinks legal={legal} className="pt-4 border-t border-border">
<button type="button" onClick={() => setShowAbout(true)} className="hover:text-main underline-offset-2 hover:underline">
Über MindSift
</button>
</LegalLinks>
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
{dangerAction && (
<FormSheet
title={dangerAction === 'account' ? 'Konto unwiderruflich löschen' : 'Alle eigenen Daten löschen'}
icon={<AlertCircle className="w-4 h-4 text-status-err" />}
onClose={() => setDangerAction(null)}
onSubmit={handleDangerSubmit}
submitLabel={dangerAction === 'account' ? 'Konto löschen' : 'Daten löschen'}
danger
busy={dangerBusy || !dangerPassword}
>
<p className="text-xs text-muted">
{dangerAction === 'account'
? 'Dein Konto und alle deine Daten werden endgültig gelöscht. Das lässt sich nicht rückgängig machen.'
: 'Alle deine Notizen, Ordner, Aufgaben, Gedanken und Listen werden endgültig gelöscht. Lade vorher ein Backup herunter, wenn du sie behalten willst.'}
</p>
<label className="block text-xs text-muted">
Zur Bestätigung dein Passwort
<input
type="password"
autoFocus
autoComplete="current-password"
value={dangerPassword}
onChange={(e) => setDangerPassword(e.target.value)}
className="mt-1 input-field w-full h-11 sm:h-8 px-2.5 text-base sm:text-xs"
/>
</label>
{dangerError && <p className="text-[11px] text-status-err">{dangerError}</p>}
</FormSheet>
)}
</div>
);
};