feat(auth,settings,tasks): add first-boot setup, real login/invites, full-page settings & admin views, multi-channel alarms, and fix null slice crashes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
This commit is contained in:
1 parent
50ac0c8f64
commit
7802eb2cf0
17 files changed
+4391
-1368
No files matched your search
+305
-91
@@ -1,23 +1,83 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Layers,
|
||||
CheckSquare,
|
||||
FileText,
|
||||
ShoppingCart,
|
||||
Brain,
|
||||
Settings,
|
||||
Shield,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { DashboardStats, SharedList, Thought, User } from './lib/types';
|
||||
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
|
||||
import { api, clearToken, getToken, triggerHaptic } from './lib/api';
|
||||
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 { SettingsModal } from './features/settings/SettingsModal';
|
||||
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
||||
import { UserSettingsView } from './features/settings/UserSettingsView';
|
||||
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
||||
|
||||
type ActiveTab = 'triage' | 'tasks' | 'workspace' | 'lists';
|
||||
type ActiveTab =
|
||||
| 'triage'
|
||||
| 'tasks'
|
||||
| 'workspace'
|
||||
| 'lists'
|
||||
| 'user_settings'
|
||||
| 'admin_settings';
|
||||
|
||||
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 bg-panel border border-border text-xs space-y-3 max-w-lg mx-auto my-8">
|
||||
<h3 className="text-sm font-bold text-white">Ansicht konnte nicht geladen werden</h3>
|
||||
<p className="text-zinc-400">{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 [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,
|
||||
@@ -34,7 +94,9 @@ export const App: React.FC = () => {
|
||||
const [captureFocusCount, setCaptureFocusCount] = useState(0);
|
||||
const [refreshRevision, setRefreshRevision] = useState(0);
|
||||
|
||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||
// Profile Dropdown State
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const refreshAll = async () => {
|
||||
try {
|
||||
@@ -45,49 +107,85 @@ export const App: React.FC = () => {
|
||||
]);
|
||||
setUser(meRes.user);
|
||||
setStats(meRes.stats);
|
||||
setDeck(deckRes);
|
||||
setLists(listsRes);
|
||||
setDeck(Array.isArray(deckRes) ? deckRes : []);
|
||||
setLists(Array.isArray(listsRes) ? listsRes : []);
|
||||
} catch {
|
||||
// Fallback to quickStart session
|
||||
try {
|
||||
const qs = await api.quickStart();
|
||||
setUser(qs.user);
|
||||
const [st, dk, ls] = await Promise.all([
|
||||
api.getStats(),
|
||||
api.getTriageDeck(),
|
||||
api.getAllLists(),
|
||||
]);
|
||||
setStats(st);
|
||||
setDeck(dk);
|
||||
setLists(ls);
|
||||
} catch {
|
||||
// Offline or waiting for server
|
||||
// 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(() => {
|
||||
const init = async () => {
|
||||
if (!getToken()) {
|
||||
try {
|
||||
await api.quickStart();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
checkBootAndAuth();
|
||||
|
||||
const onUnauthorized = async () => {
|
||||
setUser(null);
|
||||
setShowProfileMenu(false);
|
||||
try {
|
||||
const st = await api.getAuthStatus();
|
||||
setAuthStatus(st);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await api.flushOfflineQueue();
|
||||
await refreshAll();
|
||||
};
|
||||
init();
|
||||
|
||||
const onOnline = async () => {
|
||||
const synced = await api.flushOfflineQueue();
|
||||
if (synced > 0) refreshAll();
|
||||
if (getToken()) {
|
||||
const synced = await api.flushOfflineQueue();
|
||||
if (synced > 0) refreshAll();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mindsift:unauthorized', onUnauthorized);
|
||||
window.addEventListener('online', onOnline);
|
||||
return () => window.removeEventListener('online', onOnline);
|
||||
return () => {
|
||||
window.removeEventListener('mindsift:unauthorized', onUnauthorized);
|
||||
window.removeEventListener('online', onOnline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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'
|
||||
@@ -110,7 +208,6 @@ export const App: React.FC = () => {
|
||||
|
||||
const handleTriggerAddButton = () => {
|
||||
triggerHaptic(20);
|
||||
// Scroll smoothly to top and focus capture bar
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
setCaptureFocusCount((c) => c + 1);
|
||||
};
|
||||
@@ -118,11 +215,43 @@ export const App: React.FC = () => {
|
||||
const handleLogout = async () => {
|
||||
triggerHaptic(20);
|
||||
clearToken();
|
||||
await api.quickStart();
|
||||
await refreshAll();
|
||||
setShowProfileModal(false);
|
||||
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-zinc-400 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 isStaff = user.role === 'admin' || user.role === 'team_admin';
|
||||
const roleLabel =
|
||||
user.role === 'admin'
|
||||
? 'Server-Admin'
|
||||
: user.role === 'team_admin'
|
||||
? 'Team-Admin'
|
||||
: 'Mitglied';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas text-white flex flex-col font-sans selection:bg-accent/30">
|
||||
{/* Top Header Bar */}
|
||||
@@ -141,7 +270,7 @@ export const App: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-sm sm:text-base text-white tracking-tight leading-none">
|
||||
MindSift
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400 mt-0.5 hidden sm:inline-block">
|
||||
Der Kopf frei in drei Sekunden
|
||||
@@ -158,21 +287,83 @@ export const App: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User Profile Pill */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* User Profile Pill & Dropdown Menu */}
|
||||
<div className="relative flex items-center gap-2 flex-shrink-0" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowProfileModal(true);
|
||||
setShowProfileMenu((prev) => !prev);
|
||||
}}
|
||||
className="h-8 pl-1 pr-3 rounded-full bg-panel hover:bg-panel-elevated border border-border text-xs font-medium text-white flex items-center gap-2 transition-colors"
|
||||
title="Profil & Einstellungen"
|
||||
className="h-8 pl-1 pr-2.5 rounded-full bg-panel hover:bg-panel-elevated border border-border text-xs font-medium text-white flex items-center gap-2 transition-colors"
|
||||
title="Benutzermenü öffnen"
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-accent text-white text-[10px] font-bold flex items-center justify-center">
|
||||
{user?.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
||||
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
||||
</div>
|
||||
<span className="max-w-[120px] truncate hidden sm:inline">{user?.displayName || 'Konto'}</span>
|
||||
<span className="max-w-[120px] truncate hidden sm:inline">
|
||||
{user.displayName}
|
||||
</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-zinc-400" />
|
||||
</button>
|
||||
|
||||
{showProfileMenu && (
|
||||
<div className="absolute right-0 top-full mt-1.5 w-60 bg-panel border border-border shadow-2xl z-50 divide-y divide-border text-xs">
|
||||
{/* User Info Header */}
|
||||
<div className="p-3 bg-canvas">
|
||||
<p className="font-bold text-white truncate">{user.displayName}</p>
|
||||
<p className="text-[11px] text-zinc-400 truncate">{user.email}</p>
|
||||
<span className="inline-block mt-1.5 px-1.5 py-0.5 bg-panel border border-border text-[10px] text-accent font-semibold">
|
||||
{roleLabel}
|
||||
</span>
|
||||
</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-zinc-200'
|
||||
}`}
|
||||
>
|
||||
<Settings className="w-4 h-4 text-accent" />
|
||||
<span>Kontoeinstellungen</span>
|
||||
</button>
|
||||
|
||||
{isStaff && (
|
||||
<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-zinc-200'
|
||||
}`}
|
||||
>
|
||||
<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-red-400 hover:bg-red-950/30 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Abmelden</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -189,7 +380,13 @@ export const App: React.FC = () => {
|
||||
<Layers className="w-4 h-4" />
|
||||
<span>Triage</span>
|
||||
{stats.inboxCount > 0 && (
|
||||
<span className={activeTab === 'triage' ? 'badge-accent' : 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'}>
|
||||
<span
|
||||
className={
|
||||
activeTab === 'triage'
|
||||
? 'badge-accent'
|
||||
: 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'
|
||||
}
|
||||
>
|
||||
{stats.inboxCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -205,7 +402,13 @@ export const App: React.FC = () => {
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
<span>Aufgaben</span>
|
||||
{stats.tasksOpenCount > 0 && (
|
||||
<span className={activeTab === 'tasks' ? 'badge-accent' : 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'}>
|
||||
<span
|
||||
className={
|
||||
activeTab === 'tasks'
|
||||
? 'badge-accent'
|
||||
: 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'
|
||||
}
|
||||
>
|
||||
{stats.tasksOpenCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -220,7 +423,13 @@ export const App: React.FC = () => {
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>Workspace</span>
|
||||
<span className={activeTab === 'workspace' ? 'badge-accent' : 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'}>
|
||||
<span
|
||||
className={
|
||||
activeTab === 'workspace'
|
||||
? 'badge-accent'
|
||||
: 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'
|
||||
}
|
||||
>
|
||||
{stats.docsCount}
|
||||
</span>
|
||||
</button>
|
||||
@@ -235,58 +444,63 @@ export const App: React.FC = () => {
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
<span>Geteilte Listen</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Main Container */}
|
||||
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-6">
|
||||
{/* Tab View Content */}
|
||||
<section>
|
||||
{activeTab === 'triage' && (
|
||||
<TriageDeck
|
||||
deck={deck}
|
||||
lists={lists}
|
||||
onDeckChange={refreshAll}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onFocusCapture={handleTriggerAddButton}
|
||||
/>
|
||||
)}
|
||||
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
||||
<section>
|
||||
{activeTab === 'triage' && (
|
||||
<TriageDeck
|
||||
deck={deck}
|
||||
lists={lists}
|
||||
onDeckChange={refreshAll}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onFocusCapture={handleTriggerAddButton}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'tasks' && (
|
||||
<TasksView
|
||||
onRefreshStats={refreshAll}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
refreshTrigger={refreshRevision}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'tasks' && (
|
||||
<TasksView
|
||||
onRefreshStats={refreshAll}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
refreshTrigger={refreshRevision}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'workspace' && (
|
||||
<WorkspaceView
|
||||
initialDocId={selectedDocId}
|
||||
onStatsChange={refreshAll}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'workspace' && (
|
||||
<WorkspaceView
|
||||
initialDocId={selectedDocId}
|
||||
onStatsChange={refreshAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'lists' && (
|
||||
<SharedListsView onRefreshGlobal={refreshAll} />
|
||||
)}
|
||||
</section>
|
||||
{activeTab === 'lists' && (
|
||||
<SharedListsView onRefreshGlobal={refreshAll} />
|
||||
)}
|
||||
|
||||
{activeTab === 'user_settings' && (
|
||||
<UserSettingsView
|
||||
currentUser={user}
|
||||
onUserUpdated={(u) => {
|
||||
setUser(u);
|
||||
refreshAll();
|
||||
}}
|
||||
onBack={() => setActiveTab('triage')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'admin_settings' && isStaff && (
|
||||
<AdminSettingsView
|
||||
currentUser={user}
|
||||
onBack={() => setActiveTab('triage')}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</ViewErrorBoundary>
|
||||
</main>
|
||||
|
||||
{/* Profile, Settings, Discord Alarms, Webhooks, Teams & Admin Modal */}
|
||||
{showProfileModal && (
|
||||
<SettingsModal
|
||||
currentUser={user}
|
||||
onClose={() => setShowProfileModal(false)}
|
||||
onUserUpdated={(u) => {
|
||||
setUser(u);
|
||||
refreshAll();
|
||||
}}
|
||||
onLoggedOut={handleLogout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user