MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
- Demo: senders for mail, Discord and webhooks refuse, no alarm ticker; test alarm, SMTP test, server settings, backups, account and session changes of the shared demo accounts answer 403; the session list only shows the own device. The UI shows the notices and disables the controls. - SMTP password is never sent back (smtpPassSet), empty keeps it. - ?token= on webhook ingest accepts only API keys. - Request bodies capped at 2 MB (sync 8 MB, imports 101 MB). - Deleting an account removes its server backups. - Backup downloads, exports and import uploads get 30 minutes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1331 lines
58 KiB
TypeScript
1331 lines
58 KiB
TypeScript
import React, { useEffect, useState, useRef } from 'react';
|
||
import {
|
||
User as UserIcon,
|
||
Bell,
|
||
Key,
|
||
Trash2,
|
||
Download,
|
||
ArrowLeft,
|
||
Check,
|
||
Mail,
|
||
MessageSquare,
|
||
Globe,
|
||
Send,
|
||
AlertCircle,
|
||
Palette,
|
||
Upload,
|
||
Pencil,
|
||
Copy,
|
||
} from 'lucide-react';
|
||
import { AuthStatus, 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 { customThemeId, 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 { 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 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);
|
||
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 [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);
|
||
|
||
useEffect(() => {
|
||
setDisplayName(currentUser.displayName || '');
|
||
setEmail(currentUser.email || '');
|
||
setAvatarUrl(currentUser.avatarUrl || '');
|
||
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
|
||
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
|
||
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
|
||
setCustomWebhookUrl(currentUser.customWebhookUrl || '');
|
||
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
|
||
}, [currentUser]);
|
||
|
||
const toggleAlarmChannel = (ch: string) => {
|
||
setAlarmChannels((prev) =>
|
||
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
|
||
);
|
||
};
|
||
|
||
// --- 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 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: 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 max-w-2xl">
|
||
<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. 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="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
{themes.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 & 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="mein-theme"] { --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">
|
||
{/* 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 transition-colors space-y-3 bg-canvas ${
|
||
alarmChannels.includes('email') ? 'border-accent' : '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-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>
|
||
</label>
|
||
|
||
<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 transition-colors space-y-3 bg-canvas ${
|
||
alarmChannels.includes('discord') ? 'border-accent' : '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-main block">Ziel: Discord Webhook</span>
|
||
<span className="text-[11px] text-muted">
|
||
Sendet eine formatierte Embed-Benachrichtigung direkt in deinen
|
||
Discord-Kanal.
|
||
</span>
|
||
</div>
|
||
</label>
|
||
|
||
<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 ${
|
||
alarmChannels.includes('webhook') ? 'border-accent' : '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-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.
|
||
</span>
|
||
</div>
|
||
</label>
|
||
|
||
<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>
|
||
|
||
<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, 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>. 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 andere Anhänge werden übersprungen.
|
||
</>
|
||
}
|
||
/>
|
||
</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>
|
||
);
|
||
};
|