import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Layers,
CheckSquare,
FileText,
ShoppingCart,
Brain,
Settings,
Shield,
LogOut,
ChevronDown,
RotateCcw,
Palette,
HelpCircle,
Plus,
X,
Download,
Smartphone,
Share,
Users,
Calendar,
AlertTriangle,
Flame,
Mic,
MicOff,
Loader2,
Search,
} from 'lucide-react';
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types';
import {
api,
clearToken,
getToken,
triggerHaptic,
connectWebSocket,
disconnectWebSocket,
isSharingDisabled,
} from './lib/api';
import { useVoiceInput } from './lib/useVoiceInput';
import { VoiceWave } from './components/VoiceWave';
import {
AVAILABLE_THEMES,
customThemeOption,
initThemeOnBoot,
hasChosenTheme,
DEMO_THEME,
watchSystemScheme,
isCustomThemeId,
setActiveTheme as applyTheme,
ThemeId,
} from './lib/theme';
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
import { HoldToRecordFab } from './features/capture/HoldToRecordFab';
import { CountBadge } from './components/CountBadge';
import { TriageDeck } from './features/triage/TriageDeck';
import { TasksView } from './features/triage/TasksView';
import { WorkspaceView } from './features/workspace/WorkspaceView';
import { SharedListsView } from './features/lists/SharedListsView';
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { OfflineBanner } from './components/OfflineBanner';
import { useServerOnline } from './lib/useServerOnline';
import { SharedImportToast } from './components/SharedImportToast';
import { outboxCount, clearOfflineData } from './lib/offline';
import { DemoCountdown } from './components/DemoCountdown';
import { UserSettingsView } from './features/settings/UserSettingsView';
import { AdminSettingsView } from './features/settings/AdminSettingsView';
import { HelpView } from './features/help/HelpView';
import { GroupsView } from './features/teams/GroupsView';
import { useMobileActions } from './context/MobileActionContext';
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
import { UserAvatar } from './components/UserAvatar';
import { CommandPalette } from './components/CommandPalette';
import { AppFooter } from './components/AppFooter';
import { ThemeQuickPicker } from './components/ThemeQuickPicker';
import { AboutModal } from './components/AboutModal';
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
import { APP_VERSION } from './version';
type ActiveTab =
| 'triage'
| 'tasks'
| 'workspace'
| 'lists'
| 'groups'
| 'user_settings'
| 'admin_settings'
| 'help';
interface ErrorBoundaryState {
hasError: boolean;
errorText: string;
}
class ViewErrorBoundary extends React.Component<
{ children: React.ReactNode; onReset: () => void },
ErrorBoundaryState
> {
constructor(props: { children: React.ReactNode; onReset: () => void }) {
super(props);
this.state = { hasError: false, errorText: '' };
}
static getDerivedStateFromError(err: Error): ErrorBoundaryState {
return { hasError: true, errorText: err?.message || 'Unbekannter Darstellungsfehler' };
}
render() {
if (this.state.hasError) {
return (
Ansicht konnte nicht geladen werden
{this.state.errorText}
);
}
return this.props.children;
}
}
export const App: React.FC = () => {
const [activeTheme, setActiveThemeState] = useState(() => initThemeOnBoot());
const [bootLoading, setBootLoading] = useState(true);
const [authStatus, setAuthStatus] = useState({
needsSetup: false,
allowRegistration: false,
instanceName: 'MindSift',
});
const [user, setUser] = useState(null);
// Demo mode: when the next reset happens (local clock), and a counter that remounts all views after it
const [demoDeadline, setDemoDeadline] = useState(null);
const [dataEpoch, setDataEpoch] = useState(0);
const [demoNotice, setDemoNotice] = useState(null);
// Just shared from another app: these Quick Dump entries can still be sent on as task / list item
const [sharedImportIds, setSharedImportIds] = useState(null);
const closeSharedImport = useCallback(() => setSharedImportIds(null), []);
const lastDemoResetRef = useRef(0);
const handleDemoResetRef = useRef<() => void>(() => {});
// Link from the password reset mail (?resetToken=…); removed from the address bar right away
const [resetToken, setResetToken] = useState(() => {
try {
return new URLSearchParams(window.location.search).get('resetToken');
} catch {
return null;
}
});
useEffect(() => {
if (resetToken) window.history.replaceState(null, '', window.location.pathname);
}, [resetToken]);
const [customThemes, setCustomThemes] = useState([]);
const allThemes = useMemo(() => [...AVAILABLE_THEMES, ...customThemes.map(customThemeOption)], [customThemes]);
const [stats, setStats] = useState({
inboxCount: 0,
tasksOpenCount: 0,
tasksDoneCount: 0,
docsCount: 0,
docsTodosOpenCount: 0,
listItemsOpenCount: 0,
listsCount: 0,
discardedCount: 0,
});
const [deck, setDeck] = useState([]);
const [lists, setLists] = useState([]);
const [activeTab, setActiveTab] = useState('triage');
const [previousTab, setPreviousTab] = useState('triage');
const [selectedDocId, setSelectedDocId] = useState(null);
const [selectedBlockId, setSelectedBlockId] = useState(null);
const [selectedTaskId, setSelectedTaskId] = useState(null);
const [selectedListId, setSelectedListId] = useState(null);
const [showCommandPalette, setShowCommandPalette] = useState(false);
const [captureFocusCount, setCaptureFocusCount] = useState(0);
const [refreshRevision, setRefreshRevision] = useState(0);
// Profile Dropdown State
const [showProfileMenu, setShowProfileMenu] = useState(false);
const serverOnline = useServerOnline();
const [showAbout, setShowAbout] = useState(false);
const dropdownRef = useRef(null);
// Mobile Navigation & Quick-Action State
const [showMobileCapture, setShowMobileCapture] = useState(false);
const [showMobileProfile, setShowMobileProfile] = useState(false);
const [mobileTriageContent, setMobileTriageContent] = useState('');
const [mobileTriagePriority, setMobileTriagePriority] = useState('normal');
const [mobileTriageDueDate, setMobileTriageDueDate] = useState('');
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
const [mobileVoiceError, setMobileVoiceError] = useState(null);
const mobileBaseVoiceRef = useRef('');
// Voice input of the mobile capture sheet: own Whisper server or the browser (lib/voice.ts)
const mobileVoice = useVoiceInput({
onText: (text) => {
if (!text) return;
const base = mobileBaseVoiceRef.current;
setMobileTriageContent(base ? `${base} ${text}` : text);
setIsVoiceCaptured(true);
},
onError: (message) => {
setMobileVoiceError(message);
window.setTimeout(() => setMobileVoiceError(null), 4000);
},
});
const mobileVoiceActive = mobileVoice.state === 'listening';
const toggleMobileVoice = () => {
if (mobileVoice.state === 'idle') {
mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim();
setMobileVoiceError(null);
triggerHaptic(20);
}
mobileVoice.toggle();
};
const handleMobileCreateTriage = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = mobileTriageContent.trim();
if (!trimmed) return;
try {
const thought = await api.captureThought(
trimmed,
mobileTriagePriority,
isVoiceCaptured ? 'voice' : 'manual',
mobileTriageDueDate,
{ target: 'inbox' }
);
handleCaptured(thought, 'inbox');
setMobileTriageContent('');
setMobileTriagePriority('normal');
setMobileTriageDueDate('');
setIsVoiceCaptured(false);
setShowMobileCapture(false);
triggerHaptic(20);
} catch (err: any) {
alert(`Fehler beim Speichern: ${err.message || 'Unbekannter Fehler'}`);
}
};
const openMobileTriageCapture = () => {
setMobileTriageContent('');
setMobileTriagePriority('normal');
setMobileTriageDueDate('');
setIsVoiceCaptured(false);
if (mobileVoice.state !== 'idle') mobileVoice.cancel();
setShowMobileCapture(true);
};
const { isMenuOpen, setIsMenuOpen, closeMenu, setGroups, primaryAction, setPrimaryAction } = useMobileActions();
const primaryActionRef = useRef(primaryAction);
primaryActionRef.current = primaryAction;
// App icon shortcuts (manifest "shortcuts", long-press on the icon): open the tab and its input right away.
// ?shortcut=quickdump|task|list; the old links (?mode=capture, ?tab=triage) still lead to the Quick Dump.
const [pendingShortcut, setPendingShortcut] = useState(() => {
const params = new URLSearchParams(window.location.search);
const target = params.get('shortcut') || (params.get('mode') === 'capture' || params.get('tab') === 'triage' ? 'quickdump' : '');
return ({ quickdump: 'triage', task: 'tasks', list: 'lists' } as Record)[target] || null;
});
useEffect(() => {
if (!pendingShortcut || !user) return;
if (activeTab !== pendingShortcut) {
setActiveTab(pendingShortcut);
return;
}
const target = pendingShortcut;
setPendingShortcut(null);
const url = new URL(window.location.href);
['shortcut', 'mode', 'tab'].forEach((k) => url.searchParams.delete(k));
window.history.replaceState({}, '', url.pathname + url.search + url.hash);
// The view registers its "+" action once it is mounted, so wait a moment (no cleanup: resetting
// pendingShortcut above re-runs this effect and must not cancel the timer)
window.setTimeout(() => {
if (target === 'triage') handleTriggerAddButton();
else if (window.innerWidth < 640 || target === 'lists') primaryActionRef.current?.();
}, 400);
}, [pendingShortcut, user?.id, activeTab]);
useEffect(() => {
closeMenu();
if (activeTab === 'triage') {
setPrimaryAction(openMobileTriageCapture);
setGroups([
{
id: 'triage-actions',
title: 'Schnellaktionen',
items: [
{
id: 'new-thought',
label: 'Neuen Gedanken erfassen',
icon: ,
variant: 'accent',
onClick: () => openMobileTriageCapture(),
},
{
id: 'refresh-deck',
label: 'Inbox aktualisieren',
icon: ,
badge: stats.inboxCount,
onClick: () => refreshAll(),
},
],
},
{
id: 'triage-nav',
title: 'Bereiche wechseln',
items: [
{
id: 'goto-workspace',
label: 'Notizen & Workspace',
icon: ,
badge: stats.docsCount,
onClick: () => setActiveTab('workspace'),
},
{
id: 'goto-tasks',
label: 'Aufgaben & Kanban',
icon: ,
badge: stats.tasksOpenCount,
onClick: () => setActiveTab('tasks'),
},
{
id: 'goto-lists',
label: 'Einkaufslisten',
icon: ,
badge: stats.listsCount,
onClick: () => setActiveTab('lists'),
},
...(isSharingDisabled()
? []
: [
{
id: 'goto-groups',
label: 'Gruppen',
icon: ,
onClick: () => setActiveTab('groups'),
},
]),
],
},
]);
} else if (
activeTab === 'groups' ||
activeTab === 'user_settings' ||
activeTab === 'admin_settings' ||
activeTab === 'help'
) {
setGroups([
{
id: 'subview-nav',
title: 'Navigation',
items: [
{
id: 'back-triage',
label: 'Zurück zur Triage (Startseite)',
icon: ,
variant: 'accent',
onClick: () => setActiveTab('triage'),
},
{
id: 'goto-workspace',
label: 'Zum Workspace',
icon: ,
onClick: () => setActiveTab('workspace'),
},
{
id: 'goto-tasks',
label: 'Zu den Aufgaben',
icon: ,
onClick: () => setActiveTab('tasks'),
},
{
id: 'goto-lists',
label: 'Zu den Listen',
icon: ,
onClick: () => setActiveTab('lists'),
},
],
},
]);
}
}, [activeTab, stats.inboxCount, stats.tasksOpenCount, stats.docsCount, stats.listsCount]);
// Mobile Web-App (PWA) Installation State
const [isStandaloneApp, setIsStandaloneApp] = useState(() => {
if (typeof window === 'undefined') return false;
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true
);
});
const [deferredPwaPrompt, setDeferredPwaPrompt] = useState(() =>
typeof window !== 'undefined' ? (window as any).__deferredPwaPrompt || null : null
);
const [showPwaInstallModal, setShowPwaInstallModal] = useState(false);
const [showManualPwaGuide, setShowManualPwaGuide] = useState(false);
useEffect(() => {
const syncPwaPrompt = () => {
if ((window as any).__deferredPwaPrompt) {
setDeferredPwaPrompt((window as any).__deferredPwaPrompt);
}
};
const onBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
(window as any).__deferredPwaPrompt = e;
setDeferredPwaPrompt(e);
};
const onAppInstalled = () => {
(window as any).__deferredPwaPrompt = null;
setDeferredPwaPrompt(null);
setIsStandaloneApp(true);
setShowPwaInstallModal(false);
setShowManualPwaGuide(false);
};
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt);
window.addEventListener('mindsift:pwa-ready', syncPwaPrompt);
window.addEventListener('appinstalled', onAppInstalled);
return () => {
window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt);
window.removeEventListener('mindsift:pwa-ready', syncPwaPrompt);
window.removeEventListener('appinstalled', onAppInstalled);
};
}, []);
// Immediately offer Web-App installation on mobile if not yet running as installed standalone app
useEffect(() => {
if (!user || isStandaloneApp) return;
if (window.innerWidth < 640 && !sessionStorage.getItem('mindsift_pwa_dismissed')) {
setShowPwaInstallModal(true);
}
}, [user?.id, isStandaloneApp]);
const handleInstallWebApp = async () => {
triggerHaptic(15);
const promptEvent = deferredPwaPrompt || (window as any).__deferredPwaPrompt;
if (promptEvent && typeof promptEvent.prompt === 'function') {
try {
await promptEvent.prompt();
const choice = await promptEvent.userChoice;
if (choice && choice.outcome === 'accepted') {
(window as any).__deferredPwaPrompt = null;
setDeferredPwaPrompt(null);
setShowPwaInstallModal(false);
setShowManualPwaGuide(false);
}
} catch {
setShowManualPwaGuide(true);
}
} else {
setShowManualPwaGuide(true);
}
};
const handleDismissPwaModal = () => {
triggerHaptic(10);
sessionStorage.setItem('mindsift_pwa_dismissed', '1');
setShowPwaInstallModal(false);
setShowManualPwaGuide(false);
};
const handleOpenHelp = () => {
triggerHaptic(10);
setPreviousTab((prev) => (activeTab === 'help' ? prev : activeTab));
setActiveTab('help');
setShowProfileMenu(false);
};
const activateTheme = (themeId: ThemeId, themes: UserTheme[]) => {
const css = themes.find((t) => 'custom:' + t.id === themeId)?.css;
applyTheme(themeId, css);
setActiveThemeState(isCustomThemeId(themeId) && css === undefined ? 'dark' : themeId);
};
// "Wie System": the theme follows the system's light/dark switch while the app is open
useEffect(() => watchSystemScheme(setActiveThemeState), []);
const handleChangeTheme = (themeId: ThemeId) => {
triggerHaptic(10);
activateTheme(themeId, customThemes);
};
// Custom themes live on the server; after login refresh them and the active one (it may have
// been edited or deleted on another device)
const handleCustomThemesChange = (themes: UserTheme[], activate?: ThemeId) => {
setCustomThemes(themes);
const next = activate ?? activeTheme;
if (activate || isCustomThemeId(next)) activateTheme(next, themes);
};
useEffect(() => {
if (!user?.id) return;
api
.getThemes()
.then((themes) => handleCustomThemesChange(themes))
.catch(() => {});
}, [user?.id]);
const refreshAll = useCallback(async () => {
try {
const [meRes, deckRes, listsRes] = await Promise.all([
api.getMe(),
api.getTriageDeck(),
api.getAllLists(),
]);
setUser(meRes.user);
setStats(meRes.stats);
setDeck(Array.isArray(deckRes) ? deckRes : []);
setLists(Array.isArray(listsRes) ? listsRes : []);
} catch {
// If token expired or invalid, api.ts dispatches mindsift:unauthorized
}
}, []);
const checkBootAndAuth = async () => {
setBootLoading(true);
try {
const st = await api.getAuthStatus();
setAuthStatus(st);
if (st.needsSetup) {
clearToken();
setUser(null);
return;
}
if (getToken()) {
await api.flushOfflineQueue();
await refreshAll();
} else {
setUser(null);
}
} catch {
// Server temporarily unreachable
} finally {
setBootLoading(false);
}
};
useEffect(() => {
checkBootAndAuth();
const onUnauthorized = async () => {
setUser(null);
setShowProfileMenu(false);
try {
const st = await api.getAuthStatus();
setAuthStatus(st);
} catch {
// ignore
}
};
const onOnline = async () => {
if (getToken()) {
const synced = await api.flushOfflineQueue();
if (synced > 0) refreshAll();
}
};
// Changes still waiting (server was unreachable although the device was online) are retried
const retry = window.setInterval(() => {
if (navigator.onLine && outboxCount() > 0) onOnline();
}, 30000);
window.addEventListener('mindsift:unauthorized', onUnauthorized);
window.addEventListener('online', onOnline);
return () => {
window.clearInterval(retry);
window.removeEventListener('mindsift:unauthorized', onUnauthorized);
window.removeEventListener('online', onOnline);
};
}, []);
// WebSocket Connection Management
useEffect(() => {
if (user && getToken()) {
connectWebSocket();
} else {
disconnectWebSocket();
}
}, [user?.id]);
useEffect(() => {
setDemoDeadline(
authStatus.demoMode && typeof authStatus.demoResetInSeconds === 'number'
? Date.now() + authStatus.demoResetInSeconds * 1000
: null
);
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
// Demo: first visit on this device starts with a round theme instead of the square default
useEffect(() => {
if (!authStatus.demoMode || hasChosenTheme()) return;
applyTheme(DEMO_THEME);
setActiveThemeState(DEMO_THEME);
}, [authStatus.demoMode]);
// Web Share Target: import shared thoughts from external apps
useEffect(() => {
if (!user) return;
const params = new URLSearchParams(window.location.search);
const hasPendingShare = params.get('shared') === 'pending';
const processShares = async () => {
try {
const shares = await consumePendingShares();
if (shares.length === 0) return;
let importedCount = 0;
const ids: string[] = [];
for (const share of shares) {
const content = formatSharedThought(share);
if (content) {
const created = await api.captureThought(content, 'normal', 'manual', '', { target: 'inbox' });
importedCount++;
if (created?.id && !created.id.startsWith('th_offline_')) ids.push(created.id);
}
}
if (importedCount > 0) {
triggerHaptic(15);
if (ids.length === importedCount) {
setSharedImportIds(ids);
} else {
// Offline: kept for later, nothing to convert yet
setDemoNotice('Im Quick Dump abgelegt');
setTimeout(() => setDemoNotice(null), 4000);
}
refreshAll();
}
} catch (err) {
console.error('Fehler beim Import geteilter Daten:', err);
} finally {
if (hasPendingShare) {
const url = new URL(window.location.href);
url.searchParams.delete('shared');
window.history.replaceState({}, '', url.pathname + (url.search ? url.search : '') + url.hash);
}
}
};
processShares();
}, [user, refreshAll]);
// The demo database was reset: drop cached answers of the old data, remount the views (they load
// fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs).
const handleDemoReset = useCallback(() => {
if (Date.now() - lastDemoResetRef.current < 30000) return; // event and countdown may both report it
lastDemoResetRef.current = Date.now();
void clearOfflineData();
setDataEpoch((n) => n + 1);
refreshAll();
api.getAuthStatus().then(setAuthStatus).catch(() => {});
setDemoNotice('Demo-Instanz wurde zurückgesetzt');
window.setTimeout(() => setDemoNotice(null), 4000);
}, [refreshAll]);
handleDemoResetRef.current = handleDemoReset;
// Countdown reached zero but no DEMO_RESET arrived (e.g. long polling): ask the server shortly after
const handleDemoCountdownElapsed = useCallback(() => {
window.setTimeout(() => {
api
.getAuthStatus()
.then((st) => {
if ((st.demoResetInSeconds ?? 0) > 60) handleDemoResetRef.current();
else setAuthStatus(st);
})
.catch(() => {});
}, 5000);
}, []);
// Refresh stats on task/doc/list real-time events (counters in the navigation). Task events also
// reload the triage deck: a note sorted on another device must leave this deck too.
useEffect(() => {
let deckTimer: number | undefined;
const reloadDeck = () => {
window.clearTimeout(deckTimer);
deckTimer = window.setTimeout(() => {
api.getTriageDeck().then((res) => setDeck(Array.isArray(res) ? res : [])).catch(() => {});
}, 300);
};
// Events missed while the tab slept (desktop in the background, connection dropped)
const onVisible = () => {
if (document.visibilityState === 'visible' && getToken()) refreshAll();
};
const handleWs = (e: any) => {
const msg = e.detail;
if (!msg) return;
if (msg.event === 'DEMO_RESET') {
handleDemoResetRef.current();
return;
}
if (
typeof msg.event === 'string' &&
['TASK_', 'DOC_', 'SPACE_', 'ITEM_', 'LIST_'].some((prefix) => msg.event.startsWith(prefix))
) {
api.getMe().then((res) => {
setStats(res.stats);
}).catch(() => {});
if (msg.event.startsWith('TASK_')) reloadDeck();
}
};
window.addEventListener('mindsift:ws', handleWs);
document.addEventListener('visibilitychange', onVisible);
return () => {
window.clearTimeout(deckTimer);
window.removeEventListener('mindsift:ws', handleWs);
document.removeEventListener('visibilitychange', onVisible);
};
}, [refreshAll]);
// Close profile dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowProfileMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleAuthenticated = async (loggedInUser: User) => {
setUser(loggedInUser);
setAuthStatus((prev) => ({ ...prev, needsSetup: false }));
setActiveTab('triage');
await refreshAll();
};
const handleCaptured = (
thought: Thought,
target: 'inbox' | 'task' | 'list_item' | 'document' = 'inbox'
) => {
if (target === 'inbox' && thought.status === 'inbox') {
setDeck((prev) => {
if (prev.some((t) => t.id === thought.id)) return prev;
return [thought, ...prev];
});
setStats((prev) => ({ ...prev, inboxCount: prev.inboxCount + 1 }));
} else if (target === 'document' && thought.convertedRefId) {
setSelectedDocId(thought.convertedRefId);
setActiveTab('workspace');
}
setRefreshRevision((r) => r + 1);
refreshAll();
};
const handleOpenDocument = (docId: string, blockId?: string) => {
setSelectedDocId(docId);
setSelectedBlockId(blockId || null);
setActiveTab('workspace');
};
const handleOpenTask = useCallback((taskId: string) => {
setSelectedTaskId(taskId);
setActiveTab('tasks');
}, []);
const handleOpenList = (listId: string) => {
setSelectedListId(listId);
setActiveTab('lists');
};
// Global Command Palette shortcuts: Cmd+K / Ctrl+K, and '/' when not in input
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const el = e.target as HTMLElement;
const isEditing =
el.tagName === 'INPUT' ||
el.tagName === 'TEXTAREA' ||
el.isContentEditable;
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setShowCommandPalette((prev) => !prev);
return;
}
if (!isEditing && e.key === '/') {
e.preventDefault();
setShowCommandPalette(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
const handleTriggerAddButton = () => {
triggerHaptic(20);
if (window.innerWidth < 640) {
openMobileTriageCapture();
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
setCaptureFocusCount((c) => c + 1);
}
};
const handleLogout = async () => {
triggerHaptic(20);
disconnectWebSocket();
try {
await api.logout();
} catch {
clearToken();
}
setUser(null);
setShowProfileMenu(false);
setActiveTab('triage');
try {
const st = await api.getAuthStatus();
setAuthStatus(st);
} catch {
// ignore
}
};
if (bootLoading) {
return (
Verbinde mit MindSift…
);
}
// Show Setup Wizard or Login Screen if needsSetup or not logged in
if (authStatus.needsSetup || !user || resetToken) {
return (
{
// The new password ended every session, this one included
setResetToken(null);
clearToken();
setUser(null);
}}
onResetCancel={() => setResetToken(null)}
/>
);
}
const isServerAdmin = user.role === 'admin';
const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied';
const activeTabLabelMap: Record = {
triage: 'Triage',
tasks: 'Aufgaben',
workspace: 'Workspace',
lists: 'Listen',
groups: 'Gruppen',
user_settings: 'Einstellungen',
admin_settings: 'Administration',
help: 'Hilfe',
};
return (
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
{/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */}
{authStatus.instanceName || 'MindSift'} als App installieren
Vollbild ohne Browser-Leiste, schnellerer Start & Direktzugriff vom Home-Bildschirm.
{!showManualPwaGuide ? (
) : (
{/* Chrome may report "installable" only after the guide opened (e.g. shortly after the
old app was removed): then the real install is one tap away again */}
{deferredPwaPrompt && (
)}
In 2 Sekunden zum Home-Bildschirm hinzufügen:
Tippe im Browser auf{' '}
Teilen
{' '}
(iPhone/Safari) oder das{' '}
Browser-Menü (⋮).