Files
MindSift/frontend/src/App.tsx
T
sandrodach 50b017f387
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
fix(security): resolve FLAW-08 with token_version invalidation and jwt blacklist
2026-10-27 12:09:15 +01:00

1508 lines
57 KiB
TypeScript

import React, { useCallback, useEffect, 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,
} from 'lucide-react';
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User } from './lib/types';
import {
api,
clearToken,
getToken,
triggerHaptic,
connectWebSocket,
disconnectWebSocket,
} from './lib/api';
import {
AVAILABLE_THEMES,
initThemeOnBoot,
setActiveTheme as applyTheme,
ThemeId,
} from './lib/theme';
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
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 { 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';
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);
const [stats, setStats] = useState<DashboardStats>({
inboxCount: 0,
tasksOpenCount: 0,
tasksDoneCount: 0,
docsCount: 0,
docsTodosOpenCount: 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 [captureFocusCount, setCaptureFocusCount] = useState(0);
const [refreshRevision, setRefreshRevision] = useState(0);
// Profile Dropdown State
const [showProfileMenu, setShowProfileMenu] = 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 [mobileVoiceActive, setMobileVoiceActive] = useState(false);
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
const mobileRecognitionRef = useRef<any>(null);
const toggleMobileVoice = () => {
const SpeechRecognition =
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
alert('Spracheingabe wird von diesem Browser leider nicht unterstützt.');
return;
}
if (mobileVoiceActive && mobileRecognitionRef.current) {
mobileRecognitionRef.current.stop();
setMobileVoiceActive(false);
return;
}
try {
const recognition = new SpeechRecognition();
recognition.lang = 'de-DE';
recognition.continuous = false;
recognition.interimResults = true;
recognition.onstart = () => {
setMobileVoiceActive(true);
triggerHaptic(20);
};
recognition.onresult = (event: any) => {
let transcript = '';
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
setMobileTriageContent((prev) => {
const base = prev.replace(/\s*\[…\]$/, '').trim();
return base ? `${base} ${transcript}` : transcript;
});
setIsVoiceCaptured(true);
};
recognition.onerror = () => setMobileVoiceActive(false);
recognition.onend = () => setMobileVoiceActive(false);
mobileRecognitionRef.current = recognition;
recognition.start();
} catch {
setMobileVoiceActive(false);
}
};
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);
setMobileVoiceActive(false);
setShowMobileCapture(true);
};
const { isMenuOpen, setIsMenuOpen, closeMenu, setGroups, primaryAction, setPrimaryAction } = useMobileActions();
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-tasks',
label: 'Aufgaben & Kanban',
icon: <CheckSquare className="w-4 h-4" />,
badge: stats.tasksOpenCount,
onClick: () => setActiveTab('tasks'),
},
{
id: 'goto-workspace',
label: 'Notizen & Workspace',
icon: <FileText className="w-4 h-4" />,
badge: stats.docsCount,
onClick: () => setActiveTab('workspace'),
},
{
id: 'goto-lists',
label: 'Einkaufslisten',
icon: <ShoppingCart className="w-4 h-4" />,
badge: stats.listsCount,
onClick: () => setActiveTab('lists'),
},
{
id: 'goto-groups',
label: 'Teams & 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-tasks',
label: 'Zu den Aufgaben',
icon: <CheckSquare className="w-4 h-4" />,
onClick: () => setActiveTab('tasks'),
},
{
id: 'goto-workspace',
label: 'Zum Workspace',
icon: <FileText className="w-4 h-4" />,
onClick: () => setActiveTab('workspace'),
},
{
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 handleChangeTheme = (themeId: ThemeId) => {
triggerHaptic(10);
applyTheme(themeId);
setActiveThemeState(themeId);
};
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();
}
};
window.addEventListener('mindsift:unauthorized', onUnauthorized);
window.addEventListener('online', onOnline);
return () => {
window.removeEventListener('mindsift:unauthorized', onUnauthorized);
window.removeEventListener('online', onOnline);
};
}, []);
// WebSocket Connection Management
useEffect(() => {
if (user && getToken()) {
connectWebSocket();
} else {
disconnectWebSocket();
}
}, [user?.id]);
// Refresh stats on task/doc real-time events
useEffect(() => {
const handleWs = (e: any) => {
const msg = e.detail;
if (!msg) return;
if (
typeof msg.event === 'string' &&
(msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_') || msg.event.startsWith('SPACE_'))
) {
api.getMe().then((res) => {
setStats(res.stats);
}).catch(() => {});
}
};
window.addEventListener('mindsift:ws', handleWs);
return () => {
window.removeEventListener('mindsift:ws', handleWs);
};
}, []);
// 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) => {
setSelectedDocId(docId);
setActiveTab('workspace');
};
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) {
return (
<SetupAndAuthView
authStatus={authStatus}
onAuthenticated={handleAuthenticated}
/>
);
}
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: 'Teams & Gruppen',
user_settings: 'Einstellungen',
admin_settings: 'Administration',
help: 'Hilfe',
};
return (
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30">
{/* 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">
<span className="font-bold text-base text-main tracking-tight">
{authStatus.instanceName || 'MindSift'}
</span>
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
{activeTabLabelMap[activeTab]}
</span>
<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);
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"
>
<UserAvatar user={user} size="sm" className="shadow-sm" />
</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>
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</span>
</div>
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
<div className="flex-1 max-w-xl mx-auto">
<InstantCaptureBar
onCaptured={handleCaptured}
lists={lists}
autoFocusTrigger={captureFocusCount}
/>
</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="max-w-[130px] truncate hidden sm:inline text-main font-medium">
{user.displayName}
</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 z-50 divide-y divide-border text-xs">
{/* 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>
<div className="grid grid-cols-2 gap-1">
{AVAILABLE_THEMES.map((t) => (
<button
key={t.id}
type="button"
onClick={() => handleChangeTheme(t.id)}
className={`h-7 px-2 text-[11px] font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
activeTheme === t.id
? 'bg-panel-elevated border-accent text-main font-semibold'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span
className="w-2.5 h-2.5 border border-strong flex-shrink-0"
style={{ backgroundColor: t.previewBg }}
/>
<span className="truncate">{t.name.replace('MindSift ', '').split(' ')[0]}</span>
</button>
))}
</div>
</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>
<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-2' : ''}`}>
<span>Triage</span>
{stats.inboxCount > 0 && (
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
{stats.inboxCount}
</span>
)}
</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-2' : ''}`}>
<span>Aufgaben</span>
{stats.tasksOpenCount > 0 && (
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
{stats.tasksOpenCount}
</span>
)}
</span>
</button>
<button
onClick={() => {
triggerHaptic(10);
setActiveTab('workspace');
}}
className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`}
>
<FileText className="w-4 h-4" />
<span className={`relative inline-flex items-center ${(stats.docsTodosOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
<span>Workspace</span>
{(stats.docsTodosOpenCount ?? 0) > 0 && (
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
{stats.docsTodosOpenCount}
</span>
)}
</span>
</button>
<button
onClick={() => {
triggerHaptic(10);
setActiveTab('lists');
}}
className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`}
>
<ShoppingCart className="w-4 h-4" />
<span>Listen</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]} />
{/* Main Container */}
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
<section>
{activeTab === 'triage' && (
<TriageDeck
deck={deck}
lists={lists}
onDeckChange={refreshAll}
onOpenDocument={handleOpenDocument}
onFocusCapture={handleTriggerAddButton}
/>
)}
{activeTab === 'tasks' && (
<TasksView
currentUser={user}
onRefreshStats={refreshAll}
onOpenDocument={handleOpenDocument}
refreshTrigger={refreshRevision}
/>
)}
{activeTab === 'workspace' && (
<WorkspaceView
currentUser={user}
initialDocId={selectedDocId}
onStatsChange={refreshAll}
/>
)}
{activeTab === 'help' && (
<HelpView onBack={() => setActiveTab(previousTab || 'triage')} />
)}
{activeTab === 'lists' && (
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
)}
{activeTab === 'groups' && (
<GroupsView
currentUser={user}
onBack={() => setActiveTab(previousTab || 'triage')}
/>
)}
{activeTab === 'user_settings' && (
<UserSettingsView
currentUser={user}
activeTheme={activeTheme}
onThemeChange={handleChangeTheme}
onUserUpdated={(u) => {
setUser(u);
refreshAll();
}}
onBack={() => setActiveTab('triage')}
/>
)}
{activeTab === 'admin_settings' && isServerAdmin && (
<AdminSettingsView
currentUser={user}
onBack={() => setActiveTab('triage')}
/>
)}
</section>
</ViewErrorBoundary>
</main>
{/* 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" />
{stats.inboxCount > 0 && (
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
{stats.inboxCount}
</span>
)}
</div>
<span className="text-[10px] leading-tight">Triage</span>
</button>
{/* Col 2: 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" />
{stats.tasksOpenCount > 0 && (
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
{stats.tasksOpenCount}
</span>
)}
</div>
<span className="text-[10px] leading-tight">Aufgaben</span>
</button>
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
<div className="flex items-center justify-center">
<button
type="button"
onClick={() => {
triggerHaptic(15);
if (primaryAction) {
primaryAction();
} else {
openMobileTriageCapture();
}
}}
className="w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer"
title="Schnell erfassen (+)"
>
<Plus className="w-7 h-7 stroke-[2.5]" />
</button>
</div>
{/* Col 4: 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"
>
<div className="relative">
<FileText className="w-5 h-5" />
{(stats.docsTodosOpenCount ?? 0) > 0 && (
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
{stats.docsTodosOpenCount}
</span>
)}
</div>
<span className="text-[10px] leading-tight">Workspace</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"
>
<ShoppingCart className="w-5 h-5" />
<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="Was geht dir durch den Kopf?…"
className="w-full input-field px-3 py-2 pr-10 text-sm"
/>
<button
type="button"
onClick={toggleMobileVoice}
className={`absolute right-1.5 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
mobileVoiceActive
? 'bg-accent text-on-accent animate-pulse'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
title="Spracheingabe"
>
{mobileVoiceActive ? <MicOff className="w-3.5 h-3.5" /> : <Mic className="w-3.5 h-3.5" />}
</button>
</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>
<div className="grid grid-cols-2 gap-2">
{AVAILABLE_THEMES.map((t) => (
<button
key={t.id}
type="button"
onClick={() => handleChangeTheme(t.id)}
className={`h-9 px-2.5 text-xs font-medium border rounded-btn flex items-center gap-2 transition-colors cursor-pointer ${
activeTheme === t.id
? 'bg-panel-elevated border-accent text-main font-semibold'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span
className="w-3 h-3 border border-strong flex-shrink-0 rounded-full"
style={{ backgroundColor: t.previewBg }}
/>
<span className="truncate">{t.name.replace('MindSift ', '')}</span>
</button>
))}
</div>
</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>
<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>
{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>
</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">
<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>
)}
</div>
);
};
export default App;