The service worker now caches the app shell: page loads are network first with the last index.html as fallback, hashed bundles come from the cache, icons and manifest are refreshed in the background; API calls are never intercepted. GET answers are kept per user in IndexedDB and served when the server cannot be reached, so the app starts logged in with the last known thoughts, tasks and lists. Captured thoughts and checked tasks or list items go to an outbox that is sent in order when the connection returns (also at start and every 30 s). A banner shows offline state and waiting changes. Logging out clears cache and outbox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1637 lines
62 KiB
TypeScript
1637 lines
62 KiB
TypeScript
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,
|
|
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 {
|
|
AVAILABLE_THEMES,
|
|
customThemeOption,
|
|
initThemeOnBoot,
|
|
isCustomThemeId,
|
|
setActiveTheme as applyTheme,
|
|
ThemeId,
|
|
} from './lib/theme';
|
|
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
|
|
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 { outboxCount } from './lib/offline';
|
|
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 { 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);
|
|
// 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,
|
|
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 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 mobileBaseVoiceRef = useRef('');
|
|
|
|
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 {
|
|
mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim();
|
|
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;
|
|
}
|
|
const base = mobileBaseVoiceRef.current;
|
|
setMobileTriageContent(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-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);
|
|
};
|
|
|
|
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(() => {});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [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]);
|
|
|
|
// 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, 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="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">
|
|
<div className="flex flex-col">
|
|
<span className="font-bold text-base text-main tracking-tight leading-none">
|
|
{authStatus.instanceName || 'MindSift'}
|
|
</span>
|
|
<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 bg-panel border border-border transition-transform active:scale-95 cursor-pointer"
|
|
title="Globale Suche"
|
|
aria-label="Globale Suche öffnen"
|
|
>
|
|
<Search className="w-4 h-4" />
|
|
</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>
|
|
<div className="flex flex-col">
|
|
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
|
{authStatus.instanceName || 'MindSift'}
|
|
</span>
|
|
<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="h-8 px-2.5 rounded-btn bg-panel border border-border text-muted hover:text-main flex items-center gap-1.5 text-xs transition-colors cursor-pointer shrink-0"
|
|
title="Globale Suche öffnen (Cmd+K oder /)"
|
|
>
|
|
<Search className="w-3.5 h-3.5" />
|
|
<span className="hidden lg:inline font-mono text-[10px] text-accent font-semibold">⌘K</span>
|
|
</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="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">
|
|
{allThemes.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>
|
|
|
|
{!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-2' : ''}`}>
|
|
<span>Triage</span>
|
|
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
|
</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>
|
|
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
|
</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>
|
|
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
|
</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]} />
|
|
<OfflineBanner />
|
|
|
|
{/* 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}
|
|
initialSelectedTaskId={selectedTaskId}
|
|
openTasksCount={stats.tasksOpenCount}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'workspace' && (
|
|
<WorkspaceView
|
|
currentUser={user}
|
|
initialDocId={selectedDocId}
|
|
initialBlockId={selectedBlockId}
|
|
onStatsChange={refreshAll}
|
|
onOpenTask={handleOpenTask}
|
|
/>
|
|
)}
|
|
|
|
{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>
|
|
|
|
{/* 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"
|
|
>
|
|
<div className="relative">
|
|
<FileText className="w-5 h-5" />
|
|
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
|
</div>
|
|
<span className="text-[10px] leading-tight">Workspace</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: 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"
|
|
>
|
|
<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">
|
|
{allThemes.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>
|
|
|
|
{!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>
|
|
|
|
{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>
|
|
)}
|
|
|
|
{/* Global Command Palette (Cmd+K / Ctrl+K / '/') */}
|
|
<CommandPalette
|
|
isOpen={showCommandPalette}
|
|
onClose={() => setShowCommandPalette(false)}
|
|
onOpenDocument={handleOpenDocument}
|
|
onOpenTask={handleOpenTask}
|
|
onOpenList={handleOpenList}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App;
|