1788 lines
68 KiB
TypeScript
1788 lines
68 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,
|
|
Loader2,
|
|
Search,
|
|
} from 'lucide-react';
|
|
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types';
|
|
import {
|
|
api,
|
|
clearToken,
|
|
getToken,
|
|
triggerHaptic,
|
|
connectWebSocket,
|
|
disconnectWebSocket,
|
|
isSharingDisabled,
|
|
} from './lib/api';
|
|
import { useVoiceInput } from './lib/useVoiceInput';
|
|
import { VoiceWave } from './components/VoiceWave';
|
|
import {
|
|
AVAILABLE_THEMES,
|
|
customThemeOption,
|
|
initThemeOnBoot,
|
|
watchSystemScheme,
|
|
isCustomThemeId,
|
|
setActiveTheme as applyTheme,
|
|
ThemeId,
|
|
} from './lib/theme';
|
|
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
|
|
import { HoldToRecordFab } from './features/capture/HoldToRecordFab';
|
|
import { CountBadge } from './components/CountBadge';
|
|
import { TriageDeck } from './features/triage/TriageDeck';
|
|
import { TasksView } from './features/triage/TasksView';
|
|
import { WorkspaceView } from './features/workspace/WorkspaceView';
|
|
import { SharedListsView } from './features/lists/SharedListsView';
|
|
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
|
import { OfflineBanner } from './components/OfflineBanner';
|
|
import { outboxCount, clearOfflineData } from './lib/offline';
|
|
import { DemoCountdown } from './components/DemoCountdown';
|
|
import { UserSettingsView } from './features/settings/UserSettingsView';
|
|
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
|
import { HelpView } from './features/help/HelpView';
|
|
import { GroupsView } from './features/teams/GroupsView';
|
|
import { useMobileActions } from './context/MobileActionContext';
|
|
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
|
import { UserAvatar } from './components/UserAvatar';
|
|
import { CommandPalette } from './components/CommandPalette';
|
|
import { AppFooter } from './components/AppFooter';
|
|
import { ThemeQuickPicker } from './components/ThemeQuickPicker';
|
|
import { AboutModal } from './components/AboutModal';
|
|
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
|
|
import { APP_VERSION } from './version';
|
|
|
|
type ActiveTab =
|
|
| 'triage'
|
|
| 'tasks'
|
|
| 'workspace'
|
|
| 'lists'
|
|
| 'groups'
|
|
| 'user_settings'
|
|
| 'admin_settings'
|
|
| 'help';
|
|
|
|
interface ErrorBoundaryState {
|
|
hasError: boolean;
|
|
errorText: string;
|
|
}
|
|
|
|
class ViewErrorBoundary extends React.Component<
|
|
{ children: React.ReactNode; onReset: () => void },
|
|
ErrorBoundaryState
|
|
> {
|
|
constructor(props: { children: React.ReactNode; onReset: () => void }) {
|
|
super(props);
|
|
this.state = { hasError: false, errorText: '' };
|
|
}
|
|
|
|
static getDerivedStateFromError(err: Error): ErrorBoundaryState {
|
|
return { hasError: true, errorText: err?.message || 'Unbekannter Darstellungsfehler' };
|
|
}
|
|
|
|
render() {
|
|
if (this.state.hasError) {
|
|
return (
|
|
<div className="p-6 panel-surface text-xs space-y-3 max-w-lg mx-auto my-8">
|
|
<h3 className="text-sm font-bold text-main">Ansicht konnte nicht geladen werden</h3>
|
|
<p className="text-muted">{this.state.errorText}</p>
|
|
<button
|
|
onClick={() => {
|
|
this.setState({ hasError: false, errorText: '' });
|
|
this.props.onReset();
|
|
}}
|
|
className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5"
|
|
>
|
|
<RotateCcw className="w-3.5 h-3.5" />
|
|
<span>Ansicht zurücksetzen</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
export const App: React.FC = () => {
|
|
const [activeTheme, setActiveThemeState] = useState<ThemeId>(() => initThemeOnBoot());
|
|
const [bootLoading, setBootLoading] = useState(true);
|
|
const [authStatus, setAuthStatus] = useState<AuthStatus>({
|
|
needsSetup: false,
|
|
allowRegistration: false,
|
|
instanceName: 'MindSift',
|
|
});
|
|
const [user, setUser] = useState<User | null>(null);
|
|
// Demo mode: when the next reset happens (local clock), and a counter that remounts all views after it
|
|
const [demoDeadline, setDemoDeadline] = useState<number | null>(null);
|
|
const [dataEpoch, setDataEpoch] = useState(0);
|
|
const [demoNotice, setDemoNotice] = useState<string | null>(null);
|
|
const lastDemoResetRef = useRef(0);
|
|
const handleDemoResetRef = useRef<() => void>(() => {});
|
|
// Link from the password reset mail (?resetToken=…); removed from the address bar right away
|
|
const [resetToken, setResetToken] = useState<string | null>(() => {
|
|
try {
|
|
return new URLSearchParams(window.location.search).get('resetToken');
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
useEffect(() => {
|
|
if (resetToken) window.history.replaceState(null, '', window.location.pathname);
|
|
}, [resetToken]);
|
|
const [customThemes, setCustomThemes] = useState<UserTheme[]>([]);
|
|
const allThemes = useMemo(() => [...AVAILABLE_THEMES, ...customThemes.map(customThemeOption)], [customThemes]);
|
|
const [stats, setStats] = useState<DashboardStats>({
|
|
inboxCount: 0,
|
|
tasksOpenCount: 0,
|
|
tasksDoneCount: 0,
|
|
docsCount: 0,
|
|
docsTodosOpenCount: 0,
|
|
listItemsOpenCount: 0,
|
|
listsCount: 0,
|
|
discardedCount: 0,
|
|
});
|
|
const [deck, setDeck] = useState<Thought[]>([]);
|
|
const [lists, setLists] = useState<SharedList[]>([]);
|
|
const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
|
|
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
|
|
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
|
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
|
const [selectedListId, setSelectedListId] = useState<string | null>(null);
|
|
const [showCommandPalette, setShowCommandPalette] = useState(false);
|
|
const [captureFocusCount, setCaptureFocusCount] = useState(0);
|
|
const [refreshRevision, setRefreshRevision] = useState(0);
|
|
|
|
// Profile Dropdown State
|
|
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
|
const [showAbout, setShowAbout] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
// Mobile Navigation & Quick-Action State
|
|
const [showMobileCapture, setShowMobileCapture] = useState(false);
|
|
const [showMobileProfile, setShowMobileProfile] = useState(false);
|
|
const [mobileTriageContent, setMobileTriageContent] = useState('');
|
|
const [mobileTriagePriority, setMobileTriagePriority] = useState<PriorityLevel>('normal');
|
|
const [mobileTriageDueDate, setMobileTriageDueDate] = useState('');
|
|
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
|
|
const [mobileVoiceError, setMobileVoiceError] = useState<string | null>(null);
|
|
const mobileBaseVoiceRef = useRef('');
|
|
// Voice input of the mobile capture sheet: own Whisper server or the browser (lib/voice.ts)
|
|
const mobileVoice = useVoiceInput({
|
|
onText: (text) => {
|
|
if (!text) return;
|
|
const base = mobileBaseVoiceRef.current;
|
|
setMobileTriageContent(base ? `${base} ${text}` : text);
|
|
setIsVoiceCaptured(true);
|
|
},
|
|
onError: (message) => {
|
|
setMobileVoiceError(message);
|
|
window.setTimeout(() => setMobileVoiceError(null), 4000);
|
|
},
|
|
});
|
|
const mobileVoiceActive = mobileVoice.state === 'listening';
|
|
|
|
const toggleMobileVoice = () => {
|
|
if (mobileVoice.state === 'idle') {
|
|
mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim();
|
|
setMobileVoiceError(null);
|
|
triggerHaptic(20);
|
|
}
|
|
mobileVoice.toggle();
|
|
};
|
|
|
|
const handleMobileCreateTriage = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const trimmed = mobileTriageContent.trim();
|
|
if (!trimmed) return;
|
|
try {
|
|
const thought = await api.captureThought(
|
|
trimmed,
|
|
mobileTriagePriority,
|
|
isVoiceCaptured ? 'voice' : 'manual',
|
|
mobileTriageDueDate,
|
|
{ target: 'inbox' }
|
|
);
|
|
handleCaptured(thought, 'inbox');
|
|
setMobileTriageContent('');
|
|
setMobileTriagePriority('normal');
|
|
setMobileTriageDueDate('');
|
|
setIsVoiceCaptured(false);
|
|
setShowMobileCapture(false);
|
|
triggerHaptic(20);
|
|
} catch (err: any) {
|
|
alert(`Fehler beim Speichern: ${err.message || 'Unbekannter Fehler'}`);
|
|
}
|
|
};
|
|
|
|
const openMobileTriageCapture = () => {
|
|
setMobileTriageContent('');
|
|
setMobileTriagePriority('normal');
|
|
setMobileTriageDueDate('');
|
|
setIsVoiceCaptured(false);
|
|
if (mobileVoice.state !== 'idle') mobileVoice.cancel();
|
|
setShowMobileCapture(true);
|
|
};
|
|
|
|
const { isMenuOpen, setIsMenuOpen, closeMenu, setGroups, primaryAction, setPrimaryAction } = useMobileActions();
|
|
|
|
useEffect(() => {
|
|
closeMenu();
|
|
if (activeTab === 'triage') {
|
|
setPrimaryAction(openMobileTriageCapture);
|
|
setGroups([
|
|
{
|
|
id: 'triage-actions',
|
|
title: 'Schnellaktionen',
|
|
items: [
|
|
{
|
|
id: 'new-thought',
|
|
label: 'Neuen Gedanken erfassen',
|
|
icon: <Plus className="w-4 h-4" />,
|
|
variant: 'accent',
|
|
onClick: () => openMobileTriageCapture(),
|
|
},
|
|
{
|
|
id: 'refresh-deck',
|
|
label: 'Inbox aktualisieren',
|
|
icon: <RotateCcw className="w-4 h-4" />,
|
|
badge: stats.inboxCount,
|
|
onClick: () => refreshAll(),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'triage-nav',
|
|
title: 'Bereiche wechseln',
|
|
items: [
|
|
{
|
|
id: 'goto-workspace',
|
|
label: 'Notizen & Workspace',
|
|
icon: <FileText className="w-4 h-4" />,
|
|
badge: stats.docsCount,
|
|
onClick: () => setActiveTab('workspace'),
|
|
},
|
|
{
|
|
id: 'goto-tasks',
|
|
label: 'Aufgaben & Kanban',
|
|
icon: <CheckSquare className="w-4 h-4" />,
|
|
badge: stats.tasksOpenCount,
|
|
onClick: () => setActiveTab('tasks'),
|
|
},
|
|
{
|
|
id: 'goto-lists',
|
|
label: 'Einkaufslisten',
|
|
icon: <ShoppingCart className="w-4 h-4" />,
|
|
badge: stats.listsCount,
|
|
onClick: () => setActiveTab('lists'),
|
|
},
|
|
...(isSharingDisabled()
|
|
? []
|
|
: [
|
|
{
|
|
id: 'goto-groups',
|
|
label: 'Gruppen',
|
|
icon: <Users className="w-4 h-4" />,
|
|
onClick: () => setActiveTab('groups'),
|
|
},
|
|
]),
|
|
],
|
|
},
|
|
]);
|
|
} else if (
|
|
activeTab === 'groups' ||
|
|
activeTab === 'user_settings' ||
|
|
activeTab === 'admin_settings' ||
|
|
activeTab === 'help'
|
|
) {
|
|
setGroups([
|
|
{
|
|
id: 'subview-nav',
|
|
title: 'Navigation',
|
|
items: [
|
|
{
|
|
id: 'back-triage',
|
|
label: 'Zurück zur Triage (Startseite)',
|
|
icon: <Layers className="w-4 h-4" />,
|
|
variant: 'accent',
|
|
onClick: () => setActiveTab('triage'),
|
|
},
|
|
{
|
|
id: 'goto-workspace',
|
|
label: 'Zum Workspace',
|
|
icon: <FileText className="w-4 h-4" />,
|
|
onClick: () => setActiveTab('workspace'),
|
|
},
|
|
{
|
|
id: 'goto-tasks',
|
|
label: 'Zu den Aufgaben',
|
|
icon: <CheckSquare className="w-4 h-4" />,
|
|
onClick: () => setActiveTab('tasks'),
|
|
},
|
|
{
|
|
id: 'goto-lists',
|
|
label: 'Zu den Listen',
|
|
icon: <ShoppingCart className="w-4 h-4" />,
|
|
onClick: () => setActiveTab('lists'),
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
}
|
|
}, [activeTab, stats.inboxCount, stats.tasksOpenCount, stats.docsCount, stats.listsCount]);
|
|
|
|
// Mobile Web-App (PWA) Installation State
|
|
const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => {
|
|
if (typeof window === 'undefined') return false;
|
|
return (
|
|
window.matchMedia('(display-mode: standalone)').matches ||
|
|
(window.navigator as any).standalone === true
|
|
);
|
|
});
|
|
const [deferredPwaPrompt, setDeferredPwaPrompt] = useState<any>(() =>
|
|
typeof window !== 'undefined' ? (window as any).__deferredPwaPrompt || null : null
|
|
);
|
|
const [showPwaInstallModal, setShowPwaInstallModal] = useState(false);
|
|
const [showManualPwaGuide, setShowManualPwaGuide] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const syncPwaPrompt = () => {
|
|
if ((window as any).__deferredPwaPrompt) {
|
|
setDeferredPwaPrompt((window as any).__deferredPwaPrompt);
|
|
}
|
|
};
|
|
const onBeforeInstallPrompt = (e: Event) => {
|
|
e.preventDefault();
|
|
(window as any).__deferredPwaPrompt = e;
|
|
setDeferredPwaPrompt(e);
|
|
};
|
|
const onAppInstalled = () => {
|
|
(window as any).__deferredPwaPrompt = null;
|
|
setDeferredPwaPrompt(null);
|
|
setIsStandaloneApp(true);
|
|
setShowPwaInstallModal(false);
|
|
setShowManualPwaGuide(false);
|
|
};
|
|
|
|
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt);
|
|
window.addEventListener('mindsift:pwa-ready', syncPwaPrompt);
|
|
window.addEventListener('appinstalled', onAppInstalled);
|
|
|
|
return () => {
|
|
window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt);
|
|
window.removeEventListener('mindsift:pwa-ready', syncPwaPrompt);
|
|
window.removeEventListener('appinstalled', onAppInstalled);
|
|
};
|
|
}, []);
|
|
|
|
// Immediately offer Web-App installation on mobile if not yet running as installed standalone app
|
|
useEffect(() => {
|
|
if (!user || isStandaloneApp) return;
|
|
if (window.innerWidth < 640 && !sessionStorage.getItem('mindsift_pwa_dismissed')) {
|
|
setShowPwaInstallModal(true);
|
|
}
|
|
}, [user?.id, isStandaloneApp]);
|
|
|
|
const handleInstallWebApp = async () => {
|
|
triggerHaptic(15);
|
|
const promptEvent = deferredPwaPrompt || (window as any).__deferredPwaPrompt;
|
|
if (promptEvent && typeof promptEvent.prompt === 'function') {
|
|
try {
|
|
await promptEvent.prompt();
|
|
const choice = await promptEvent.userChoice;
|
|
if (choice && choice.outcome === 'accepted') {
|
|
(window as any).__deferredPwaPrompt = null;
|
|
setDeferredPwaPrompt(null);
|
|
setShowPwaInstallModal(false);
|
|
setShowManualPwaGuide(false);
|
|
}
|
|
} catch {
|
|
setShowManualPwaGuide(true);
|
|
}
|
|
} else {
|
|
setShowManualPwaGuide(true);
|
|
}
|
|
};
|
|
|
|
const handleDismissPwaModal = () => {
|
|
triggerHaptic(10);
|
|
sessionStorage.setItem('mindsift_pwa_dismissed', '1');
|
|
setShowPwaInstallModal(false);
|
|
setShowManualPwaGuide(false);
|
|
};
|
|
|
|
const handleOpenHelp = () => {
|
|
triggerHaptic(10);
|
|
setPreviousTab((prev) => (activeTab === 'help' ? prev : activeTab));
|
|
setActiveTab('help');
|
|
setShowProfileMenu(false);
|
|
};
|
|
|
|
const activateTheme = (themeId: ThemeId, themes: UserTheme[]) => {
|
|
const css = themes.find((t) => 'custom:' + t.id === themeId)?.css;
|
|
applyTheme(themeId, css);
|
|
setActiveThemeState(isCustomThemeId(themeId) && css === undefined ? 'dark' : themeId);
|
|
};
|
|
|
|
// "Wie System": the theme follows the system's light/dark switch while the app is open
|
|
useEffect(() => watchSystemScheme(setActiveThemeState), []);
|
|
|
|
const handleChangeTheme = (themeId: ThemeId) => {
|
|
triggerHaptic(10);
|
|
activateTheme(themeId, customThemes);
|
|
};
|
|
|
|
// Custom themes live on the server; after login refresh them and the active one (it may have
|
|
// been edited or deleted on another device)
|
|
const handleCustomThemesChange = (themes: UserTheme[], activate?: ThemeId) => {
|
|
setCustomThemes(themes);
|
|
const next = activate ?? activeTheme;
|
|
if (activate || isCustomThemeId(next)) activateTheme(next, themes);
|
|
};
|
|
useEffect(() => {
|
|
if (!user?.id) return;
|
|
api
|
|
.getThemes()
|
|
.then((themes) => handleCustomThemesChange(themes))
|
|
.catch(() => {});
|
|
|
|
}, [user?.id]);
|
|
|
|
const refreshAll = useCallback(async () => {
|
|
try {
|
|
const [meRes, deckRes, listsRes] = await Promise.all([
|
|
api.getMe(),
|
|
api.getTriageDeck(),
|
|
api.getAllLists(),
|
|
]);
|
|
setUser(meRes.user);
|
|
setStats(meRes.stats);
|
|
setDeck(Array.isArray(deckRes) ? deckRes : []);
|
|
setLists(Array.isArray(listsRes) ? listsRes : []);
|
|
} catch {
|
|
// If token expired or invalid, api.ts dispatches mindsift:unauthorized
|
|
}
|
|
}, []);
|
|
|
|
const checkBootAndAuth = async () => {
|
|
setBootLoading(true);
|
|
try {
|
|
const st = await api.getAuthStatus();
|
|
setAuthStatus(st);
|
|
|
|
if (st.needsSetup) {
|
|
clearToken();
|
|
setUser(null);
|
|
return;
|
|
}
|
|
|
|
if (getToken()) {
|
|
await api.flushOfflineQueue();
|
|
await refreshAll();
|
|
} else {
|
|
setUser(null);
|
|
}
|
|
} catch {
|
|
// Server temporarily unreachable
|
|
} finally {
|
|
setBootLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
checkBootAndAuth();
|
|
|
|
const onUnauthorized = async () => {
|
|
setUser(null);
|
|
setShowProfileMenu(false);
|
|
try {
|
|
const st = await api.getAuthStatus();
|
|
setAuthStatus(st);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
const onOnline = async () => {
|
|
if (getToken()) {
|
|
const synced = await api.flushOfflineQueue();
|
|
if (synced > 0) refreshAll();
|
|
}
|
|
};
|
|
|
|
// Changes still waiting (server was unreachable although the device was online) are retried
|
|
const retry = window.setInterval(() => {
|
|
if (navigator.onLine && outboxCount() > 0) onOnline();
|
|
}, 30000);
|
|
|
|
window.addEventListener('mindsift:unauthorized', onUnauthorized);
|
|
window.addEventListener('online', onOnline);
|
|
return () => {
|
|
window.clearInterval(retry);
|
|
window.removeEventListener('mindsift:unauthorized', onUnauthorized);
|
|
window.removeEventListener('online', onOnline);
|
|
};
|
|
}, []);
|
|
|
|
// WebSocket Connection Management
|
|
useEffect(() => {
|
|
if (user && getToken()) {
|
|
connectWebSocket();
|
|
} else {
|
|
disconnectWebSocket();
|
|
}
|
|
}, [user?.id]);
|
|
|
|
useEffect(() => {
|
|
setDemoDeadline(
|
|
authStatus.demoMode && typeof authStatus.demoResetInSeconds === 'number'
|
|
? Date.now() + authStatus.demoResetInSeconds * 1000
|
|
: null
|
|
);
|
|
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
|
|
|
|
// Web Share Target: import shared thoughts from external apps
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
const params = new URLSearchParams(window.location.search);
|
|
const hasPendingShare = params.get('shared') === 'pending';
|
|
|
|
const processShares = async () => {
|
|
try {
|
|
const shares = await consumePendingShares();
|
|
if (shares.length === 0) return;
|
|
|
|
let importedCount = 0;
|
|
for (const share of shares) {
|
|
const content = formatSharedThought(share);
|
|
if (content) {
|
|
await api.captureThought(content, 'normal', 'manual', '', { target: 'inbox' });
|
|
importedCount++;
|
|
}
|
|
}
|
|
|
|
if (importedCount > 0) {
|
|
triggerHaptic(15);
|
|
setDemoNotice('Erfolgreich im Quick Dump abgelegt');
|
|
setTimeout(() => setDemoNotice(null), 4000);
|
|
refreshAll();
|
|
}
|
|
} catch (err) {
|
|
console.error('Fehler beim Import geteilter Daten:', err);
|
|
} finally {
|
|
if (hasPendingShare) {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete('shared');
|
|
window.history.replaceState({}, '', url.pathname + (url.search ? url.search : '') + url.hash);
|
|
}
|
|
}
|
|
};
|
|
|
|
processShares();
|
|
}, [user, refreshAll]);
|
|
|
|
// The demo database was reset: drop cached answers of the old data, remount the views (they load
|
|
// fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs).
|
|
const handleDemoReset = useCallback(() => {
|
|
if (Date.now() - lastDemoResetRef.current < 30000) return; // event and countdown may both report it
|
|
lastDemoResetRef.current = Date.now();
|
|
void clearOfflineData();
|
|
setDataEpoch((n) => n + 1);
|
|
refreshAll();
|
|
api.getAuthStatus().then(setAuthStatus).catch(() => {});
|
|
setDemoNotice('Demo-Instanz wurde zurückgesetzt');
|
|
window.setTimeout(() => setDemoNotice(null), 4000);
|
|
}, [refreshAll]);
|
|
handleDemoResetRef.current = handleDemoReset;
|
|
|
|
// Countdown reached zero but no DEMO_RESET arrived (e.g. long polling): ask the server shortly after
|
|
const handleDemoCountdownElapsed = useCallback(() => {
|
|
window.setTimeout(() => {
|
|
api
|
|
.getAuthStatus()
|
|
.then((st) => {
|
|
if ((st.demoResetInSeconds ?? 0) > 60) handleDemoResetRef.current();
|
|
else setAuthStatus(st);
|
|
})
|
|
.catch(() => {});
|
|
}, 5000);
|
|
}, []);
|
|
|
|
// Refresh stats on task/doc/list real-time events (counters in the navigation). Task events also
|
|
// reload the triage deck: a note sorted on another device must leave this deck too.
|
|
useEffect(() => {
|
|
let deckTimer: number | undefined;
|
|
const reloadDeck = () => {
|
|
window.clearTimeout(deckTimer);
|
|
deckTimer = window.setTimeout(() => {
|
|
api.getTriageDeck().then((res) => setDeck(Array.isArray(res) ? res : [])).catch(() => {});
|
|
}, 300);
|
|
};
|
|
// Events missed while the tab slept (desktop in the background, connection dropped)
|
|
const onVisible = () => {
|
|
if (document.visibilityState === 'visible' && getToken()) refreshAll();
|
|
};
|
|
const handleWs = (e: any) => {
|
|
const msg = e.detail;
|
|
if (!msg) return;
|
|
if (msg.event === 'DEMO_RESET') {
|
|
handleDemoResetRef.current();
|
|
return;
|
|
}
|
|
if (
|
|
typeof msg.event === 'string' &&
|
|
['TASK_', 'DOC_', 'SPACE_', 'ITEM_', 'LIST_'].some((prefix) => msg.event.startsWith(prefix))
|
|
) {
|
|
api.getMe().then((res) => {
|
|
setStats(res.stats);
|
|
}).catch(() => {});
|
|
if (msg.event.startsWith('TASK_')) reloadDeck();
|
|
}
|
|
};
|
|
window.addEventListener('mindsift:ws', handleWs);
|
|
document.addEventListener('visibilitychange', onVisible);
|
|
return () => {
|
|
window.clearTimeout(deckTimer);
|
|
window.removeEventListener('mindsift:ws', handleWs);
|
|
document.removeEventListener('visibilitychange', onVisible);
|
|
};
|
|
}, [refreshAll]);
|
|
|
|
// Close profile dropdown on outside click
|
|
useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
setShowProfileMenu(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const handleAuthenticated = async (loggedInUser: User) => {
|
|
setUser(loggedInUser);
|
|
setAuthStatus((prev) => ({ ...prev, needsSetup: false }));
|
|
setActiveTab('triage');
|
|
await refreshAll();
|
|
};
|
|
|
|
const handleCaptured = (
|
|
thought: Thought,
|
|
target: 'inbox' | 'task' | 'list_item' | 'document' = 'inbox'
|
|
) => {
|
|
if (target === 'inbox' && thought.status === 'inbox') {
|
|
setDeck((prev) => {
|
|
if (prev.some((t) => t.id === thought.id)) return prev;
|
|
return [thought, ...prev];
|
|
});
|
|
setStats((prev) => ({ ...prev, inboxCount: prev.inboxCount + 1 }));
|
|
} else if (target === 'document' && thought.convertedRefId) {
|
|
setSelectedDocId(thought.convertedRefId);
|
|
setActiveTab('workspace');
|
|
}
|
|
setRefreshRevision((r) => r + 1);
|
|
refreshAll();
|
|
};
|
|
|
|
const handleOpenDocument = (docId: string, blockId?: string) => {
|
|
setSelectedDocId(docId);
|
|
setSelectedBlockId(blockId || null);
|
|
setActiveTab('workspace');
|
|
};
|
|
|
|
const handleOpenTask = useCallback((taskId: string) => {
|
|
setSelectedTaskId(taskId);
|
|
setActiveTab('tasks');
|
|
}, []);
|
|
|
|
const handleOpenList = (listId: string) => {
|
|
setSelectedListId(listId);
|
|
setActiveTab('lists');
|
|
};
|
|
|
|
// Global Command Palette shortcuts: Cmd+K / Ctrl+K, and '/' when not in input
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const el = e.target as HTMLElement;
|
|
const isEditing =
|
|
el.tagName === 'INPUT' ||
|
|
el.tagName === 'TEXTAREA' ||
|
|
el.isContentEditable;
|
|
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
e.preventDefault();
|
|
setShowCommandPalette((prev) => !prev);
|
|
return;
|
|
}
|
|
|
|
if (!isEditing && e.key === '/') {
|
|
e.preventDefault();
|
|
setShowCommandPalette(true);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, []);
|
|
|
|
const handleTriggerAddButton = () => {
|
|
triggerHaptic(20);
|
|
if (window.innerWidth < 640) {
|
|
openMobileTriageCapture();
|
|
} else {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
setCaptureFocusCount((c) => c + 1);
|
|
}
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
triggerHaptic(20);
|
|
disconnectWebSocket();
|
|
try {
|
|
await api.logout();
|
|
} catch {
|
|
clearToken();
|
|
}
|
|
setUser(null);
|
|
setShowProfileMenu(false);
|
|
setActiveTab('triage');
|
|
try {
|
|
const st = await api.getAuthStatus();
|
|
setAuthStatus(st);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
if (bootLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-canvas text-muted flex items-center justify-center text-xs font-sans">
|
|
Verbinde mit MindSift…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show Setup Wizard or Login Screen if needsSetup or not logged in
|
|
if (authStatus.needsSetup || !user || resetToken) {
|
|
return (
|
|
<SetupAndAuthView
|
|
authStatus={authStatus}
|
|
onAuthenticated={handleAuthenticated}
|
|
resetToken={resetToken}
|
|
onResetDone={() => {
|
|
// The new password ended every session, this one included
|
|
setResetToken(null);
|
|
clearToken();
|
|
setUser(null);
|
|
}}
|
|
onResetCancel={() => setResetToken(null)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const isServerAdmin = user.role === 'admin';
|
|
const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied';
|
|
|
|
const activeTabLabelMap: Record<ActiveTab, string> = {
|
|
triage: 'Triage',
|
|
tasks: 'Aufgaben',
|
|
workspace: 'Workspace',
|
|
lists: 'Listen',
|
|
groups: 'Gruppen',
|
|
user_settings: 'Einstellungen',
|
|
admin_settings: 'Administration',
|
|
help: 'Hilfe',
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`bg-canvas text-main flex flex-col font-sans selection:bg-accent/30 ${
|
|
activeTab === 'workspace' ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'
|
|
}`}
|
|
>
|
|
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
|
|
<header className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm">
|
|
{/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */}
|
|
<div className="sm:hidden flex items-center justify-between h-14 px-4 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(12);
|
|
setIsMenuOpen((prev) => !prev);
|
|
}}
|
|
className={`min-h-[44px] px-2.5 -ml-2 rounded-xl flex items-center gap-2.5 cursor-pointer select-none active:scale-95 transition-all ${
|
|
isMenuOpen ? 'bg-panel border border-accent/40 shadow-sm' : 'hover:bg-panel/60'
|
|
}`}
|
|
title="Menü & Ansichts-Optionen öffnen"
|
|
>
|
|
<div className="w-8 h-8 bg-accent text-on-accent rounded-xl shadow-sm flex items-center justify-center flex-shrink-0">
|
|
<Brain className="w-4 h-4" />
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="flex flex-col">
|
|
<span className="font-bold text-base text-main tracking-tight leading-none">
|
|
{authStatus.instanceName || 'MindSift'}
|
|
</span>
|
|
{demoDeadline ? (
|
|
<DemoCountdown
|
|
deadline={demoDeadline}
|
|
minutes={authStatus.demoResetMinutes}
|
|
onElapsed={handleDemoCountdownElapsed}
|
|
className="text-[9px] mt-0.5"
|
|
/>
|
|
) : (
|
|
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
|
)}
|
|
</div>
|
|
<ChevronDown
|
|
className={`w-4 h-4 text-muted transition-transform duration-150 ${
|
|
isMenuOpen ? 'rotate-180 text-accent' : ''
|
|
}`}
|
|
/>
|
|
</div>
|
|
</button>
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
{!isStandaloneApp && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowManualPwaGuide(false);
|
|
setShowPwaInstallModal(true);
|
|
}}
|
|
className="h-9 px-2.5 rounded-xl bg-accent/15 border border-accent/40 text-accent text-xs font-semibold flex items-center gap-1.5 active:scale-95 transition-transform cursor-pointer"
|
|
title="Als Web-App auf dem Smartphone installieren"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
<span>App</span>
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowCommandPalette(true);
|
|
}}
|
|
className="w-11 h-11 rounded-full flex items-center justify-center text-muted hover:text-main transition-transform active:scale-95 cursor-pointer"
|
|
title="Globale Suche"
|
|
aria-label="Globale Suche öffnen"
|
|
>
|
|
{/* Same visible size as the avatar next to it; the 44px button stays the touch target */}
|
|
<span className="w-9 h-9 rounded-full flex items-center justify-center bg-panel border border-border">
|
|
<Search className="w-4 h-4" />
|
|
</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowMobileProfile(true);
|
|
}}
|
|
className={`w-11 h-11 -mr-1.5 rounded-full flex items-center justify-center transition-transform active:scale-95 cursor-pointer ${
|
|
activeTab === 'user_settings' || activeTab === 'admin_settings' || activeTab === 'help'
|
|
? 'ring-2 ring-accent'
|
|
: ''
|
|
}`}
|
|
title="Profil & Einstellungen"
|
|
>
|
|
<UserAvatar user={user} size="md" 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>
|
|
{demoDeadline ? (
|
|
<DemoCountdown
|
|
deadline={demoDeadline}
|
|
minutes={authStatus.demoResetMinutes}
|
|
onElapsed={handleDemoCountdownElapsed}
|
|
className="text-[10px] mt-0.5"
|
|
/>
|
|
) : (
|
|
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
|
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
|
|
<div className="flex-1">
|
|
<InstantCaptureBar
|
|
onCaptured={handleCaptured}
|
|
lists={lists}
|
|
autoFocusTrigger={captureFocusCount}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowCommandPalette(true)}
|
|
className="w-8 h-8 rounded-full bg-panel border border-border text-muted hover:text-main flex items-center justify-center transition-colors cursor-pointer shrink-0"
|
|
title="Globale Suche (Strg+K / ⌘K oder /)"
|
|
aria-label="Globale Suche (Strg+K / ⌘K oder /)"
|
|
>
|
|
<Search className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* User Profile Pill & Dropdown Menu */}
|
|
<div className="relative h-9 flex items-center gap-2 flex-shrink-0" ref={dropdownRef}>
|
|
<button
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowProfileMenu((prev) => !prev);
|
|
}}
|
|
className="h-8 flex items-center gap-2 text-xs font-medium text-main hover:opacity-85 transition-opacity cursor-pointer group"
|
|
title="Benutzermenü öffnen"
|
|
>
|
|
<UserAvatar user={user} size="sm" />
|
|
<span className="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 origin-top-right animate-in fade-in zoom-in-95 slide-in-from-top-1 duration-150">
|
|
{/* User Info Header */}
|
|
<div className="p-3 bg-canvas flex items-center gap-2.5">
|
|
<UserAvatar user={user} size="md" className="border border-border/70" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-bold text-main truncate">{user.displayName}</p>
|
|
<p className="text-[11px] text-muted truncate">{user.email}</p>
|
|
<span className="inline-block mt-1 px-1.5 py-0.5 bg-panel border border-border rounded-btn text-[10px] text-accent font-semibold">
|
|
{roleLabel}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Theme Quick Switcher */}
|
|
<div className="p-2.5 space-y-1.5">
|
|
<span className="text-[10px] font-semibold text-muted uppercase tracking-wider flex items-center gap-1.5 px-1">
|
|
<Palette className="w-3 h-3 text-accent" />
|
|
<span>Design-Theme</span>
|
|
</span>
|
|
<ThemeQuickPicker activeTheme={activeTheme} themes={allThemes} onChange={handleChangeTheme} />
|
|
</div>
|
|
|
|
{/* Menu Items */}
|
|
<div className="py-1">
|
|
<button
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowProfileMenu(false);
|
|
setActiveTab('user_settings');
|
|
}}
|
|
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
|
|
activeTab === 'user_settings' ? 'text-accent font-semibold' : 'text-main'
|
|
}`}
|
|
>
|
|
<Settings className="w-4 h-4 text-accent" />
|
|
<span>Kontoeinstellungen</span>
|
|
</button>
|
|
|
|
{!isSharingDisabled() && (
|
|
<button
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowProfileMenu(false);
|
|
setActiveTab('groups');
|
|
}}
|
|
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
|
|
activeTab === 'groups' ? 'text-accent font-semibold' : 'text-main'
|
|
}`}
|
|
>
|
|
<Users className="w-4 h-4 text-accent" />
|
|
<span>Gruppen</span>
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
onClick={handleOpenHelp}
|
|
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
|
|
activeTab === 'help' ? 'text-accent font-semibold' : 'text-main'
|
|
}`}
|
|
>
|
|
<HelpCircle className="w-4 h-4 text-accent" />
|
|
<span>Hilfe & Anleitung</span>
|
|
</button>
|
|
|
|
{isServerAdmin && (
|
|
<button
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowProfileMenu(false);
|
|
setActiveTab('admin_settings');
|
|
}}
|
|
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
|
|
activeTab === 'admin_settings'
|
|
? 'text-accent font-semibold'
|
|
: 'text-main'
|
|
}`}
|
|
>
|
|
<Shield className="w-4 h-4 text-accent" />
|
|
<span>Administration</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Logout */}
|
|
<div className="py-1">
|
|
<button
|
|
onClick={handleLogout}
|
|
className="w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 text-status-err hover:bg-status-err-bg transition-colors"
|
|
>
|
|
<LogOut className="w-4 h-4" />
|
|
<span>Abmelden</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop Navigation tabs attached to header */}
|
|
<nav className="hidden sm:flex max-w-6xl mx-auto px-2 border-t border-border items-stretch justify-between">
|
|
<div className="flex items-stretch overflow-x-auto no-scrollbar">
|
|
<button
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setActiveTab('triage');
|
|
}}
|
|
className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`}
|
|
>
|
|
<Layers className="w-4 h-4" />
|
|
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-3' : ''}`}>
|
|
<span>Triage</span>
|
|
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
|
|
</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setActiveTab('workspace');
|
|
}}
|
|
className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`}
|
|
>
|
|
<FileText className="w-4 h-4" />
|
|
<span>Workspace</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setActiveTab('tasks');
|
|
}}
|
|
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
|
|
>
|
|
<CheckSquare className="w-4 h-4" />
|
|
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-3' : ''}`}>
|
|
<span>Aufgaben</span>
|
|
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
|
|
</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setActiveTab('lists');
|
|
}}
|
|
className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`}
|
|
>
|
|
<ShoppingCart className="w-4 h-4" />
|
|
<span className={`relative inline-flex items-center ${(stats.listItemsOpenCount ?? 0) > 0 ? 'pr-3' : ''}`}>
|
|
<span>Listen</span>
|
|
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Borderless Circled Question Mark Help Button in Tab Bar */}
|
|
<div className="flex items-center pr-2 flex-shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={handleOpenHelp}
|
|
className={`p-1.5 transition-colors cursor-pointer rounded-full ${
|
|
activeTab === 'help'
|
|
? 'text-accent'
|
|
: 'text-muted hover:text-main'
|
|
}`}
|
|
title="Hilfe & Dokumentation"
|
|
>
|
|
<HelpCircle className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
</header>
|
|
|
|
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
|
|
<OfflineBanner />
|
|
{demoNotice && (
|
|
<div
|
|
role="status"
|
|
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] px-3 py-1.5 rounded-full border border-accent/40 bg-panel-elevated text-main text-[11px] font-medium shadow-panel"
|
|
>
|
|
✨ {demoNotice}
|
|
</div>
|
|
)}
|
|
|
|
{/* Main Container */}
|
|
{/* The workspace fills the window exactly and scrolls only inside the note, never the page */}
|
|
<main
|
|
className={`flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 ${
|
|
activeTab === 'workspace' ? 'min-h-0 flex flex-col sm:pb-0' : 'sm:pb-6'
|
|
}`}
|
|
>
|
|
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
|
<section key={dataEpoch} className={activeTab === 'workspace' ? 'flex-1 min-h-0 flex flex-col' : undefined}>
|
|
{activeTab === 'triage' && (
|
|
<TriageDeck
|
|
deck={deck}
|
|
lists={lists}
|
|
onDeckChange={refreshAll}
|
|
onOpenDocument={handleOpenDocument}
|
|
onFocusCapture={handleTriggerAddButton}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'tasks' && (
|
|
<TasksView
|
|
currentUser={user}
|
|
onRefreshStats={refreshAll}
|
|
onOpenDocument={handleOpenDocument}
|
|
refreshTrigger={refreshRevision}
|
|
initialSelectedTaskId={selectedTaskId}
|
|
openTasksCount={stats.tasksOpenCount}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'workspace' && (
|
|
<WorkspaceView
|
|
currentUser={user}
|
|
initialDocId={selectedDocId}
|
|
initialBlockId={selectedBlockId}
|
|
onStatsChange={refreshAll}
|
|
onOpenTask={handleOpenTask}
|
|
onOpenSearch={() => setShowCommandPalette(true)}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'help' && (
|
|
<HelpView onBack={() => setActiveTab(previousTab || 'triage')} />
|
|
)}
|
|
|
|
{activeTab === 'lists' && (
|
|
<SharedListsView
|
|
onRefreshGlobal={refreshAll}
|
|
currentUser={user}
|
|
initialListId={selectedListId}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'groups' && (
|
|
<GroupsView
|
|
currentUser={user}
|
|
onBack={() => setActiveTab(previousTab || 'triage')}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'user_settings' && (
|
|
<UserSettingsView
|
|
currentUser={user}
|
|
activeTheme={activeTheme}
|
|
themes={allThemes}
|
|
customThemes={customThemes}
|
|
onThemeChange={handleChangeTheme}
|
|
onCustomThemesChange={handleCustomThemesChange}
|
|
onUserUpdated={(u) => {
|
|
setUser(u);
|
|
refreshAll();
|
|
}}
|
|
onBack={() => setActiveTab('triage')}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'admin_settings' && isServerAdmin && (
|
|
<AdminSettingsView
|
|
currentUser={user}
|
|
onBack={() => setActiveTab('triage')}
|
|
/>
|
|
)}
|
|
</section>
|
|
</ViewErrorBoundary>
|
|
</main>
|
|
|
|
<AppFooter
|
|
legal={authStatus.legal}
|
|
onOpenAbout={() => setShowAbout(true)}
|
|
/>
|
|
|
|
{/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
|
|
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
|
|
{/* Col 1: Triage */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setActiveTab('triage');
|
|
}}
|
|
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
|
|
activeTab === 'triage' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
|
}`}
|
|
title="Triage"
|
|
>
|
|
<div className="relative">
|
|
<Layers className="w-5 h-5" />
|
|
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
|
</div>
|
|
<span className="text-[10px] leading-tight">Triage</span>
|
|
</button>
|
|
|
|
{/* Col 2: Workspace */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setActiveTab('workspace');
|
|
}}
|
|
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
|
|
activeTab === 'workspace' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
|
}`}
|
|
title="Workspace"
|
|
>
|
|
<FileText className="w-5 h-5" />
|
|
<span className="text-[10px] leading-tight">Workspace</span>
|
|
</button>
|
|
|
|
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
|
|
<div className="flex items-center justify-center">
|
|
<HoldToRecordFab
|
|
onTap={() => {
|
|
triggerHaptic(15);
|
|
if (primaryAction) {
|
|
primaryAction();
|
|
} else {
|
|
openMobileTriageCapture();
|
|
}
|
|
}}
|
|
onTranscript={async (text) => {
|
|
const thought = await api.captureThought(text, 'normal', 'voice', '', { target: 'inbox' });
|
|
handleCaptured(thought, 'inbox');
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Col 4: Aufgaben */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setActiveTab('tasks');
|
|
}}
|
|
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
|
|
activeTab === 'tasks' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
|
}`}
|
|
title="Aufgaben"
|
|
>
|
|
<div className="relative">
|
|
<CheckSquare className="w-5 h-5" />
|
|
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
|
</div>
|
|
<span className="text-[10px] leading-tight">Aufgaben</span>
|
|
</button>
|
|
|
|
{/* Col 5: Listen */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setActiveTab('lists');
|
|
}}
|
|
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
|
|
activeTab === 'lists' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
|
}`}
|
|
title="Listen"
|
|
>
|
|
<div className="relative">
|
|
<ShoppingCart className="w-5 h-5" />
|
|
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
|
</div>
|
|
<span className="text-[10px] leading-tight">Listen</span>
|
|
</button>
|
|
</nav>
|
|
|
|
{/* MOBILE QUICK-ADD BOTTOM SHEET / MODAL (DESIGN-ALIGNED WITH TASKS VIEW) */}
|
|
{showMobileCapture && (
|
|
<div
|
|
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
|
onClick={() => setShowMobileCapture(false)}
|
|
>
|
|
<div
|
|
className="w-full max-h-[85vh] overflow-y-auto bg-panel border-t border-border rounded-t-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] animate-in slide-in-from-bottom duration-200"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between pb-3 border-b border-border mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
|
|
<Layers className="w-4 h-4" />
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-main">Quick Dump erfassen</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowMobileCapture(false)}
|
|
className="p-1.5 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleMobileCreateTriage} className="space-y-3">
|
|
{/* Text Input with Voice Dictation */}
|
|
<div>
|
|
<label className="text-[11px] font-semibold text-muted block mb-1">
|
|
Gedanke / Quick Dump
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
autoFocus
|
|
value={mobileTriageContent}
|
|
onChange={(e) => setMobileTriageContent(e.target.value)}
|
|
placeholder={
|
|
mobileVoiceError
|
|
? mobileVoiceError
|
|
: mobileVoiceActive
|
|
? 'Ich höre zu…'
|
|
: mobileVoice.state === 'transcribing'
|
|
? 'Wird erkannt…'
|
|
: 'Was geht dir durch den Kopf?…'
|
|
}
|
|
className={
|
|
mobileVoiceActive && mobileVoice.mode === 'server'
|
|
? 'w-full input-field px-3 py-2 pr-24 text-sm'
|
|
: 'w-full input-field px-3 py-2 pr-10 text-sm'
|
|
}
|
|
/>
|
|
{mobileVoice.available && (
|
|
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
|
{mobileVoiceActive && mobileVoice.mode === 'server' && (
|
|
<VoiceWave getLevel={mobileVoice.level} bars={7} className="h-4 text-accent" />
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={toggleMobileVoice}
|
|
disabled={mobileVoice.state === 'transcribing'}
|
|
className={`w-9 h-9 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
|
|
mobileVoiceActive
|
|
? mobileVoice.mode === 'server'
|
|
? 'bg-status-err text-on-accent'
|
|
: 'bg-accent text-on-accent animate-pulse'
|
|
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
|
}`}
|
|
title={mobileVoiceActive ? 'Aufnahme beenden' : 'Spracheingabe'}
|
|
>
|
|
{mobileVoice.state === 'transcribing' ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : mobileVoiceActive ? (
|
|
<MicOff className="w-4 h-4" />
|
|
) : (
|
|
<Mic className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Priority - 4 Buttons (Identical to TasksView) */}
|
|
<div className="space-y-1.5 pt-1">
|
|
<label className="text-[11px] font-semibold text-muted block">Priorität</label>
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
|
|
const config: Record<
|
|
PriorityLevel,
|
|
{ label: string; icon?: React.ReactNode; activeClass: string }
|
|
> = {
|
|
low: {
|
|
label: 'Niedrig',
|
|
activeClass: 'bg-canvas border-strong text-main font-semibold shadow-xs',
|
|
},
|
|
normal: {
|
|
label: 'Normal',
|
|
activeClass: 'bg-accent/15 border-accent text-accent font-semibold shadow-xs',
|
|
},
|
|
high: {
|
|
label: 'Wichtig',
|
|
icon: <AlertTriangle className="w-3 h-3 text-status-warn" />,
|
|
activeClass:
|
|
'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs',
|
|
},
|
|
urgent: {
|
|
label: 'Dringend',
|
|
icon: <Flame className="w-3 h-3 text-status-err" />,
|
|
activeClass:
|
|
'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs',
|
|
},
|
|
};
|
|
const active = mobileTriagePriority === p;
|
|
const item = config[p];
|
|
return (
|
|
<button
|
|
key={p}
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setMobileTriagePriority(p);
|
|
}}
|
|
className={`h-9 px-1 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
|
active
|
|
? item.activeClass
|
|
: 'bg-canvas border-border text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
{item.icon}
|
|
<span className="truncate">{item.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Due Date (Optional) */}
|
|
<div className="space-y-1 pt-1">
|
|
<label className="text-[11px] font-semibold text-muted flex items-center gap-1">
|
|
<Calendar className="w-3.5 h-3.5 text-accent" />
|
|
<span>Fällig am (optional)</span>
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={mobileTriageDueDate}
|
|
onChange={(e) => setMobileTriageDueDate(e.target.value)}
|
|
className="w-full input-field px-3 py-2 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
{/* Bottom Buttons */}
|
|
<div className="pt-2 flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowMobileCapture(false)}
|
|
className="btn-ghost h-10 px-4 text-xs cursor-pointer"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={!mobileTriageContent.trim()}
|
|
className="btn-primary h-10 px-5 text-xs font-semibold disabled:opacity-40 cursor-pointer"
|
|
>
|
|
Gedanke erfassen
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* MOBILE USER PROFILE SHEET */}
|
|
{showMobileProfile && (
|
|
<div
|
|
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
|
onClick={() => setShowMobileProfile(false)}
|
|
>
|
|
<div
|
|
className="w-full max-h-[85vh] overflow-y-auto bg-panel border-t border-border rounded-t-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] space-y-4 animate-in slide-in-from-bottom duration-200 divide-y divide-border"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* User Info Header */}
|
|
<div className="flex items-center justify-between pb-2">
|
|
<div className="flex items-center gap-3">
|
|
<UserAvatar user={user} size="lg" className="border border-border/80 shadow-sm" />
|
|
<div>
|
|
<p className="font-bold text-sm text-main">{user.displayName}</p>
|
|
<p className="text-xs text-muted">{user.email}</p>
|
|
<span className="inline-block mt-1 px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-[10px] text-accent font-semibold">
|
|
{roleLabel}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowMobileProfile(false)}
|
|
className="p-1 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Theme Quick Switcher */}
|
|
<div className="pt-3 space-y-2">
|
|
<span className="text-[11px] font-semibold text-muted uppercase tracking-wider flex items-center gap-1.5">
|
|
<Palette className="w-3.5 h-3.5 text-accent" />
|
|
<span>Design-Theme</span>
|
|
</span>
|
|
<ThemeQuickPicker activeTheme={activeTheme} themes={allThemes} onChange={handleChangeTheme} touch />
|
|
</div>
|
|
|
|
{/* Menu Items */}
|
|
<div className="pt-3 space-y-1">
|
|
{!isStandaloneApp && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowMobileProfile(false);
|
|
setShowManualPwaGuide(false);
|
|
setShowPwaInstallModal(true);
|
|
}}
|
|
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 bg-accent/10 border border-accent/30 text-accent font-semibold transition-colors text-xs cursor-pointer mb-1"
|
|
>
|
|
<Smartphone className="w-4 h-4 text-accent" />
|
|
<span>Web-App auf Smartphone installieren</span>
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowMobileProfile(false);
|
|
setActiveTab('user_settings');
|
|
}}
|
|
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
|
|
activeTab === 'user_settings' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
|
|
}`}
|
|
>
|
|
<Settings className="w-4 h-4 text-accent" />
|
|
<span>Kontoeinstellungen</span>
|
|
</button>
|
|
|
|
{!isSharingDisabled() && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowMobileProfile(false);
|
|
setActiveTab('groups');
|
|
}}
|
|
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
|
|
activeTab === 'groups' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
|
|
}`}
|
|
>
|
|
<Users className="w-4 h-4 text-accent" />
|
|
<span>Gruppen</span>
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShowMobileProfile(false);
|
|
handleOpenHelp();
|
|
}}
|
|
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
|
|
activeTab === 'help' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
|
|
}`}
|
|
>
|
|
<HelpCircle className="w-4 h-4 text-accent" />
|
|
<span>Hilfe & Anleitung</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowMobileProfile(false);
|
|
setShowAbout(true);
|
|
}}
|
|
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer text-main"
|
|
>
|
|
<Brain className="w-4 h-4 text-accent" />
|
|
<span>Über MindSift</span>
|
|
</button>
|
|
|
|
{isServerAdmin && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowMobileProfile(false);
|
|
setActiveTab('admin_settings');
|
|
}}
|
|
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
|
|
activeTab === 'admin_settings' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
|
|
}`}
|
|
>
|
|
<Shield className="w-4 h-4 text-accent" />
|
|
<span>Administration</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Logout */}
|
|
<div className="pt-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShowMobileProfile(false);
|
|
handleLogout();
|
|
}}
|
|
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 text-status-err hover:bg-status-err-bg transition-colors text-xs font-medium cursor-pointer"
|
|
>
|
|
<LogOut className="w-4 h-4" />
|
|
<span>Abmelden</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Version & About Hint on Mobile */}
|
|
<div className="pt-2 text-center text-[10px] text-faint font-mono">
|
|
MindSift {APP_VERSION}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* IMMEDIATE MOBILE WEB-APP INSTALLATION OFFER SHEET */}
|
|
{showPwaInstallModal && !isStandaloneApp && (
|
|
<div
|
|
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/65 backdrop-blur-xs animate-in fade-in duration-150"
|
|
onClick={handleDismissPwaModal}
|
|
>
|
|
<div
|
|
className="w-full bg-panel border-t border-border rounded-t-2xl shadow-panel p-5 pb-[calc(1.75rem+env(safe-area-inset-bottom))] space-y-4 animate-in slide-in-from-bottom duration-200"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-12 h-12 rounded-2xl bg-accent text-on-accent shadow-md flex items-center justify-center flex-shrink-0">
|
|
<Brain className="w-6 h-6" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-base font-bold text-main leading-tight">
|
|
{authStatus.instanceName || 'MindSift'} als App installieren
|
|
</h3>
|
|
<p className="text-xs text-muted mt-0.5">
|
|
Vollbild ohne Browser-Leiste, schnellerer Start & Direktzugriff vom Home-Bildschirm.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleDismissPwaModal}
|
|
className="p-1.5 text-faint hover:text-main rounded-btn cursor-pointer"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{!showManualPwaGuide ? (
|
|
<div className="space-y-2.5 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={handleInstallWebApp}
|
|
className="btn-primary w-full h-12 text-sm font-semibold rounded-xl flex items-center justify-center gap-2 shadow-md active:scale-98 transition-transform cursor-pointer"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
<span>Jetzt als Web-App installieren</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleDismissPwaModal}
|
|
className="btn-action w-full h-10 text-xs rounded-xl justify-center cursor-pointer"
|
|
>
|
|
Später im Browser weiter nutzen
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3 pt-1">
|
|
<div className="p-3.5 rounded-xl bg-canvas border border-border space-y-2.5 text-xs text-main">
|
|
<p className="font-semibold text-accent flex items-center gap-1.5">
|
|
<Smartphone className="w-4 h-4" />
|
|
<span>In 2 Sekunden zum Home-Bildschirm hinzufügen:</span>
|
|
</p>
|
|
<ol className="space-y-2 text-muted list-decimal list-inside">
|
|
<li>
|
|
Tippe im Browser auf{' '}
|
|
<strong className="text-main inline-flex items-center gap-1">
|
|
<Share className="w-3.5 h-3.5 text-accent inline" /> Teilen
|
|
</strong>{' '}
|
|
(iPhone/Safari) oder das{' '}
|
|
<strong className="text-main">Browser-Menü (⋮)</strong>.
|
|
</li>
|
|
<li>
|
|
Wähle{' '}
|
|
<strong className="text-main">„Zum Home-Bildschirm“</strong> bzw.{' '}
|
|
<strong className="text-main">„App installieren“</strong>.
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleDismissPwaModal}
|
|
className="btn-primary w-full h-11 text-xs font-semibold rounded-xl justify-center cursor-pointer"
|
|
>
|
|
Verstanden
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Global Command Palette (Cmd+K / Ctrl+K / '/') */}
|
|
<CommandPalette
|
|
isOpen={showCommandPalette}
|
|
onClose={() => setShowCommandPalette(false)}
|
|
onOpenDocument={handleOpenDocument}
|
|
onOpenTask={handleOpenTask}
|
|
onOpenList={handleOpenList}
|
|
/>
|
|
|
|
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App;
|