MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 2m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1993 lines
93 KiB
TypeScript
1993 lines
93 KiB
TypeScript
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. 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 & 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">
|
||
{/* 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"></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>
|
||
);
|
||
};
|