Files
MindSift/frontend/src/App.tsx
T
sandrodachandClaude Opus 5.5 9ed63281ed
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m44s
feat(demo): start the demo in the round Fjord Night theme
A visitor without a chosen theme on this device sees the demo in
fjord-night instead of the square default; a chosen theme stays and
real installations are unchanged.

Release v0.27.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:13:29 +01:00

1885 lines
73 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, 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 (
<div className="p-6 panel-surface text-xs space-y-3 max-w-lg mx-auto my-8">
<h3 className="text-sm font-bold text-main">Ansicht konnte nicht geladen werden</h3>
<p className="text-muted">{this.state.errorText}</p>
<button
onClick={() => {
this.setState({ hasError: false, errorText: '' });
this.props.onReset();
}}
className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>Ansicht zurücksetzen</span>
</button>
</div>
);
}
return this.props.children;
}
}
export const App: React.FC = () => {
const [activeTheme, setActiveThemeState] = useState<ThemeId>(() => initThemeOnBoot());
const [bootLoading, setBootLoading] = useState(true);
const [authStatus, setAuthStatus] = useState<AuthStatus>({
needsSetup: false,
allowRegistration: false,
instanceName: 'MindSift',
});
const [user, setUser] = useState<User | null>(null);
// Demo mode: when the next reset happens (local clock), and a counter that remounts all views after it
const [demoDeadline, setDemoDeadline] = useState<number | null>(null);
const [dataEpoch, setDataEpoch] = useState(0);
const [demoNotice, setDemoNotice] = useState<string | null>(null);
// Just shared from another app: these Quick Dump entries can still be sent on as task / list item
const [sharedImportIds, setSharedImportIds] = useState<string[] | null>(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<string | null>(() => {
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<UserTheme[]>([]);
const allThemes = useMemo(() => [...AVAILABLE_THEMES, ...customThemes.map(customThemeOption)], [customThemes]);
const [stats, setStats] = useState<DashboardStats>({
inboxCount: 0,
tasksOpenCount: 0,
tasksDoneCount: 0,
docsCount: 0,
docsTodosOpenCount: 0,
listItemsOpenCount: 0,
listsCount: 0,
discardedCount: 0,
});
const [deck, setDeck] = useState<Thought[]>([]);
const [lists, setLists] = useState<SharedList[]>([]);
const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [selectedListId, setSelectedListId] = useState<string | null>(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<HTMLDivElement | null>(null);
// Mobile Navigation & Quick-Action State
const [showMobileCapture, setShowMobileCapture] = useState(false);
const [showMobileProfile, setShowMobileProfile] = useState(false);
const [mobileTriageContent, setMobileTriageContent] = useState('');
const [mobileTriagePriority, setMobileTriagePriority] = useState<PriorityLevel>('normal');
const [mobileTriageDueDate, setMobileTriageDueDate] = useState('');
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
const [mobileVoiceError, setMobileVoiceError] = useState<string | null>(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<ActiveTab | null>(() => {
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<string, ActiveTab>)[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: <Plus className="w-4 h-4" />,
variant: 'accent',
onClick: () => openMobileTriageCapture(),
},
{
id: 'refresh-deck',
label: 'Inbox aktualisieren',
icon: <RotateCcw className="w-4 h-4" />,
badge: stats.inboxCount,
onClick: () => refreshAll(),
},
],
},
{
id: 'triage-nav',
title: 'Bereiche wechseln',
items: [
{
id: 'goto-workspace',
label: 'Notizen & Workspace',
icon: <FileText className="w-4 h-4" />,
badge: stats.docsCount,
onClick: () => setActiveTab('workspace'),
},
{
id: 'goto-tasks',
label: 'Aufgaben & Kanban',
icon: <CheckSquare className="w-4 h-4" />,
badge: stats.tasksOpenCount,
onClick: () => setActiveTab('tasks'),
},
{
id: 'goto-lists',
label: 'Einkaufslisten',
icon: <ShoppingCart className="w-4 h-4" />,
badge: stats.listsCount,
onClick: () => setActiveTab('lists'),
},
...(isSharingDisabled()
? []
: [
{
id: 'goto-groups',
label: 'Gruppen',
icon: <Users className="w-4 h-4" />,
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: <Layers className="w-4 h-4" />,
variant: 'accent',
onClick: () => setActiveTab('triage'),
},
{
id: 'goto-workspace',
label: 'Zum Workspace',
icon: <FileText className="w-4 h-4" />,
onClick: () => setActiveTab('workspace'),
},
{
id: 'goto-tasks',
label: 'Zu den Aufgaben',
icon: <CheckSquare className="w-4 h-4" />,
onClick: () => setActiveTab('tasks'),
},
{
id: 'goto-lists',
label: 'Zu den Listen',
icon: <ShoppingCart className="w-4 h-4" />,
onClick: () => setActiveTab('lists'),
},
],
},
]);
}
}, [activeTab, stats.inboxCount, stats.tasksOpenCount, stats.docsCount, stats.listsCount]);
// Mobile Web-App (PWA) Installation State
const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true
);
});
const [deferredPwaPrompt, setDeferredPwaPrompt] = useState<any>(() =>
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 (
<div className="min-h-screen bg-canvas text-muted flex items-center justify-center text-xs font-sans">
Verbinde mit MindSift…
</div>
);
}
// Show Setup Wizard or Login Screen if needsSetup or not logged in
if (authStatus.needsSetup || !user || resetToken) {
return (
<SetupAndAuthView
authStatus={authStatus}
onAuthenticated={handleAuthenticated}
resetToken={resetToken}
onResetDone={() => {
// 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<ActiveTab, string> = {
triage: 'Triage',
tasks: 'Aufgaben',
workspace: 'Workspace',
lists: 'Listen',
groups: 'Gruppen',
user_settings: 'Einstellungen',
admin_settings: 'Administration',
help: 'Hilfe',
};
return (
<div
className={`bg-canvas text-main flex flex-col font-sans selection:bg-accent/30 ${
activeTab === 'workspace' ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'
}`}
>
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
<header className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm">
{/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */}
<div className="sm:hidden flex items-center justify-between h-14 px-4 pt-1">
<button
type="button"
onClick={() => {
triggerHaptic(12);
setIsMenuOpen((prev) => !prev);
}}
className={`min-h-[44px] px-2.5 -ml-2 rounded-xl flex items-center gap-2.5 cursor-pointer select-none active:scale-95 transition-all ${
isMenuOpen ? 'bg-panel border border-accent/40 shadow-sm' : 'hover:bg-panel/60'
}`}
title="Menü & Ansichts-Optionen öffnen"
>
<div className="w-8 h-8 bg-accent text-on-accent rounded-xl shadow-sm flex items-center justify-center flex-shrink-0">
<Brain className="w-4 h-4" />
</div>
<div className="flex items-center gap-1.5">
<div className="flex flex-col">
<span className="font-bold text-base text-main tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</span>
{demoDeadline ? (
<DemoCountdown
deadline={demoDeadline}
minutes={authStatus.demoResetMinutes}
onElapsed={handleDemoCountdownElapsed}
className="text-[9px] mt-0.5"
/>
) : (
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
)}
</div>
<ChevronDown
className={`w-4 h-4 text-muted transition-transform duration-150 ${
isMenuOpen ? 'rotate-180 text-accent' : ''
}`}
/>
</div>
</button>
<div className="flex items-center gap-1.5">
{!isStandaloneApp && (
<button
type="button"
onClick={() => {
triggerHaptic(10);
setShowManualPwaGuide(false);
setShowPwaInstallModal(true);
}}
className="h-9 px-2.5 rounded-xl bg-accent/15 border border-accent/40 text-accent text-xs font-semibold flex items-center gap-1.5 active:scale-95 transition-transform cursor-pointer"
title="Als Web-App auf dem Smartphone installieren"
>
<Download className="w-3.5 h-3.5" />
<span>App</span>
</button>
)}
<button
type="button"
onClick={() => {
triggerHaptic(10);
setShowCommandPalette(true);
}}
className="w-11 h-11 rounded-full flex items-center justify-center text-muted hover:text-main transition-transform active:scale-95 cursor-pointer"
title="Globale Suche"
aria-label="Globale Suche öffnen"
>
{/* Same visible size as the avatar next to it; the 44px button stays the touch target */}
<span className="w-9 h-9 rounded-full flex items-center justify-center bg-panel border border-border">
<Search className="w-4 h-4" />
</span>
</button>
<button
type="button"
onClick={() => {
triggerHaptic(10);
setShowMobileProfile(true);
}}
className={`w-11 h-11 -mr-1.5 rounded-full flex items-center justify-center transition-transform active:scale-95 cursor-pointer ${
activeTab === 'user_settings' || activeTab === 'admin_settings' || activeTab === 'help'
? 'ring-2 ring-accent'
: ''
}`}
title="Profil & Einstellungen"
>
<span className="relative">
<UserAvatar user={user} size="md" className="shadow-sm" />
{/* Connection status like in a chat app: green online, red without server */}
<span
role="status"
aria-label={serverOnline ? 'Online' : 'Offline – keine Verbindung zum Server'}
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full ring-2 ring-canvas ${
serverOnline ? 'bg-status-ok' : 'bg-status-err'
}`}
/>
</span>
</button>
</div>
</div>
{/* Desktop Header: 3-column Layout (Logo, InstantCaptureBar, Profile Pill) */}
<div className="hidden sm:flex max-w-6xl mx-auto px-4 py-2 items-start justify-between gap-6">
{/* Logo & Headline */}
<div
onClick={() => {
setActiveTab('triage');
handleTriggerAddButton();
}}
className="h-9 flex items-center gap-2.5 cursor-pointer group select-none flex-shrink-0"
>
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
<Brain className="w-4 h-4" />
</div>
<div className="flex flex-col">
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</span>
{demoDeadline ? (
<DemoCountdown
deadline={demoDeadline}
minutes={authStatus.demoResetMinutes}
onElapsed={handleDemoCountdownElapsed}
className="text-[10px] mt-0.5"
/>
) : (
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
)}
</div>
</div>
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
<div className="flex-1">
<InstantCaptureBar
onCaptured={handleCaptured}
lists={lists}
autoFocusTrigger={captureFocusCount}
/>
</div>
<button
type="button"
onClick={() => setShowCommandPalette(true)}
className="w-8 h-8 rounded-full bg-panel border border-border text-muted hover:text-main flex items-center justify-center transition-colors cursor-pointer shrink-0"
title="Globale Suche (Strg+K / ⌘K oder /)"
aria-label="Globale Suche (Strg+K / ⌘K oder /)"
>
<Search className="w-3.5 h-3.5" />
</button>
</div>
{/* User Profile Pill & Dropdown Menu */}
<div className="relative h-9 flex items-center gap-2 flex-shrink-0" ref={dropdownRef}>
<button
onClick={() => {
triggerHaptic(10);
setShowProfileMenu((prev) => !prev);
}}
className="h-8 flex items-center gap-2 text-xs font-medium text-main hover:opacity-85 transition-opacity cursor-pointer group"
title="Benutzermenü öffnen"
>
<UserAvatar user={user} size="sm" />
<span className="flex flex-col items-start leading-tight">
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
{user.displayName}
</span>
{!serverOnline && (
<span
role="status"
title="Keine Verbindung zum Server – Änderungen werden später synchronisiert"
className="hidden sm:inline-flex items-center gap-1 text-[10px] font-normal text-muted"
>
<span className="w-1.5 h-1.5 rounded-full bg-status-err" />
offline
</span>
)}
</span>
<ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" />
</button>
{showProfileMenu && (
<div className="absolute right-0 top-full mt-1.5 w-64 panel-surface shadow-panel overflow-hidden z-50 divide-y divide-border text-xs origin-top-right animate-in fade-in zoom-in-95 slide-in-from-top-1 duration-150">
{/* User Info Header */}
<div className="p-3 bg-canvas flex items-center gap-2.5">
<UserAvatar user={user} size="md" className="border border-border/70" />
<div className="min-w-0 flex-1">
<p className="font-bold text-main truncate">{user.displayName}</p>
<p className="text-[11px] text-muted truncate">{user.email}</p>
<span className="inline-block mt-1 px-1.5 py-0.5 bg-panel border border-border rounded-btn text-[10px] text-accent font-semibold">
{roleLabel}
</span>
</div>
</div>
{/* Theme Quick Switcher */}
<div className="p-2.5 space-y-1.5">
<span className="text-[10px] font-semibold text-muted uppercase tracking-wider flex items-center gap-1.5 px-1">
<Palette className="w-3 h-3 text-accent" />
<span>Design-Theme</span>
</span>
<ThemeQuickPicker activeTheme={activeTheme} themes={allThemes} onChange={handleChangeTheme} />
</div>
{/* Menu Items */}
<div className="py-1">
<button
onClick={() => {
triggerHaptic(10);
setShowProfileMenu(false);
setActiveTab('user_settings');
}}
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
activeTab === 'user_settings' ? 'text-accent font-semibold' : 'text-main'
}`}
>
<Settings className="w-4 h-4 text-accent" />
<span>Kontoeinstellungen</span>
</button>
{!isSharingDisabled() && (
<button
onClick={() => {
triggerHaptic(10);
setShowProfileMenu(false);
setActiveTab('groups');
}}
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
activeTab === 'groups' ? 'text-accent font-semibold' : 'text-main'
}`}
>
<Users className="w-4 h-4 text-accent" />
<span>Gruppen</span>
</button>
)}
<button
onClick={handleOpenHelp}
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
activeTab === 'help' ? 'text-accent font-semibold' : 'text-main'
}`}
>
<HelpCircle className="w-4 h-4 text-accent" />
<span>Hilfe & Anleitung</span>
</button>
{isServerAdmin && (
<button
onClick={() => {
triggerHaptic(10);
setShowProfileMenu(false);
setActiveTab('admin_settings');
}}
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
activeTab === 'admin_settings'
? 'text-accent font-semibold'
: 'text-main'
}`}
>
<Shield className="w-4 h-4 text-accent" />
<span>Administration</span>
</button>
)}
</div>
{/* Logout */}
<div className="py-1">
<button
onClick={handleLogout}
className="w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 text-status-err hover:bg-status-err-bg transition-colors"
>
<LogOut className="w-4 h-4" />
<span>Abmelden</span>
</button>
</div>
</div>
)}
</div>
</div>
{/* Desktop Navigation tabs attached to header */}
<nav className="hidden sm:flex max-w-6xl mx-auto px-2 border-t border-border items-stretch justify-between">
<div className="flex items-stretch overflow-x-auto no-scrollbar">
<button
onClick={() => {
triggerHaptic(10);
setActiveTab('triage');
}}
className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`}
>
<Layers className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-3' : ''}`}>
<span>Triage</span>
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
</span>
</button>
<button
onClick={() => {
triggerHaptic(10);
setActiveTab('workspace');
}}
className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`}
>
<FileText className="w-4 h-4" />
<span>Workspace</span>
</button>
<button
onClick={() => {
triggerHaptic(10);
setActiveTab('tasks');
}}
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
>
<CheckSquare className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-3' : ''}`}>
<span>Aufgaben</span>
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
</span>
</button>
<button
onClick={() => {
triggerHaptic(10);
setActiveTab('lists');
}}
className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`}
>
<ShoppingCart className="w-4 h-4" />
<span className={`relative inline-flex items-center ${(stats.listItemsOpenCount ?? 0) > 0 ? 'pr-3' : ''}`}>
<span>Listen</span>
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
</span>
</button>
</div>
{/* Borderless Circled Question Mark Help Button in Tab Bar */}
<div className="flex items-center pr-2 flex-shrink-0">
<button
type="button"
onClick={handleOpenHelp}
className={`p-1.5 transition-colors cursor-pointer rounded-full ${
activeTab === 'help'
? 'text-accent'
: 'text-muted hover:text-main'
}`}
title="Hilfe & Dokumentation"
>
<HelpCircle className="w-4 h-4" />
</button>
</div>
</nav>
</header>
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
<OfflineBanner />
{sharedImportIds && (
<SharedImportToast
thoughtIds={sharedImportIds}
lists={lists}
onClose={closeSharedImport}
onConverted={(message) => {
setSharedImportIds(null);
setDemoNotice(message);
setTimeout(() => setDemoNotice(null), 3000);
refreshAll();
}}
/>
)}
{demoNotice && (
<div
role="status"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] px-3 py-1.5 rounded-full border border-accent/40 bg-panel-elevated text-main text-[11px] font-medium shadow-panel"
>
✨ {demoNotice}
</div>
)}
{/* Main Container */}
{/* The workspace fills the window exactly and scrolls only inside the note, never the page */}
<main
className={`flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 ${
activeTab === 'workspace' ? 'min-h-0 flex flex-col sm:pb-0' : 'sm:pb-6'
}`}
>
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
<section key={dataEpoch} className={activeTab === 'workspace' ? 'flex-1 min-h-0 flex flex-col' : undefined}>
{activeTab === 'triage' && (
<TriageDeck
deck={deck}
lists={lists}
onDeckChange={refreshAll}
onOpenDocument={handleOpenDocument}
onFocusCapture={handleTriggerAddButton}
/>
)}
{activeTab === 'tasks' && (
<TasksView
currentUser={user}
onRefreshStats={refreshAll}
onOpenDocument={handleOpenDocument}
refreshTrigger={refreshRevision}
initialSelectedTaskId={selectedTaskId}
openTasksCount={stats.tasksOpenCount}
/>
)}
{activeTab === 'workspace' && (
<WorkspaceView
currentUser={user}
initialDocId={selectedDocId}
initialBlockId={selectedBlockId}
onStatsChange={refreshAll}
onOpenTask={handleOpenTask}
onOpenSearch={() => setShowCommandPalette(true)}
/>
)}
{activeTab === 'help' && (
<HelpView onBack={() => setActiveTab(previousTab || 'triage')} />
)}
{activeTab === 'lists' && (
<SharedListsView
onRefreshGlobal={refreshAll}
currentUser={user}
initialListId={selectedListId}
/>
)}
{activeTab === 'groups' && (
<GroupsView
currentUser={user}
onBack={() => setActiveTab(previousTab || 'triage')}
/>
)}
{activeTab === 'user_settings' && (
<UserSettingsView
currentUser={user}
activeTheme={activeTheme}
themes={allThemes}
customThemes={customThemes}
onThemeChange={handleChangeTheme}
onCustomThemesChange={handleCustomThemesChange}
onUserUpdated={(u) => {
setUser(u);
refreshAll();
}}
onBack={() => setActiveTab('triage')}
/>
)}
{activeTab === 'admin_settings' && isServerAdmin && (
<AdminSettingsView
currentUser={user}
onBack={() => setActiveTab('triage')}
/>
)}
</section>
</ViewErrorBoundary>
</main>
<AppFooter
legal={authStatus.legal}
onOpenAbout={() => setShowAbout(true)}
/>
{/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
{/* Col 1: Triage */}
<button
type="button"
onClick={() => {
triggerHaptic(10);
setActiveTab('triage');
}}
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
activeTab === 'triage' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
}`}
title="Triage"
>
<div className="relative">
<Layers className="w-5 h-5" />
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
</div>
<span className="text-[10px] leading-tight">Triage</span>
</button>
{/* Col 2: Workspace */}
<button
type="button"
onClick={() => {
triggerHaptic(10);
setActiveTab('workspace');
}}
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
activeTab === 'workspace' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
}`}
title="Workspace"
>
<FileText className="w-5 h-5" />
<span className="text-[10px] leading-tight">Workspace</span>
</button>
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
<div className="flex items-center justify-center">
<HoldToRecordFab
onTap={() => {
triggerHaptic(15);
if (primaryAction) {
primaryAction();
} else {
openMobileTriageCapture();
}
}}
onTranscript={async (text) => {
const thought = await api.captureThought(text, 'normal', 'voice', '', { target: 'inbox' });
handleCaptured(thought, 'inbox');
}}
/>
</div>
{/* Col 4: Aufgaben */}
<button
type="button"
onClick={() => {
triggerHaptic(10);
setActiveTab('tasks');
}}
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
activeTab === 'tasks' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
}`}
title="Aufgaben"
>
<div className="relative">
<CheckSquare className="w-5 h-5" />
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
</div>
<span className="text-[10px] leading-tight">Aufgaben</span>
</button>
{/* Col 5: Listen */}
<button
type="button"
onClick={() => {
triggerHaptic(10);
setActiveTab('lists');
}}
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
activeTab === 'lists' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
}`}
title="Listen"
>
<div className="relative">
<ShoppingCart className="w-5 h-5" />
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
</div>
<span className="text-[10px] leading-tight">Listen</span>
</button>
</nav>
{/* MOBILE QUICK-ADD BOTTOM SHEET / MODAL (DESIGN-ALIGNED WITH TASKS VIEW) */}
{showMobileCapture && (
<div
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
onClick={() => setShowMobileCapture(false)}
>
<div
className="w-full max-h-[85vh] overflow-y-auto bg-panel border-t border-border rounded-t-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] animate-in slide-in-from-bottom duration-200"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between pb-3 border-b border-border mb-3">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
<Layers className="w-4 h-4" />
</div>
<h3 className="text-sm font-semibold text-main">Quick Dump erfassen</h3>
</div>
<button
type="button"
onClick={() => setShowMobileCapture(false)}
className="p-1.5 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleMobileCreateTriage} className="space-y-3">
{/* Text Input with Voice Dictation */}
<div>
<label className="text-[11px] font-semibold text-muted block mb-1">
Gedanke / Quick Dump
</label>
<div className="relative">
<input
type="text"
autoFocus
value={mobileTriageContent}
onChange={(e) => setMobileTriageContent(e.target.value)}
placeholder={
mobileVoiceError
? mobileVoiceError
: mobileVoiceActive
? 'Ich höre zu…'
: mobileVoice.state === 'transcribing'
? 'Wird erkannt…'
: 'Was geht dir durch den Kopf?…'
}
className={
mobileVoiceActive && mobileVoice.mode === 'server'
? 'w-full input-field px-3 py-2 pr-24 text-sm'
: 'w-full input-field px-3 py-2 pr-10 text-sm'
}
/>
{mobileVoice.available && (
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-2">
{mobileVoiceActive && mobileVoice.mode === 'server' && (
<VoiceWave getLevel={mobileVoice.level} bars={7} className="h-4 text-accent" />
)}
<button
type="button"
onClick={toggleMobileVoice}
disabled={mobileVoice.state === 'transcribing'}
className={`w-9 h-9 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
mobileVoiceActive
? mobileVoice.mode === 'server'
? 'bg-status-err text-on-accent'
: 'bg-accent text-on-accent animate-pulse'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
title={mobileVoiceActive ? 'Aufnahme beenden' : 'Spracheingabe'}
>
{mobileVoice.state === 'transcribing' ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : mobileVoiceActive ? (
<MicOff className="w-4 h-4" />
) : (
<Mic className="w-4 h-4" />
)}
</button>
</div>
)}
</div>
</div>
{/* Priority - 4 Buttons (Identical to TasksView) */}
<div className="space-y-1.5 pt-1">
<label className="text-[11px] font-semibold text-muted block">Priorität</label>
<div className="grid grid-cols-4 gap-1.5">
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
const config: Record<
PriorityLevel,
{ label: string; icon?: React.ReactNode; activeClass: string }
> = {
low: {
label: 'Niedrig',
activeClass: 'bg-canvas border-strong text-main font-semibold shadow-xs',
},
normal: {
label: 'Normal',
activeClass: 'bg-accent/15 border-accent text-accent font-semibold shadow-xs',
},
high: {
label: 'Wichtig',
icon: <AlertTriangle className="w-3 h-3 text-status-warn" />,
activeClass:
'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs',
},
urgent: {
label: 'Dringend',
icon: <Flame className="w-3 h-3 text-status-err" />,
activeClass:
'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs',
},
};
const active = mobileTriagePriority === p;
const item = config[p];
return (
<button
key={p}
type="button"
onClick={() => {
triggerHaptic(10);
setMobileTriagePriority(p);
}}
className={`h-9 px-1 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
active
? item.activeClass
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
{item.icon}
<span className="truncate">{item.label}</span>
</button>
);
})}
</div>
</div>
{/* Due Date (Optional) */}
<div className="space-y-1 pt-1">
<label className="text-[11px] font-semibold text-muted flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-accent" />
<span>Fällig am (optional)</span>
</label>
<input
type="date"
value={mobileTriageDueDate}
onChange={(e) => setMobileTriageDueDate(e.target.value)}
className="w-full input-field px-3 py-2 text-xs"
/>
</div>
{/* Bottom Buttons */}
<div className="pt-2 flex justify-end gap-2">
<button
type="button"
onClick={() => setShowMobileCapture(false)}
className="btn-ghost h-10 px-4 text-xs cursor-pointer"
>
Abbrechen
</button>
<button
type="submit"
disabled={!mobileTriageContent.trim()}
className="btn-primary h-10 px-5 text-xs font-semibold disabled:opacity-40 cursor-pointer"
>
Gedanke erfassen
</button>
</div>
</form>
</div>
</div>
)}
{/* MOBILE USER PROFILE SHEET */}
{showMobileProfile && (
<div
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
onClick={() => setShowMobileProfile(false)}
>
<div
className="w-full max-h-[85vh] overflow-y-auto bg-panel border-t border-border rounded-t-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] space-y-4 animate-in slide-in-from-bottom duration-200 divide-y divide-border"
onClick={(e) => e.stopPropagation()}
>
{/* User Info Header */}
<div className="flex items-center justify-between pb-2">
<div className="flex items-center gap-3">
<UserAvatar user={user} size="lg" className="border border-border/80 shadow-sm" />
<div>
<p className="font-bold text-sm text-main">{user.displayName}</p>
<p className="text-xs text-muted">{user.email}</p>
<span className="inline-block mt-1 px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-[10px] text-accent font-semibold">
{roleLabel}
</span>
</div>
</div>
<button
type="button"
onClick={() => setShowMobileProfile(false)}
className="p-1 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Theme Quick Switcher */}
<div className="pt-3 space-y-2">
<span className="text-[11px] font-semibold text-muted uppercase tracking-wider flex items-center gap-1.5">
<Palette className="w-3.5 h-3.5 text-accent" />
<span>Design-Theme</span>
</span>
<ThemeQuickPicker activeTheme={activeTheme} themes={allThemes} onChange={handleChangeTheme} touch />
</div>
{/* Menu Items */}
<div className="pt-3 space-y-1">
{!isStandaloneApp && (
<button
type="button"
onClick={() => {
triggerHaptic(10);
setShowMobileProfile(false);
setShowManualPwaGuide(false);
setShowPwaInstallModal(true);
}}
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 bg-accent/10 border border-accent/30 text-accent font-semibold transition-colors text-xs cursor-pointer mb-1"
>
<Smartphone className="w-4 h-4 text-accent" />
<span>Web-App auf Smartphone installieren</span>
</button>
)}
<button
type="button"
onClick={() => {
triggerHaptic(10);
setShowMobileProfile(false);
setActiveTab('user_settings');
}}
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
activeTab === 'user_settings' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
}`}
>
<Settings className="w-4 h-4 text-accent" />
<span>Kontoeinstellungen</span>
</button>
{!isSharingDisabled() && (
<button
type="button"
onClick={() => {
triggerHaptic(10);
setShowMobileProfile(false);
setActiveTab('groups');
}}
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
activeTab === 'groups' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
}`}
>
<Users className="w-4 h-4 text-accent" />
<span>Gruppen</span>
</button>
)}
<button
type="button"
onClick={() => {
setShowMobileProfile(false);
handleOpenHelp();
}}
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
activeTab === 'help' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
}`}
>
<HelpCircle className="w-4 h-4 text-accent" />
<span>Hilfe & Anleitung</span>
</button>
<button
type="button"
onClick={() => {
triggerHaptic(10);
setShowMobileProfile(false);
setShowAbout(true);
}}
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer text-main"
>
<Brain className="w-4 h-4 text-accent" />
<span>Über MindSift</span>
</button>
{isServerAdmin && (
<button
type="button"
onClick={() => {
triggerHaptic(10);
setShowMobileProfile(false);
setActiveTab('admin_settings');
}}
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
activeTab === 'admin_settings' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
}`}
>
<Shield className="w-4 h-4 text-accent" />
<span>Administration</span>
</button>
)}
</div>
{/* Logout */}
<div className="pt-3">
<button
type="button"
onClick={() => {
setShowMobileProfile(false);
handleLogout();
}}
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 text-status-err hover:bg-status-err-bg transition-colors text-xs font-medium cursor-pointer"
>
<LogOut className="w-4 h-4" />
<span>Abmelden</span>
</button>
</div>
{/* Version & About Hint on Mobile */}
<div className="pt-2 text-center text-[10px] text-faint font-mono">
MindSift {APP_VERSION}
</div>
</div>
</div>
)}
{/* IMMEDIATE MOBILE WEB-APP INSTALLATION OFFER SHEET */}
{showPwaInstallModal && !isStandaloneApp && (
<div
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/65 backdrop-blur-xs animate-in fade-in duration-150"
onClick={handleDismissPwaModal}
>
<div
className="w-full bg-panel border-t border-border rounded-t-2xl shadow-panel p-5 pb-[calc(1.75rem+env(safe-area-inset-bottom))] space-y-4 animate-in slide-in-from-bottom duration-200"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-accent text-on-accent shadow-md flex items-center justify-center flex-shrink-0">
<Brain className="w-6 h-6" />
</div>
<div>
<h3 className="text-base font-bold text-main leading-tight">
{authStatus.instanceName || 'MindSift'} als App installieren
</h3>
<p className="text-xs text-muted mt-0.5">
Vollbild ohne Browser-Leiste, schnellerer Start & Direktzugriff vom Home-Bildschirm.
</p>
</div>
</div>
<button
type="button"
onClick={handleDismissPwaModal}
className="p-1.5 text-faint hover:text-main rounded-btn cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{!showManualPwaGuide ? (
<div className="space-y-2.5 pt-1">
<button
type="button"
onClick={handleInstallWebApp}
className="btn-primary w-full h-12 text-sm font-semibold rounded-xl flex items-center justify-center gap-2 shadow-md active:scale-98 transition-transform cursor-pointer"
>
<Download className="w-4 h-4" />
<span>Jetzt als Web-App installieren</span>
</button>
<button
type="button"
onClick={handleDismissPwaModal}
className="btn-action w-full h-10 text-xs rounded-xl justify-center cursor-pointer"
>
Später im Browser weiter nutzen
</button>
</div>
) : (
<div className="space-y-3 pt-1">
{/* 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 && (
<button
type="button"
onClick={handleInstallWebApp}
className="btn-primary w-full h-12 text-sm font-semibold rounded-xl flex items-center justify-center gap-2 shadow-md active:scale-98 transition-transform cursor-pointer"
>
<Download className="w-4 h-4" />
<span>Jetzt installieren</span>
</button>
)}
<div className="p-3.5 rounded-xl bg-canvas border border-border space-y-2.5 text-xs text-main">
<p className="font-semibold text-accent flex items-center gap-1.5">
<Smartphone className="w-4 h-4" />
<span>In 2 Sekunden zum Home-Bildschirm hinzufügen:</span>
</p>
<ol className="space-y-2 text-muted list-decimal list-inside">
<li>
Tippe im Browser auf{' '}
<strong className="text-main inline-flex items-center gap-1">
<Share className="w-3.5 h-3.5 text-accent inline" /> Teilen
</strong>{' '}
(iPhone/Safari) oder das{' '}
<strong className="text-main">Browser-Menü (⋮)</strong>.
</li>
<li>
Wähle{' '}
<strong className="text-main">„Zum Home-Bildschirm“</strong> bzw.{' '}
<strong className="text-main">„App installieren“</strong>.
</li>
</ol>
</div>
<button
type="button"
onClick={handleDismissPwaModal}
className="btn-primary w-full h-11 text-xs font-semibold rounded-xl justify-center cursor-pointer"
>
Verstanden
</button>
</div>
)}
</div>
</div>
)}
{/* Global Command Palette (Cmd+K / Ctrl+K / '/') */}
<CommandPalette
isOpen={showCommandPalette}
onClose={() => setShowCommandPalette(false)}
onOpenDocument={handleOpenDocument}
onOpenTask={handleOpenTask}
onOpenList={handleOpenList}
/>
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
</div>
);
};
export default App;