MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 19s
1525 lines
58 KiB
TypeScript
1525 lines
58 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import {
|
|
Layers,
|
|
CheckSquare,
|
|
FileText,
|
|
ShoppingCart,
|
|
Brain,
|
|
Settings,
|
|
Shield,
|
|
LogOut,
|
|
ChevronDown,
|
|
RotateCcw,
|
|
Palette,
|
|
HelpCircle,
|
|
Plus,
|
|
X,
|
|
Download,
|
|
Smartphone,
|
|
Share,
|
|
Users,
|
|
Calendar,
|
|
AlertTriangle,
|
|
Flame,
|
|
Mic,
|
|
MicOff,
|
|
} from 'lucide-react';
|
|
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User } from './lib/types';
|
|
import {
|
|
api,
|
|
clearToken,
|
|
getToken,
|
|
triggerHaptic,
|
|
connectWebSocket,
|
|
disconnectWebSocket,
|
|
} from './lib/api';
|
|
import {
|
|
AVAILABLE_THEMES,
|
|
initThemeOnBoot,
|
|
setActiveTheme as applyTheme,
|
|
ThemeId,
|
|
} from './lib/theme';
|
|
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
|
|
import { TriageDeck } from './features/triage/TriageDeck';
|
|
import { TasksView } from './features/triage/TasksView';
|
|
import { WorkspaceView } from './features/workspace/WorkspaceView';
|
|
import { SharedListsView } from './features/lists/SharedListsView';
|
|
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
|
import { UserSettingsView } from './features/settings/UserSettingsView';
|
|
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
|
import { HelpView } from './features/help/HelpView';
|
|
import { GroupsView } from './features/teams/GroupsView';
|
|
import { useMobileActions } from './context/MobileActionContext';
|
|
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
|
import { UserAvatar } from './components/UserAvatar';
|
|
|
|
type ActiveTab =
|
|
| 'triage'
|
|
| 'tasks'
|
|
| 'workspace'
|
|
| 'lists'
|
|
| 'groups'
|
|
| 'user_settings'
|
|
| 'admin_settings'
|
|
| 'help';
|
|
|
|
interface ErrorBoundaryState {
|
|
hasError: boolean;
|
|
errorText: string;
|
|
}
|
|
|
|
class ViewErrorBoundary extends React.Component<
|
|
{ children: React.ReactNode; onReset: () => void },
|
|
ErrorBoundaryState
|
|
> {
|
|
constructor(props: { children: React.ReactNode; onReset: () => void }) {
|
|
super(props);
|
|
this.state = { hasError: false, errorText: '' };
|
|
}
|
|
|
|
static getDerivedStateFromError(err: Error): ErrorBoundaryState {
|
|
return { hasError: true, errorText: err?.message || 'Unbekannter Darstellungsfehler' };
|
|
}
|
|
|
|
render() {
|
|
if (this.state.hasError) {
|
|
return (
|
|
<div className="p-6 panel-surface text-xs space-y-3 max-w-lg mx-auto my-8">
|
|
<h3 className="text-sm font-bold text-main">Ansicht konnte nicht geladen werden</h3>
|
|
<p className="text-muted">{this.state.errorText}</p>
|
|
<button
|
|
onClick={() => {
|
|
this.setState({ hasError: false, errorText: '' });
|
|
this.props.onReset();
|
|
}}
|
|
className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5"
|
|
>
|
|
<RotateCcw className="w-3.5 h-3.5" />
|
|
<span>Ansicht zurücksetzen</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
export const App: React.FC = () => {
|
|
const [activeTheme, setActiveThemeState] = useState<ThemeId>(() => initThemeOnBoot());
|
|
const [bootLoading, setBootLoading] = useState(true);
|
|
const [authStatus, setAuthStatus] = useState<AuthStatus>({
|
|
needsSetup: false,
|
|
allowRegistration: false,
|
|
instanceName: 'MindSift',
|
|
});
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [stats, setStats] = useState<DashboardStats>({
|
|
inboxCount: 0,
|
|
tasksOpenCount: 0,
|
|
tasksDoneCount: 0,
|
|
docsCount: 0,
|
|
docsTodosOpenCount: 0,
|
|
listsCount: 0,
|
|
discardedCount: 0,
|
|
});
|
|
const [deck, setDeck] = useState<Thought[]>([]);
|
|
const [lists, setLists] = useState<SharedList[]>([]);
|
|
const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
|
|
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
|
|
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
|
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
|
const [captureFocusCount, setCaptureFocusCount] = useState(0);
|
|
const [refreshRevision, setRefreshRevision] = useState(0);
|
|
|
|
// Profile Dropdown State
|
|
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
// Mobile Navigation & Quick-Action State
|
|
const [showMobileCapture, setShowMobileCapture] = useState(false);
|
|
const [showMobileProfile, setShowMobileProfile] = useState(false);
|
|
const [mobileTriageContent, setMobileTriageContent] = useState('');
|
|
const [mobileTriagePriority, setMobileTriagePriority] = useState<PriorityLevel>('normal');
|
|
const [mobileTriageDueDate, setMobileTriageDueDate] = useState('');
|
|
const [mobileVoiceActive, setMobileVoiceActive] = useState(false);
|
|
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
|
|
const mobileRecognitionRef = useRef<any>(null);
|
|
const 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'),
|
|
},
|
|
{
|
|
id: 'goto-groups',
|
|
label: 'Teams & Gruppen',
|
|
icon: <Users className="w-4 h-4" />,
|
|
onClick: () => setActiveTab('groups'),
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
} else if (
|
|
activeTab === 'groups' ||
|
|
activeTab === 'user_settings' ||
|
|
activeTab === 'admin_settings' ||
|
|
activeTab === 'help'
|
|
) {
|
|
setGroups([
|
|
{
|
|
id: 'subview-nav',
|
|
title: 'Navigation',
|
|
items: [
|
|
{
|
|
id: 'back-triage',
|
|
label: 'Zurück zur Triage (Startseite)',
|
|
icon: <Layers className="w-4 h-4" />,
|
|
variant: 'accent',
|
|
onClick: () => setActiveTab('triage'),
|
|
},
|
|
{
|
|
id: 'goto-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 handleChangeTheme = (themeId: ThemeId) => {
|
|
triggerHaptic(10);
|
|
applyTheme(themeId);
|
|
setActiveThemeState(themeId);
|
|
};
|
|
|
|
const refreshAll = useCallback(async () => {
|
|
try {
|
|
const [meRes, deckRes, listsRes] = await Promise.all([
|
|
api.getMe(),
|
|
api.getTriageDeck(),
|
|
api.getAllLists(),
|
|
]);
|
|
setUser(meRes.user);
|
|
setStats(meRes.stats);
|
|
setDeck(Array.isArray(deckRes) ? deckRes : []);
|
|
setLists(Array.isArray(listsRes) ? listsRes : []);
|
|
} catch {
|
|
// If token expired or invalid, api.ts dispatches mindsift:unauthorized
|
|
}
|
|
}, []);
|
|
|
|
const checkBootAndAuth = async () => {
|
|
setBootLoading(true);
|
|
try {
|
|
const st = await api.getAuthStatus();
|
|
setAuthStatus(st);
|
|
|
|
if (st.needsSetup) {
|
|
clearToken();
|
|
setUser(null);
|
|
return;
|
|
}
|
|
|
|
if (getToken()) {
|
|
await api.flushOfflineQueue();
|
|
await refreshAll();
|
|
} else {
|
|
setUser(null);
|
|
}
|
|
} catch {
|
|
// Server temporarily unreachable
|
|
} finally {
|
|
setBootLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
checkBootAndAuth();
|
|
|
|
const onUnauthorized = async () => {
|
|
setUser(null);
|
|
setShowProfileMenu(false);
|
|
try {
|
|
const st = await api.getAuthStatus();
|
|
setAuthStatus(st);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
const onOnline = async () => {
|
|
if (getToken()) {
|
|
const synced = await api.flushOfflineQueue();
|
|
if (synced > 0) refreshAll();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('mindsift:unauthorized', onUnauthorized);
|
|
window.addEventListener('online', onOnline);
|
|
return () => {
|
|
window.removeEventListener('mindsift:unauthorized', onUnauthorized);
|
|
window.removeEventListener('online', onOnline);
|
|
};
|
|
}, []);
|
|
|
|
// WebSocket Connection Management
|
|
useEffect(() => {
|
|
if (user && getToken()) {
|
|
connectWebSocket();
|
|
} else {
|
|
disconnectWebSocket();
|
|
}
|
|
}, [user?.id]);
|
|
|
|
// Refresh stats on task/doc real-time events
|
|
useEffect(() => {
|
|
const handleWs = (e: any) => {
|
|
const msg = e.detail;
|
|
if (!msg) return;
|
|
if (
|
|
typeof msg.event === 'string' &&
|
|
(msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_') || msg.event.startsWith('SPACE_'))
|
|
) {
|
|
api.getMe().then((res) => {
|
|
setStats(res.stats);
|
|
}).catch(() => {});
|
|
}
|
|
};
|
|
window.addEventListener('mindsift:ws', handleWs);
|
|
return () => {
|
|
window.removeEventListener('mindsift:ws', handleWs);
|
|
};
|
|
}, []);
|
|
|
|
// Close profile dropdown on outside click
|
|
useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
setShowProfileMenu(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const handleAuthenticated = async (loggedInUser: User) => {
|
|
setUser(loggedInUser);
|
|
setAuthStatus((prev) => ({ ...prev, needsSetup: false }));
|
|
setActiveTab('triage');
|
|
await refreshAll();
|
|
};
|
|
|
|
const handleCaptured = (
|
|
thought: Thought,
|
|
target: 'inbox' | 'task' | 'list_item' | 'document' = 'inbox'
|
|
) => {
|
|
if (target === 'inbox' && thought.status === 'inbox') {
|
|
setDeck((prev) => {
|
|
if (prev.some((t) => t.id === thought.id)) return prev;
|
|
return [thought, ...prev];
|
|
});
|
|
setStats((prev) => ({ ...prev, inboxCount: prev.inboxCount + 1 }));
|
|
} else if (target === 'document' && thought.convertedRefId) {
|
|
setSelectedDocId(thought.convertedRefId);
|
|
setActiveTab('workspace');
|
|
}
|
|
setRefreshRevision((r) => r + 1);
|
|
refreshAll();
|
|
};
|
|
|
|
const handleOpenDocument = (docId: string, blockId?: string) => {
|
|
setSelectedDocId(docId);
|
|
setSelectedBlockId(blockId || null);
|
|
setActiveTab('workspace');
|
|
};
|
|
|
|
const handleOpenTask = useCallback((taskId: string) => {
|
|
setSelectedTaskId(taskId);
|
|
setActiveTab('tasks');
|
|
}, []);
|
|
|
|
const handleTriggerAddButton = () => {
|
|
triggerHaptic(20);
|
|
if (window.innerWidth < 640) {
|
|
openMobileTriageCapture();
|
|
} else {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
setCaptureFocusCount((c) => c + 1);
|
|
}
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
triggerHaptic(20);
|
|
disconnectWebSocket();
|
|
try {
|
|
await api.logout();
|
|
} catch {
|
|
clearToken();
|
|
}
|
|
setUser(null);
|
|
setShowProfileMenu(false);
|
|
setActiveTab('triage');
|
|
try {
|
|
const st = await api.getAuthStatus();
|
|
setAuthStatus(st);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
if (bootLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-canvas text-muted flex items-center justify-center text-xs font-sans">
|
|
Verbinde mit MindSift…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show Setup Wizard or Login Screen if needsSetup or not logged in
|
|
if (authStatus.needsSetup || !user) {
|
|
return (
|
|
<SetupAndAuthView
|
|
authStatus={authStatus}
|
|
onAuthenticated={handleAuthenticated}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const isServerAdmin = user.role === 'admin';
|
|
const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied';
|
|
|
|
const activeTabLabelMap: Record<ActiveTab, string> = {
|
|
triage: 'Triage',
|
|
tasks: 'Aufgaben',
|
|
workspace: 'Workspace',
|
|
lists: 'Listen',
|
|
groups: 'Teams & Gruppen',
|
|
user_settings: 'Einstellungen',
|
|
admin_settings: 'Administration',
|
|
help: 'Hilfe',
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30">
|
|
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
|
|
<header className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm">
|
|
{/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */}
|
|
<div className="sm:hidden flex items-center justify-between h-14 px-4 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(12);
|
|
setIsMenuOpen((prev) => !prev);
|
|
}}
|
|
className={`min-h-[44px] px-2.5 -ml-2 rounded-xl flex items-center gap-2.5 cursor-pointer select-none active:scale-95 transition-all ${
|
|
isMenuOpen ? 'bg-panel border border-accent/40 shadow-sm' : 'hover:bg-panel/60'
|
|
}`}
|
|
title="Menü & Ansichts-Optionen öffnen"
|
|
>
|
|
<div className="w-8 h-8 bg-accent text-on-accent rounded-xl shadow-sm flex items-center justify-center flex-shrink-0">
|
|
<Brain className="w-4 h-4" />
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<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">v0.1</span>
|
|
</div>
|
|
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
|
|
{activeTabLabelMap[activeTab]}
|
|
</span>
|
|
<ChevronDown
|
|
className={`w-4 h-4 text-muted transition-transform duration-150 ${
|
|
isMenuOpen ? 'rotate-180 text-accent' : ''
|
|
}`}
|
|
/>
|
|
</div>
|
|
</button>
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
{!isStandaloneApp && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowManualPwaGuide(false);
|
|
setShowPwaInstallModal(true);
|
|
}}
|
|
className="h-9 px-2.5 rounded-xl bg-accent/15 border border-accent/40 text-accent text-xs font-semibold flex items-center gap-1.5 active:scale-95 transition-transform cursor-pointer"
|
|
title="Als Web-App auf dem Smartphone installieren"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
<span>App</span>
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowMobileProfile(true);
|
|
}}
|
|
className={`w-11 h-11 -mr-1.5 rounded-full flex items-center justify-center transition-transform active:scale-95 cursor-pointer ${
|
|
activeTab === 'user_settings' || activeTab === 'admin_settings' || activeTab === 'help'
|
|
? 'ring-2 ring-accent'
|
|
: ''
|
|
}`}
|
|
title="Profil & Einstellungen"
|
|
>
|
|
<UserAvatar user={user} size="sm" className="shadow-sm" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop Header: 3-column Layout (Logo, InstantCaptureBar, Profile Pill) */}
|
|
<div className="hidden sm:flex max-w-6xl mx-auto px-4 py-2 items-start justify-between gap-6">
|
|
{/* Logo & Headline */}
|
|
<div
|
|
onClick={() => {
|
|
setActiveTab('triage');
|
|
handleTriggerAddButton();
|
|
}}
|
|
className="h-9 flex items-center gap-2.5 cursor-pointer group select-none flex-shrink-0"
|
|
>
|
|
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
|
<Brain className="w-4 h-4" />
|
|
</div>
|
|
<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">v0.1</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
|
<div className="flex-1 max-w-xl mx-auto">
|
|
<InstantCaptureBar
|
|
onCaptured={handleCaptured}
|
|
lists={lists}
|
|
autoFocusTrigger={captureFocusCount}
|
|
/>
|
|
</div>
|
|
|
|
{/* User Profile Pill & Dropdown Menu */}
|
|
<div className="relative h-9 flex items-center gap-2 flex-shrink-0" ref={dropdownRef}>
|
|
<button
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowProfileMenu((prev) => !prev);
|
|
}}
|
|
className="h-8 flex items-center gap-2 text-xs font-medium text-main hover:opacity-85 transition-opacity cursor-pointer group"
|
|
title="Benutzermenü öffnen"
|
|
>
|
|
<UserAvatar user={user} size="sm" />
|
|
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
|
{user.displayName}
|
|
</span>
|
|
<ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" />
|
|
</button>
|
|
|
|
{showProfileMenu && (
|
|
<div className="absolute right-0 top-full mt-1.5 w-64 panel-surface shadow-panel z-50 divide-y divide-border text-xs">
|
|
{/* User Info Header */}
|
|
<div className="p-3 bg-canvas flex items-center gap-2.5">
|
|
<UserAvatar user={user} size="md" className="border border-border/70" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-bold text-main truncate">{user.displayName}</p>
|
|
<p className="text-[11px] text-muted truncate">{user.email}</p>
|
|
<span className="inline-block mt-1 px-1.5 py-0.5 bg-panel border border-border rounded-btn text-[10px] text-accent font-semibold">
|
|
{roleLabel}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Theme Quick Switcher */}
|
|
<div className="p-2.5 space-y-1.5">
|
|
<span className="text-[10px] font-semibold text-muted uppercase tracking-wider flex items-center gap-1.5 px-1">
|
|
<Palette className="w-3 h-3 text-accent" />
|
|
<span>Design-Theme</span>
|
|
</span>
|
|
<div className="grid grid-cols-2 gap-1">
|
|
{AVAILABLE_THEMES.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
onClick={() => handleChangeTheme(t.id)}
|
|
className={`h-7 px-2 text-[11px] font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
|
|
activeTheme === t.id
|
|
? 'bg-panel-elevated border-accent text-main font-semibold'
|
|
: 'bg-canvas border-border text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
<span
|
|
className="w-2.5 h-2.5 border border-strong flex-shrink-0"
|
|
style={{ backgroundColor: t.previewBg }}
|
|
/>
|
|
<span className="truncate">{t.name.replace('MindSift ', '').split(' ')[0]}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Menu Items */}
|
|
<div className="py-1">
|
|
<button
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowProfileMenu(false);
|
|
setActiveTab('user_settings');
|
|
}}
|
|
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
|
|
activeTab === 'user_settings' ? 'text-accent font-semibold' : 'text-main'
|
|
}`}
|
|
>
|
|
<Settings className="w-4 h-4 text-accent" />
|
|
<span>Kontoeinstellungen</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowProfileMenu(false);
|
|
setActiveTab('groups');
|
|
}}
|
|
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
|
|
activeTab === 'groups' ? 'text-accent font-semibold' : 'text-main'
|
|
}`}
|
|
>
|
|
<Users className="w-4 h-4 text-accent" />
|
|
<span>Gruppen</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleOpenHelp}
|
|
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
|
|
activeTab === 'help' ? 'text-accent font-semibold' : 'text-main'
|
|
}`}
|
|
>
|
|
<HelpCircle className="w-4 h-4 text-accent" />
|
|
<span>Hilfe & Anleitung</span>
|
|
</button>
|
|
|
|
{isServerAdmin && (
|
|
<button
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowProfileMenu(false);
|
|
setActiveTab('admin_settings');
|
|
}}
|
|
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
|
|
activeTab === 'admin_settings'
|
|
? 'text-accent font-semibold'
|
|
: 'text-main'
|
|
}`}
|
|
>
|
|
<Shield className="w-4 h-4 text-accent" />
|
|
<span>Administration</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Logout */}
|
|
<div className="py-1">
|
|
<button
|
|
onClick={handleLogout}
|
|
className="w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 text-status-err hover:bg-status-err-bg transition-colors"
|
|
>
|
|
<LogOut className="w-4 h-4" />
|
|
<span>Abmelden</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop Navigation tabs attached to header */}
|
|
<nav className="hidden sm:flex max-w-6xl mx-auto px-2 border-t border-border items-stretch justify-between">
|
|
<div className="flex items-stretch overflow-x-auto no-scrollbar">
|
|
<button
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setActiveTab('triage');
|
|
}}
|
|
className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`}
|
|
>
|
|
<Layers className="w-4 h-4" />
|
|
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-2' : ''}`}>
|
|
<span>Triage</span>
|
|
{stats.inboxCount > 0 && (
|
|
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
|
|
{stats.inboxCount}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setActiveTab('workspace');
|
|
}}
|
|
className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`}
|
|
>
|
|
<FileText className="w-4 h-4" />
|
|
<span className={`relative inline-flex items-center ${(stats.docsTodosOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
|
|
<span>Workspace</span>
|
|
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
|
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
|
|
{stats.docsTodosOpenCount}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setActiveTab('tasks');
|
|
}}
|
|
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
|
|
>
|
|
<CheckSquare className="w-4 h-4" />
|
|
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
|
|
<span>Aufgaben</span>
|
|
{stats.tasksOpenCount > 0 && (
|
|
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
|
|
{stats.tasksOpenCount}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setActiveTab('lists');
|
|
}}
|
|
className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`}
|
|
>
|
|
<ShoppingCart className="w-4 h-4" />
|
|
<span>Listen</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Borderless Circled Question Mark Help Button in Tab Bar */}
|
|
<div className="flex items-center pr-2 flex-shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={handleOpenHelp}
|
|
className={`p-1.5 transition-colors cursor-pointer rounded-full ${
|
|
activeTab === 'help'
|
|
? 'text-accent'
|
|
: 'text-muted hover:text-main'
|
|
}`}
|
|
title="Hilfe & Dokumentation"
|
|
>
|
|
<HelpCircle className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
</header>
|
|
|
|
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
|
|
|
|
{/* Main Container */}
|
|
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
|
|
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
|
<section>
|
|
{activeTab === 'triage' && (
|
|
<TriageDeck
|
|
deck={deck}
|
|
lists={lists}
|
|
onDeckChange={refreshAll}
|
|
onOpenDocument={handleOpenDocument}
|
|
onFocusCapture={handleTriggerAddButton}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'tasks' && (
|
|
<TasksView
|
|
currentUser={user}
|
|
onRefreshStats={refreshAll}
|
|
onOpenDocument={handleOpenDocument}
|
|
refreshTrigger={refreshRevision}
|
|
initialSelectedTaskId={selectedTaskId}
|
|
/>
|
|
)}
|
|
|
|
{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} />
|
|
)}
|
|
|
|
{activeTab === 'groups' && (
|
|
<GroupsView
|
|
currentUser={user}
|
|
onBack={() => setActiveTab(previousTab || 'triage')}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'user_settings' && (
|
|
<UserSettingsView
|
|
currentUser={user}
|
|
activeTheme={activeTheme}
|
|
onThemeChange={handleChangeTheme}
|
|
onUserUpdated={(u) => {
|
|
setUser(u);
|
|
refreshAll();
|
|
}}
|
|
onBack={() => setActiveTab('triage')}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'admin_settings' && isServerAdmin && (
|
|
<AdminSettingsView
|
|
currentUser={user}
|
|
onBack={() => setActiveTab('triage')}
|
|
/>
|
|
)}
|
|
</section>
|
|
</ViewErrorBoundary>
|
|
</main>
|
|
|
|
{/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
|
|
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
|
|
{/* Col 1: Triage */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setActiveTab('triage');
|
|
}}
|
|
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
|
|
activeTab === 'triage' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
|
}`}
|
|
title="Triage"
|
|
>
|
|
<div className="relative">
|
|
<Layers className="w-5 h-5" />
|
|
{stats.inboxCount > 0 && (
|
|
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
|
|
{stats.inboxCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-[10px] leading-tight">Triage</span>
|
|
</button>
|
|
|
|
{/* Col 2: Workspace */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setActiveTab('workspace');
|
|
}}
|
|
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
|
|
activeTab === 'workspace' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
|
}`}
|
|
title="Workspace"
|
|
>
|
|
<div className="relative">
|
|
<FileText className="w-5 h-5" />
|
|
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
|
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
|
|
{stats.docsTodosOpenCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-[10px] leading-tight">Workspace</span>
|
|
</button>
|
|
|
|
{/* Col 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" />
|
|
{stats.tasksOpenCount > 0 && (
|
|
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
|
|
{stats.tasksOpenCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-[10px] leading-tight">Aufgaben</span>
|
|
</button>
|
|
|
|
{/* Col 5: Listen */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setActiveTab('lists');
|
|
}}
|
|
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
|
|
activeTab === 'lists' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
|
}`}
|
|
title="Listen"
|
|
>
|
|
<ShoppingCart className="w-5 h-5" />
|
|
<span className="text-[10px] leading-tight">Listen</span>
|
|
</button>
|
|
</nav>
|
|
|
|
{/* MOBILE QUICK-ADD BOTTOM SHEET / MODAL (DESIGN-ALIGNED WITH TASKS VIEW) */}
|
|
{showMobileCapture && (
|
|
<div
|
|
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
|
onClick={() => setShowMobileCapture(false)}
|
|
>
|
|
<div
|
|
className="w-full max-h-[85vh] overflow-y-auto bg-panel border-t border-border rounded-t-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] animate-in slide-in-from-bottom duration-200"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between pb-3 border-b border-border mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
|
|
<Layers className="w-4 h-4" />
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-main">Quick Dump erfassen</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowMobileCapture(false)}
|
|
className="p-1.5 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleMobileCreateTriage} className="space-y-3">
|
|
{/* Text Input with Voice Dictation */}
|
|
<div>
|
|
<label className="text-[11px] font-semibold text-muted block mb-1">
|
|
Gedanke / Quick Dump
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
autoFocus
|
|
value={mobileTriageContent}
|
|
onChange={(e) => setMobileTriageContent(e.target.value)}
|
|
placeholder="Was geht dir durch den Kopf?…"
|
|
className="w-full input-field px-3 py-2 pr-10 text-sm"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={toggleMobileVoice}
|
|
className={`absolute right-1.5 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
|
|
mobileVoiceActive
|
|
? 'bg-accent text-on-accent animate-pulse'
|
|
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
|
}`}
|
|
title="Spracheingabe"
|
|
>
|
|
{mobileVoiceActive ? <MicOff className="w-3.5 h-3.5" /> : <Mic className="w-3.5 h-3.5" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Priority - 4 Buttons (Identical to TasksView) */}
|
|
<div className="space-y-1.5 pt-1">
|
|
<label className="text-[11px] font-semibold text-muted block">Priorität</label>
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
|
|
const config: Record<
|
|
PriorityLevel,
|
|
{ label: string; icon?: React.ReactNode; activeClass: string }
|
|
> = {
|
|
low: {
|
|
label: 'Niedrig',
|
|
activeClass: 'bg-canvas border-strong text-main font-semibold shadow-xs',
|
|
},
|
|
normal: {
|
|
label: 'Normal',
|
|
activeClass: 'bg-accent/15 border-accent text-accent font-semibold shadow-xs',
|
|
},
|
|
high: {
|
|
label: 'Wichtig',
|
|
icon: <AlertTriangle className="w-3 h-3 text-status-warn" />,
|
|
activeClass:
|
|
'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs',
|
|
},
|
|
urgent: {
|
|
label: 'Dringend',
|
|
icon: <Flame className="w-3 h-3 text-status-err" />,
|
|
activeClass:
|
|
'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs',
|
|
},
|
|
};
|
|
const active = mobileTriagePriority === p;
|
|
const item = config[p];
|
|
return (
|
|
<button
|
|
key={p}
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setMobileTriagePriority(p);
|
|
}}
|
|
className={`h-9 px-1 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
|
active
|
|
? item.activeClass
|
|
: 'bg-canvas border-border text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
{item.icon}
|
|
<span className="truncate">{item.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Due Date (Optional) */}
|
|
<div className="space-y-1 pt-1">
|
|
<label className="text-[11px] font-semibold text-muted flex items-center gap-1">
|
|
<Calendar className="w-3.5 h-3.5 text-accent" />
|
|
<span>Fällig am (optional)</span>
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={mobileTriageDueDate}
|
|
onChange={(e) => setMobileTriageDueDate(e.target.value)}
|
|
className="w-full input-field px-3 py-2 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
{/* Bottom Buttons */}
|
|
<div className="pt-2 flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowMobileCapture(false)}
|
|
className="btn-ghost h-10 px-4 text-xs cursor-pointer"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={!mobileTriageContent.trim()}
|
|
className="btn-primary h-10 px-5 text-xs font-semibold disabled:opacity-40 cursor-pointer"
|
|
>
|
|
Gedanke erfassen
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* MOBILE USER PROFILE SHEET */}
|
|
{showMobileProfile && (
|
|
<div
|
|
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
|
onClick={() => setShowMobileProfile(false)}
|
|
>
|
|
<div
|
|
className="w-full max-h-[85vh] overflow-y-auto bg-panel border-t border-border rounded-t-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] space-y-4 animate-in slide-in-from-bottom duration-200 divide-y divide-border"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* User Info Header */}
|
|
<div className="flex items-center justify-between pb-2">
|
|
<div className="flex items-center gap-3">
|
|
<UserAvatar user={user} size="lg" className="border border-border/80 shadow-sm" />
|
|
<div>
|
|
<p className="font-bold text-sm text-main">{user.displayName}</p>
|
|
<p className="text-xs text-muted">{user.email}</p>
|
|
<span className="inline-block mt-1 px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-[10px] text-accent font-semibold">
|
|
{roleLabel}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowMobileProfile(false)}
|
|
className="p-1 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Theme Quick Switcher */}
|
|
<div className="pt-3 space-y-2">
|
|
<span className="text-[11px] font-semibold text-muted uppercase tracking-wider flex items-center gap-1.5">
|
|
<Palette className="w-3.5 h-3.5 text-accent" />
|
|
<span>Design-Theme</span>
|
|
</span>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{AVAILABLE_THEMES.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
onClick={() => handleChangeTheme(t.id)}
|
|
className={`h-9 px-2.5 text-xs font-medium border rounded-btn flex items-center gap-2 transition-colors cursor-pointer ${
|
|
activeTheme === t.id
|
|
? 'bg-panel-elevated border-accent text-main font-semibold'
|
|
: 'bg-canvas border-border text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
<span
|
|
className="w-3 h-3 border border-strong flex-shrink-0 rounded-full"
|
|
style={{ backgroundColor: t.previewBg }}
|
|
/>
|
|
<span className="truncate">{t.name.replace('MindSift ', '')}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Menu Items */}
|
|
<div className="pt-3 space-y-1">
|
|
{!isStandaloneApp && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowMobileProfile(false);
|
|
setShowManualPwaGuide(false);
|
|
setShowPwaInstallModal(true);
|
|
}}
|
|
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 bg-accent/10 border border-accent/30 text-accent font-semibold transition-colors text-xs cursor-pointer mb-1"
|
|
>
|
|
<Smartphone className="w-4 h-4 text-accent" />
|
|
<span>Web-App auf Smartphone installieren</span>
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowMobileProfile(false);
|
|
setActiveTab('user_settings');
|
|
}}
|
|
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
|
|
activeTab === 'user_settings' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
|
|
}`}
|
|
>
|
|
<Settings className="w-4 h-4 text-accent" />
|
|
<span>Kontoeinstellungen</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowMobileProfile(false);
|
|
setActiveTab('groups');
|
|
}}
|
|
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
|
|
activeTab === 'groups' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
|
|
}`}
|
|
>
|
|
<Users className="w-4 h-4 text-accent" />
|
|
<span>Gruppen</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShowMobileProfile(false);
|
|
handleOpenHelp();
|
|
}}
|
|
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
|
|
activeTab === 'help' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
|
|
}`}
|
|
>
|
|
<HelpCircle className="w-4 h-4 text-accent" />
|
|
<span>Hilfe & Anleitung</span>
|
|
</button>
|
|
|
|
{isServerAdmin && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
triggerHaptic(10);
|
|
setShowMobileProfile(false);
|
|
setActiveTab('admin_settings');
|
|
}}
|
|
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
|
|
activeTab === 'admin_settings' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
|
|
}`}
|
|
>
|
|
<Shield className="w-4 h-4 text-accent" />
|
|
<span>Administration</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Logout */}
|
|
<div className="pt-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShowMobileProfile(false);
|
|
handleLogout();
|
|
}}
|
|
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 text-status-err hover:bg-status-err-bg transition-colors text-xs font-medium cursor-pointer"
|
|
>
|
|
<LogOut className="w-4 h-4" />
|
|
<span>Abmelden</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* IMMEDIATE MOBILE WEB-APP INSTALLATION OFFER SHEET */}
|
|
{showPwaInstallModal && !isStandaloneApp && (
|
|
<div
|
|
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/65 backdrop-blur-xs animate-in fade-in duration-150"
|
|
onClick={handleDismissPwaModal}
|
|
>
|
|
<div
|
|
className="w-full bg-panel border-t border-border rounded-t-2xl shadow-panel p-5 pb-[calc(1.75rem+env(safe-area-inset-bottom))] space-y-4 animate-in slide-in-from-bottom duration-200"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-12 h-12 rounded-2xl bg-accent text-on-accent shadow-md flex items-center justify-center flex-shrink-0">
|
|
<Brain className="w-6 h-6" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-base font-bold text-main leading-tight">
|
|
{authStatus.instanceName || 'MindSift'} als App installieren
|
|
</h3>
|
|
<p className="text-xs text-muted mt-0.5">
|
|
Vollbild ohne Browser-Leiste, schnellerer Start & Direktzugriff vom Home-Bildschirm.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleDismissPwaModal}
|
|
className="p-1.5 text-faint hover:text-main rounded-btn cursor-pointer"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{!showManualPwaGuide ? (
|
|
<div className="space-y-2.5 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={handleInstallWebApp}
|
|
className="btn-primary w-full h-12 text-sm font-semibold rounded-xl flex items-center justify-center gap-2 shadow-md active:scale-98 transition-transform cursor-pointer"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
<span>Jetzt als Web-App installieren</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleDismissPwaModal}
|
|
className="btn-action w-full h-10 text-xs rounded-xl justify-center cursor-pointer"
|
|
>
|
|
Später im Browser weiter nutzen
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3 pt-1">
|
|
<div className="p-3.5 rounded-xl bg-canvas border border-border space-y-2.5 text-xs text-main">
|
|
<p className="font-semibold text-accent flex items-center gap-1.5">
|
|
<Smartphone className="w-4 h-4" />
|
|
<span>In 2 Sekunden zum Home-Bildschirm hinzufügen:</span>
|
|
</p>
|
|
<ol className="space-y-2 text-muted list-decimal list-inside">
|
|
<li>
|
|
Tippe im Browser auf{' '}
|
|
<strong className="text-main inline-flex items-center gap-1">
|
|
<Share className="w-3.5 h-3.5 text-accent inline" /> Teilen
|
|
</strong>{' '}
|
|
(iPhone/Safari) oder das{' '}
|
|
<strong className="text-main">Browser-Menü (⋮)</strong>.
|
|
</li>
|
|
<li>
|
|
Wähle{' '}
|
|
<strong className="text-main">„Zum Home-Bildschirm“</strong> bzw.{' '}
|
|
<strong className="text-main">„App installieren“</strong>.
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleDismissPwaModal}
|
|
className="btn-primary w-full h-11 text-xs font-semibold rounded-xl justify-center cursor-pointer"
|
|
>
|
|
Verstanden
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App;
|