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 = ({ currentUser, activeTheme, themes, customThemes, onThemeChange, onCustomThemesChange, onUserUpdated, onBack, }) => { const [section, setSection] = useState('profile'); const [legal, setLegal] = useState(); // --- Danger zone --- const [dangerAction, setDangerAction] = useState<'data' | 'account' | null>(null); const [dangerPassword, setDangerPassword] = useState(''); const [dangerError, setDangerError] = useState(null); const [dangerBusy, setDangerBusy] = useState(false); const [dangerDone, setDangerDone] = useState(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(getCornerStyle); const [followSystem, setFollowSystemState] = useState(getFollowSystem); const [buttonBorders, setButtonBordersState] = useState(getButtonBorders); const [accent, setAccent] = useState(getAccentColor); useEffect(() => { api .getAuthStatus() .then((st) => { setLegal(st.legal); setDemoMode(Boolean(st.demoMode)); }) .catch(() => {}); }, []); // --- Custom Theme Editor State --- const [themeEditId, setThemeEditId] = useState(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(null); const themeFileRef = useRef(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(null); // --- Multi-Channel Alarms State --- const [notificationSettings, setNotificationSettings] = useState(() => 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( currentUser.notifyBeforeMinutes ?? 30 ); const [alarmMsg, setAlarmMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); const [savingAlarms, setSavingAlarms] = useState(false); const [testingChannel, setTestingChannel] = useState(null); // Web push: devices of this account and whether this browser is one of them const [pushDevices, setPushDevices] = useState([]); const [pushHere, setPushHere] = useState(false); const [pushEndpointHere, setPushEndpointHere] = useState(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) => { 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(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 (
{/* Top Page Header */}

Kontoeinstellungen

Persönliches Profil, Design-Themes, Aufgaben-Alarme, Private Webhooks & Daten-Backup

Rolle: {roleBadgeLabel}
{/* Main 2-Column Layout */}
{/* Left Navigation Sidebar */} {/* Right Content Area */}
{/* ================================================================= */} {/* SECTION 1: PROFIL & SICHERHEIT */} {/* ================================================================= */} {section === 'profile' && (

Profil & Anmeldedaten

Verwalte deinen Anzeigenamen, deine E-Mail-Adresse und dein persönliches Passwort.

{profileMsg && (
{profileMsg.type === 'ok' ? ( ) : ( )} {profileMsg.text}
)}
{/* Avatar Management */}
{isProcessingAvatar && (
Lädt…
)}
{avatarUrl && ( )}
{/* Emoji Presets */}
Oder schnelles Emoji wählen:
{['🦊', '🐱', '🐶', '🐼', '🦁', '🦉', '🚀', '☕', '⚡', '🎨', '🌟', '🎯', '💡'].map( (preset) => ( ) )}

Wird überall kreisförmig dargestellt (z. B. Menüleiste oben, Freigabelisten, Aufgaben).

setDisplayName(e.target.value)} required className="w-full h-9 px-3 input-field" />
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" />
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" />
{needsCurrentPassword && (
setCurrentPassword(e.target.value)} required autoComplete="current-password" className="w-full h-9 px-3 input-field" />
)}
{/* Sessions & Security */}

Aktive Sitzungen & Sicherheit

Geräte, auf denen du angemeldet bist. Unbenutzte Sitzungen enden nach 30 Tagen; eine Passwortänderung meldet alle anderen Geräte ab.

Alle Sitzungen sofort beenden

Invalidiert alle ausgestellten Tokens auf allen Geräten und Browsern.

{/* Danger zone: delete own data or the whole account (password required) */}

Gefahrenzone

Alle eigenen Daten löschen

Löscht deine Notizen, Ordner, Aufgaben, Gedanken und Listen. Konto, Passwort und Einstellungen bleiben. Mit dir geteilte Inhalte anderer bleiben erhalten.

Konto unwiderruflich löschen

{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.'}

{dangerDone &&

{dangerDone}

}
)} {/* ================================================================= */} {/* SECTION 2: DARSTELLUNG & THEMES (SINGLE-FILE CSS THEMING) */} {/* ================================================================= */} {section === 'appearance' && (

Darstellung & Single-File CSS Themes

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.

Link-Vorschauen anzeigen

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.

Journal anzeigen

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.

{ setJournalEnabled(v); setJournalOn(v); }} title="Journal anzeigen" />
{/* Colour families: left light, right dark */}
Theme Hell Dunkel
{THEME_FAMILIES.map((f) => (

{f.name}

{f.subtitle}

{[f.light, f.dark].map((id) => { const t = AVAILABLE_THEMES.find((x) => x.id === id)!; const selected = activeTheme === id; return ( ); })}
))}
{/* Corners for every theme (per device) */}

Ecken

Abgerundet oder eckig – unabhängig vom Theme. Gilt für dieses Gerät.

{( [ ['theme', 'Wie Theme'], ['round', 'Rund'], ['square', 'Eckig'], ] as [CornerStyle, string][] ).map(([value, label]) => ( ))}
{/* Light/dark: fixed, or following the system inside the family */}

Hell oder dunkel

„Wie System“ wechselt innerhalb der Farbfamilie mit dem Hell-/Dunkelmodus des Geräts.

{( [ [false, 'Selbst wählen'], [true, 'Wie System'], ] as [boolean, string][] ).map(([value, label]) => ( ))}
{/* Frames around buttons */}

Rahmen an Knöpfen

Ohne Rahmen sind Knöpfe nur leicht hinterlegt. Gilt für dieses Gerät.

{( [ ['on', 'Mit Rahmen'], ['off', 'Ohne Rahmen'], ] as [ButtonBorders, string][] ).map(([value, label]) => ( ))}
{/* Own accent colour on top of the theme */}

Akzentfarbe

Ersetzt die Akzentfarbe des Themes (Knöpfe, Links, Markierungen). Gilt für dieses Gerät.

{accent && ( )}
{['#2563eb', '#0f766e', '#16a34a', '#ca8a04', '#c2410c', '#dc2626', '#db2777', '#8e44c9', '#475569'].map((c) => (
{themes.some((t) => t.isCustom) &&

Eigene Themes

}
{themes.filter((t) => t.isCustom).map((t) => { const isSelected = activeTheme === t.id; const custom = customThemes.find((c) => customThemeId(c) === t.id); return (
{custom && (
{deletingThemeId === custom.id ? ( <> Theme wirklich löschen? ) : ( <> )}
)}
); })}
{/* Custom theme: upload or paste CSS, based on the template */}

{themeEditId ? 'Eigenes Theme bearbeiten' : 'Eigenes Theme hinzufügen'}

Ein Theme ist CSS mit 6 Blöcken: 1. Akzentfarbe, 2. Oberflächen,{' '} 3. Textfarben, 4. Rahmen & Schatten, 5. Statusfarben und{' '} 6. Rundungen. 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.

setThemeName(e.target.value)} placeholder="Name, z. B. Wald" maxLength={60} className="flex-1 min-w-0 h-8 px-2.5 input-field" /> { handleThemeFile(e.target.files?.[0]); e.target.value = ''; }} />