Files
MindSift/frontend/src/features/workspace/WorkspaceView.tsx
T
sandrodach 0e06cc5577
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
feat(collab): redesign note collaboration with REST auto-save, opt-in live session, and block locking
2026-10-27 14:02:38 +01:00

2566 lines
109 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
FilePlus,
FolderPlus,
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
Search,
Pin,
Trash2,
Check,
Hash,
FileText,
Users,
Plus,
X,
ExternalLink,
Sparkles,
ArrowLeft,
CheckSquare,
PanelLeftClose,
PanelLeftOpen,
ChevronLeft,
Radio,
Clock,
AlertTriangle,
Loader2,
Lock,
} from 'lucide-react';
import {
Document,
DocumentBlock,
DocShare,
SharedFolder,
TagDetails,
TagSummary,
Team,
UnifiedShare,
User,
} from '../../lib/types';
import {
api,
onWebSocketMessage,
triggerHaptic,
onRealtimeStatus,
isRealtimeConnected,
getRealtimeTransport,
RealtimeTransport,
} from '../../lib/api';
import { DocSyncStatus } from '../../lib/useDocumentSync';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus, DocLiveState } from './DocumentEditor';
interface WorkspaceViewProps {
currentUser?: User | null;
initialDocId?: string | null;
onStatsChange: () => void;
}
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags';
interface FolderTreeNode {
name: string;
fullPath: string;
subfolders: FolderTreeNode[];
docs: Document[];
sharedFolderObj?: SharedFolder;
}
interface FolderTreeItemProps {
node: FolderTreeNode;
depth: number;
expandedFolders: Record<string, boolean>;
setExpandedFolders: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
selectedDocId: string | null;
subView: WorkspaceSubView;
dragOverFolder: string | null;
setDragOverFolder: (f: string | null) => void;
handleDropOnFolder: (folderPath: string, e: React.DragEvent) => void;
handleFolderDragStart: (e: React.DragEvent, path: string) => void;
handleDocDragStart: (e: React.DragEvent, id: string) => void;
handleCreateDocument: (folderPath?: string) => void;
handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void;
handleDeleteFolder: (folderPath: string) => void;
onSelectDoc: (id: string) => void;
}
const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
node,
depth,
expandedFolders,
setExpandedFolders,
selectedDocId,
subView,
dragOverFolder,
setDragOverFolder,
handleDropOnFolder,
handleFolderDragStart,
handleDocDragStart,
handleCreateDocument,
handleShareFolderClick,
handleDeleteFolder,
onSelectDoc,
}) => {
const isExpanded = Boolean(expandedFolders[node.fullPath]);
const isDragOver = dragOverFolder === node.fullPath;
const hasContent = node.subfolders.length > 0 || node.docs.length > 0;
return (
<div className="space-y-0.5">
<div
draggable
onDragStart={(e) => handleFolderDragStart(e, node.fullPath)}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverFolder(node.fullPath);
}}
onDragLeave={(e) => {
e.preventDefault();
if (dragOverFolder === node.fullPath) setDragOverFolder(null);
}}
onDrop={(e) => handleDropOnFolder(node.fullPath, e)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
className={`group flex items-center justify-between pr-1.5 h-7 rounded-btn text-xs cursor-pointer border-l-2 transition-colors ${
isDragOver
? 'bg-accent/20 border-accent text-main font-semibold'
: 'border-transparent hover:bg-panel-elevated text-muted'
}`}
>
<button
type="button"
onClick={() =>
setExpandedFolders((prev) => ({
...prev,
[node.fullPath]: !prev[node.fullPath],
}))
}
className="flex items-center gap-1.5 flex-1 min-w-0 text-left h-full cursor-pointer"
>
{hasContent ? (
isExpanded ? (
<ChevronDown className="w-3.5 h-3.5 text-faint shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-faint shrink-0" />
)
) : (
<span className="w-3.5 shrink-0" />
)}
{isExpanded ? (
<FolderOpen className="w-3.5 h-3.5 text-accent shrink-0" />
) : (
<Folder className="w-3.5 h-3.5 text-accent shrink-0" />
)}
<span className="truncate">{node.name}</span>
{node.sharedFolderObj && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
)}
<span className="text-[10px] text-faint ml-auto pr-1">
{node.docs.length}
</span>
</button>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity shrink-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleCreateDocument(node.fullPath);
}}
className="p-1 h-6 w-6 flex items-center justify-center rounded-btn hover:bg-canvas text-muted hover:text-main cursor-pointer"
title="Notiz in diesem Ordner erstellen"
>
<Plus className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleShareFolderClick(node.fullPath, node.sharedFolderObj);
}}
className={`p-1 h-6 w-6 flex items-center justify-center rounded-btn hover:bg-canvas cursor-pointer ${
node.sharedFolderObj ? 'text-accent' : 'text-faint hover:text-accent'
}`}
title="Ordner teilen und Berechtigungen"
>
<Users className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(node.fullPath);
}}
className="p-1 h-6 w-6 flex items-center justify-center rounded-btn hover:bg-status-err/20 text-faint hover:text-status-err cursor-pointer"
title="Ordner löschen"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
{isExpanded && (
<div className="space-y-0.5">
{/* Subfolders in this folder */}
{node.subfolders.map((childNode) => (
<FolderTreeItem
key={childNode.fullPath}
node={childNode}
depth={depth + 1}
expandedFolders={expandedFolders}
setExpandedFolders={setExpandedFolders}
selectedDocId={selectedDocId}
subView={subView}
dragOverFolder={dragOverFolder}
setDragOverFolder={setDragOverFolder}
handleDropOnFolder={handleDropOnFolder}
handleFolderDragStart={handleFolderDragStart}
handleDocDragStart={handleDocDragStart}
handleCreateDocument={handleCreateDocument}
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={onSelectDoc}
/>
))}
{/* Notes directly in this folder */}
{node.docs.map((d) => (
<div
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
onClick={() => onSelectDoc(d.id)}
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
className={`w-full flex items-center gap-2 pr-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
? 'bg-panel border-accent text-main font-semibold shadow-xs'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
</div>
))}
{!hasContent && (
<div
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
className="py-1 text-[10px] text-faint italic"
>
Leer
</div>
)}
</div>
)}
</div>
);
};
export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
currentUser,
initialDocId,
onStatsChange,
}) => {
const [docs, setDocs] = useState<Document[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [sharedFolders, setSharedFolders] = useState<SharedFolder[]>([]);
const [tagsSummary, setTagsSummary] = useState<TagSummary[]>([]);
const [selectedDocId, setSelectedDocId] = useState<string | null>(initialDocId || null);
const [targetBlockId, setTargetBlockId] = useState<string | null>(null);
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
const [subView, setSubView] = useState<WorkspaceSubView>('editor');
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
const [tagNoteDraft, setTagNoteDraft] = useState<string>('');
const [tagSearchQuery, setTagSearchQuery] = useState<string>('');
const [tagSortBy, setTagSortBy] = useState<'count' | 'name'>('count');
const [searchQuery, setSearchQuery] = useState<string>('');
const [expandedFolders, setExpandedFolders] = useState<Record<string, boolean>>({});
const [showNewFolderModal, setShowNewFolderModal] = useState<boolean>(false);
const [newFolderName, setNewFolderName] = useState<string>('');
const [newFolderIsShared, setNewFolderIsShared] = useState<boolean>(false);
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(Boolean(initialDocId));
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState<boolean>(() => {
try {
return localStorage.getItem('mindsift:workspace_sidebar_collapsed') === 'true';
} catch {
return false;
}
});
const toggleSidebar = useCallback(() => {
setIsSidebarCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem('mindsift:workspace_sidebar_collapsed', String(next));
} catch {}
return next;
});
}, []);
// Realtime connection status for top header indicator (identical to SharedListsView)
const [realtimeConnected, setRealtimeConnected] = useState<boolean>(() => isRealtimeConnected());
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>(() => getRealtimeTransport());
const [activeDocSyncStatus, setActiveDocSyncStatus] = useState<DocSyncStatus>('connected');
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
const [docSaveError, setDocSaveError] = useState<string | null>(null);
const [docLiveState, setDocLiveState] = useState<DocLiveState>('idle');
const [docLiveInitiator, setDocLiveInitiator] = useState<string | null>(null);
const [showWsBlockedModal, setShowWsBlockedModal] = useState<boolean>(false);
const liveToggleRef = useRef<(() => void) | null>(null);
useEffect(() => {
const unsub = onRealtimeStatus((connected, transport) => {
setRealtimeConnected(connected);
setRealtimeTransport(transport);
});
return unsub;
}, []);
// Drag and Drop state
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
// Rollup drilldown & View Mode
const [todoFolderDrilldown, setTodoFolderDrilldown] = useState<string | null>(null);
const [todoGrouping, setTodoGrouping] = useState<'folder_note' | 'note' | 'folder'>(() => {
return (localStorage.getItem('mindsift_todo_grouping') as any) || 'folder_note';
});
const [collapsedTodoFolders, setCollapsedTodoFolders] = useState<Set<string>>(new Set());
const [collapsedTodoNotes, setCollapsedTodoNotes] = useState<Set<string>>(new Set());
// Sharing Modal State
const [shareModalDoc, setShareModalDoc] = useState<Document | null>(null);
const [shareModalFolder, setShareModalFolder] = useState<SharedFolder | null>(null);
const [activeDocShares, setActiveDocShares] = useState<DocShare[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [collaborators, setCollaborators] = useState<User[]>([]);
const isCreatingRef = useRef<boolean>(false);
const { setGroups, setPrimaryAction } = useMobileActions();
const loadWorkspaceData = useCallback(async () => {
try {
const [docsRes, foldersRes, sharedFoldersRes, tagsRes] = await Promise.all([
api.getDocuments(),
api.getFolders(),
api.getSharedFolders().catch(() => []),
api.getWorkspaceTags().catch(() => []),
]);
setDocs(docsRes);
setFolders(foldersRes);
setSharedFolders(sharedFoldersRes);
setTagsSummary(tagsRes);
setSelectedDocId((prevId) => {
if (prevId && docsRes.some((d) => d.id === prevId)) {
return prevId;
}
return docsRes.length > 0 ? docsRes[0].id : null;
});
} catch {
// ignore
}
}, []);
useEffect(() => {
loadWorkspaceData();
}, [loadWorkspaceData]);
useEffect(() => {
if (initialDocId) {
setSelectedDocId(initialDocId);
setSubView('editor');
setMobileShowDetail(true);
}
}, [initialDocId]);
// Global WebSocket listener with strict isolation & deduplication
useEffect(() => {
const unsub = onWebSocketMessage((msg) => {
if (msg.event === 'DOC_CREATED' && msg.payload) {
const created = msg.payload as Document;
// Strict Privacy Check: Only add if created by or shared with current user
if (currentUser) {
const isOwner = created.userId === currentUser.id;
const isShared =
created.sharedWith?.some(
(s) => s.userId === currentUser.id || s.targetId === currentUser.id
) || false;
const isFolderShared = Boolean(created.sharedFolderId || created.isSharedFolder);
if (!isOwner && !isShared && !isFolderShared) {
return;
}
}
setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev]));
} else if (msg.event === 'DOC_UPDATED' && msg.payload) {
const updated = msg.payload as Document;
setDocs((prev) => prev.map((d) => (d.id === updated.id ? { ...d, ...updated } : d)));
} else if (msg.event === 'DOC_DELETED' && msg.payload?.id) {
const delId = msg.payload.id as string;
setDocs((prev) => prev.filter((d) => d.id !== delId));
setSelectedDocId((prev) => (prev === delId ? null : prev));
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
api.getFolders().then(setFolders).catch(() => {});
api.getSharedFolders().then(setSharedFolders).catch(() => {});
api.getDocuments().then(setDocs).catch(() => {});
}
});
return unsub;
}, [currentUser]);
const handleCreateDocument = useCallback(
async (folderPath = '') => {
if (isCreatingRef.current) return;
isCreatingRef.current = true;
triggerHaptic(10);
try {
const created = await api.createDocument({
title: 'Neue Notiz',
icon: '📝',
folderPath,
blocks: [
{
id: `blk_${Date.now().toString(36)}_0`,
type: 'text',
content: '',
level: 0,
position: 1000,
},
],
});
setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev]));
setNewlyCreatedDocId(created.id);
setTargetBlockId(null);
setSelectedDocId(created.id);
setSubView('editor');
setMobileShowDetail(true);
if (folderPath) {
setExpandedFolders((prev) => ({ ...prev, [folderPath]: true }));
}
onStatsChange();
} catch {
// ignore
} finally {
setTimeout(() => {
isCreatingRef.current = false;
}, 300);
}
},
[onStatsChange]
);
const handleJumpToDocBlock = useCallback((docId: string, blockId?: string) => {
setSelectedDocId(docId);
setTargetBlockId(blockId || null);
setNewlyCreatedDocId(null);
setSubView('editor');
setMobileShowDetail(true);
}, []);
// Register mobile FAB actions
useEffect(() => {
setPrimaryAction(() => handleCreateDocument(''));
setGroups([
{
id: 'workspace-actions',
title: 'Notizen & Ordner',
items: [
{
id: 'new-note',
label: 'Neue Notiz',
icon: <FilePlus className="w-4 h-4" />,
variant: 'accent',
onClick: () => handleCreateDocument(''),
},
{
id: 'new-folder',
label: 'Neuer Ordner',
icon: <FolderPlus className="w-4 h-4" />,
onClick: () => setShowNewFolderModal(true),
},
],
},
]);
return () => {
setGroups([]);
setPrimaryAction(undefined);
};
}, [handleCreateDocument, setGroups, setPrimaryAction]);
const handleDeleteDocument = useCallback(
async (docId: string) => {
try {
await api.deleteDocument(docId);
setDocs((prev) => {
const remaining = prev.filter((d) => d.id !== docId);
if (selectedDocId === docId) {
setSelectedDocId(remaining.length > 0 ? remaining[0].id : null);
}
return remaining;
});
setMobileShowDetail(false);
onStatsChange();
} catch {
// ignore
}
},
[onStatsChange, selectedDocId]
);
const handleDocMetadataChange = useCallback((updated: Document) => {
setDocs((prev) => prev.map((d) => (d.id === updated.id ? { ...d, ...updated } : d)));
}, []);
const handleCreateFolderSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const clean = newFolderName.trim().replace(/^\/+|\/+$/g, '');
if (!clean) return;
try {
if (newFolderIsShared) {
const sf = await api.createSharedFolder({ name: clean, mountPath: clean });
setSharedFolders((prev) => [...prev, sf]);
}
const res = await api.createFolder(clean);
setFolders((prev) => (prev.includes(res.path) ? prev : [...prev, res.path].sort()));
const parts = res.path.split('/');
let acc = '';
const expansions: Record<string, boolean> = {};
for (const part of parts) {
acc = acc ? `${acc}/${part}` : part;
expansions[acc] = true;
}
setExpandedFolders((prev) => ({ ...prev, ...expansions }));
setNewFolderName('');
setNewFolderIsShared(false);
setShowNewFolderModal(false);
} catch {
// ignore
}
};
const handleDeleteFolder = async (folderPath: string) => {
try {
const res = await api.deleteFolder(folderPath);
setFolders((prev) =>
prev.filter((f) => f !== folderPath && !f.startsWith(`${folderPath}/`))
);
if (res.deletedDocIds && res.deletedDocIds.length > 0) {
setDocs((prev) => prev.filter((d) => !res.deletedDocIds.includes(d.id)));
if (selectedDocId && res.deletedDocIds.includes(selectedDocId)) {
setSelectedDocId(null);
}
}
onStatsChange();
} catch {
// ignore
}
};
// HTML5 Drag & Drop handlers for moving documents into folders
const handleDocDragStart = (e: React.DragEvent, docId: string) => {
e.stopPropagation();
e.dataTransfer.setData('application/mindsift-doc-id', docId);
e.dataTransfer.setData('text/plain', docId);
e.dataTransfer.effectAllowed = 'move';
setDraggingDocId(docId);
};
const handleFolderDragStart = (e: React.DragEvent, path: string) => {
e.stopPropagation();
e.dataTransfer.setData('application/mindsift-folder-path', path);
e.dataTransfer.setData('text/plain', path);
e.dataTransfer.effectAllowed = 'move';
setDraggingFolderPath(path);
};
const handleDropOnFolder = async (targetFolder: string, e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOverFolder(null);
const docId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
const folderPath = e.dataTransfer.getData('application/mindsift-folder-path') || draggingFolderPath;
setDraggingDocId(null);
setDraggingFolderPath(null);
if (docId) {
triggerHaptic(15);
try {
const updatedDoc = await api.moveDocument(docId, targetFolder);
setDocs((prev) =>
prev.map((d) => (d.id === docId ? { ...d, folderPath: updatedDoc.folderPath } : d))
);
if (targetFolder) {
const parts = targetFolder.split('/');
let acc = '';
const expansions: Record<string, boolean> = {};
for (const part of parts) {
acc = acc ? `${acc}/${part}` : part;
expansions[acc] = true;
}
setExpandedFolders((prev) => ({ ...prev, ...expansions }));
}
} catch {
// ignore
}
} else if (folderPath && folderPath !== targetFolder && !targetFolder.startsWith(`${folderPath}/`)) {
triggerHaptic(15);
try {
await api.moveFolder(folderPath, targetFolder);
if (targetFolder) {
const parts = targetFolder.split('/');
let acc = '';
const expansions: Record<string, boolean> = {};
for (const part of parts) {
acc = acc ? `${acc}/${part}` : part;
expansions[acc] = true;
}
setExpandedFolders((prev) => ({ ...prev, ...expansions }));
}
loadWorkspaceData();
} catch {
// ignore
}
}
};
const handleSelectTag = useCallback(async (tag: string) => {
const cleanTag = tag.replace(/^#/, '').toLowerCase();
setSelectedTag(cleanTag);
setSubView('tag_detail');
setMobileShowDetail(true);
try {
const details = await api.getTagDetails(cleanTag);
setTagDetails(details);
setTagNoteDraft(details.noteContent || '');
} catch {
setTagDetails(null);
}
}, []);
const handleSaveTagNote = async () => {
if (!selectedTag) return;
try {
await api.updateTagPageNote(selectedTag, tagNoteDraft);
} catch {
// ignore
}
};
const handleWikiLinkClick = useCallback(
(pageTitle: string) => {
const clean = pageTitle.trim().toLowerCase();
const match = docs.find((d) => d.title.trim().toLowerCase() === clean);
if (match) {
setSelectedDocId(match.id);
setSubView('editor');
setMobileShowDetail(true);
} else {
handleCreateDocument('');
}
},
[docs, handleCreateDocument]
);
const openDocShareModal = useCallback(async (doc: Document) => {
setShareModalDoc(doc);
setShareModalFolder(null);
try {
const [sharesRes, teamsRes, collabRes] = await Promise.all([
api.getDocShares(doc.id).catch(() => []),
api.getUserTeams().catch(() => []),
api.getUsersDirectory().catch(() => []),
]);
setActiveDocShares(sharesRes);
setTeams(teamsRes);
setCollaborators(collabRes);
} catch {
// ignore
}
}, []);
const openFolderShareModal = useCallback(async (sf: SharedFolder) => {
setShareModalFolder(sf);
setShareModalDoc(null);
try {
const [teamsRes, collabRes, freshSf] = await Promise.all([
api.getUserTeams().catch(() => []),
api.getUsersDirectory().catch(() => []),
api.getSharedFolder(sf.id).catch(() => sf),
]);
setShareModalFolder(freshSf);
setTeams(teamsRes);
setCollaborators(collabRes);
} catch {
// ignore
}
}, []);
const handleShareFolderClick = useCallback(
async (folderPath: string, existingSf?: SharedFolder) => {
if (existingSf) {
openFolderShareModal(existingSf);
return;
}
try {
const clean = folderPath.trim().replace(/^\/+|\/+$/g, '');
const sf = await api.createSharedFolder({ name: clean, mountPath: clean });
setSharedFolders((prev) => [...prev.filter((f) => f.id !== sf.id), sf]);
openFolderShareModal(sf);
} catch {
// ignore
}
},
[openFolderShareModal]
);
const handleToggleRollupTodo = async (doc: Document, blockId: string, currentChecked: boolean) => {
triggerHaptic(10);
const updatedBlocks = (doc.blocks || []).map((b) =>
b.id === blockId ? { ...b, checked: !currentChecked } : b
);
setDocs((prev) =>
prev.map((d) => (d.id === doc.id ? { ...d, blocks: updatedBlocks } : d))
);
try {
await api.patchDocumentBlocks(doc.id, {
upserted: [{ id: blockId, checked: !currentChecked, type: 'todo', content: '' }],
});
} catch {
loadWorkspaceData();
}
};
const allFolderPaths = useMemo(() => {
const set = new Set<string>(folders);
for (const d of docs) {
if (d.folderPath) set.add(d.folderPath);
}
for (const sf of sharedFolders) {
if (sf.mountPath) set.add(sf.mountPath);
}
return Array.from(set).sort();
}, [docs, folders, sharedFolders]);
const filteredDocs = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return docs;
return docs.filter(
(d) =>
d.title.toLowerCase().includes(q) ||
(d.content && d.content.toLowerCase().includes(q)) ||
(d.tags && d.tags.some((t) => t.toLowerCase().includes(q)))
);
}, [docs, searchQuery]);
const folderTree = useMemo(() => {
const rootNodes: FolderTreeNode[] = [];
const nodeMap = new Map<string, FolderTreeNode>();
const allPathsSet = new Set<string>();
for (const p of allFolderPaths) {
const parts = p.split('/');
let current = '';
for (const part of parts) {
current = current ? `${current}/${part}` : part;
allPathsSet.add(current);
}
}
const sortedPaths = Array.from(allPathsSet).sort((a, b) => {
const depthA = a.split('/').length;
const depthB = b.split('/').length;
if (depthA !== depthB) return depthA - depthB;
return a.localeCompare(b);
});
for (const p of sortedPaths) {
const parts = p.split('/');
const leafName = parts[parts.length - 1];
const sharedFolderObj = sharedFolders.find(
(sf) => sf.mountPath === p || sf.name === p
);
const folderDocs = filteredDocs.filter((d) => d.folderPath === p);
const node: FolderTreeNode = {
name: leafName,
fullPath: p,
subfolders: [],
docs: folderDocs,
sharedFolderObj,
};
nodeMap.set(p, node);
if (parts.length === 1) {
rootNodes.push(node);
} else {
const parentPath = parts.slice(0, -1).join('/');
const parentNode = nodeMap.get(parentPath);
if (parentNode) {
parentNode.subfolders.push(node);
} else {
rootNodes.push(node);
}
}
}
return rootNodes;
}, [allFolderPaths, filteredDocs, sharedFolders]);
const pinnedDocs = useMemo(
() => filteredDocs.filter((d) => d.isPinned),
[filteredDocs]
);
const rootDocs = useMemo(
() => filteredDocs.filter((d) => !d.folderPath),
[filteredDocs]
);
// Global To-Dos rollup collections
const allRollupTodos = useMemo(() => {
const list: { doc: Document; blockId: string; content: string; checked: boolean }[] = [];
for (const d of docs) {
const fName = d.folderPath || 'Hauptebene';
if (todoFolderDrilldown && fName !== todoFolderDrilldown) continue;
for (const b of d.blocks || []) {
if (b.type === 'todo' && b.content.trim() !== '') {
list.push({ doc: d, blockId: b.id, content: b.content, checked: Boolean(b.checked) });
}
}
}
return list;
}, [docs, todoFolderDrilldown]);
const rollupTodosByFolder = useMemo(() => {
const map = new Map<string, { doc: Document; blockId: string; content: string; checked: boolean }[]>();
for (const item of allRollupTodos) {
const fName = item.doc.folderPath || 'Hauptebene';
const arr = map.get(fName) || [];
arr.push(item);
map.set(fName, arr);
}
return map;
}, [allRollupTodos]);
const rollupTodosByNote = useMemo(() => {
const map = new Map<
string,
{ doc: Document; items: { doc: Document; blockId: string; content: string; checked: boolean }[] }
>();
for (const item of allRollupTodos) {
const existing = map.get(item.doc.id);
if (existing) {
existing.items.push(item);
} else {
map.set(item.doc.id, { doc: item.doc, items: [item] });
}
}
return map;
}, [allRollupTodos]);
const totalOpenTodos = useMemo(() => {
let count = 0;
for (const d of docs) {
for (const b of d.blocks || []) {
if (b.type === 'todo' && !b.checked && b.content.trim() !== '') {
count++;
}
}
}
return count;
}, [docs]);
// Real-time matched blocks and all nested children for the selected hashtag
const activeTagGroups = useMemo(() => {
if (!selectedTag) return [];
const cleanTag = selectedTag.toLowerCase();
const map = new Map<
string,
{
docId: string;
title: string;
icon: string;
folderPath: string;
matchedBlocks: { rootBlock: DocumentBlock; children: DocumentBlock[] }[];
}
>();
for (const d of docs) {
const blks = d.blocks || [];
if (blks.length === 0) continue;
const matched: { rootBlock: DocumentBlock; children: DocumentBlock[] }[] = [];
const processedChildIds = new Set<string>();
for (let i = 0; i < blks.length; i++) {
const b = blks[i];
if (processedChildIds.has(b.id)) continue;
const content = b.content || '';
const regex = new RegExp(`(?:^|[\\s(])#(${cleanTag})(?=[\\s.,!?:;)#\\]}]|$)`, 'i');
if (regex.test(content)) {
const children: DocumentBlock[] = [];
const rootLevel = b.level || 0;
for (let j = i + 1; j < blks.length; j++) {
const nextB = blks[j];
if ((nextB.level || 0) > rootLevel) {
children.push(nextB);
processedChildIds.add(nextB.id);
} else {
break;
}
}
matched.push({ rootBlock: b, children });
}
}
if (matched.length > 0) {
map.set(d.id, {
docId: d.id,
title: d.title || 'Unbenannte Notiz',
icon: d.icon || '📝',
folderPath: d.folderPath || '',
matchedBlocks: matched,
});
}
}
if (tagDetails?.documents) {
for (const dg of tagDetails.documents) {
if (!map.has(dg.docId)) {
map.set(dg.docId, {
docId: dg.docId,
title: dg.title || 'Unbenannte Notiz',
icon: dg.icon || '📝',
folderPath: dg.folderPath || '',
matchedBlocks: dg.matchedBlocks || [],
});
}
}
}
return Array.from(map.values());
}, [docs, selectedTag, tagDetails]);
const activeTagTotalMatches = useMemo(() => {
let count = 0;
for (const g of activeTagGroups) {
count += g.matchedBlocks.length;
}
return count;
}, [activeTagGroups]);
const selectedDocObj = useMemo(
() => docs.find((d) => d.id === selectedDocId) || null,
[docs, selectedDocId]
);
const unifiedShares: UnifiedShare[] = useMemo(() => {
if (shareModalDoc) {
return activeDocShares.map((s) => ({
id: s.userId || s.teamId || s.id,
targetType: s.targetType || (s.teamId ? 'team' : 'user'),
targetId: s.targetId || s.teamId || s.userId || '',
targetName: s.targetName || s.displayName || s.userEmail || 'Mitglied',
targetEmail: s.userEmail,
permission: s.permission === 'write' ? 'editor' : 'viewer',
createdAt: s.createdAt,
}));
}
if (shareModalFolder) {
return (shareModalFolder.shares || []).map((s) => ({
id: s.id,
targetType: s.targetType,
targetId: s.targetId,
targetName: s.targetName || s.email || 'Mitglied',
targetEmail: s.email,
permission: s.permission === 'write' ? 'editor' : 'viewer',
createdAt: s.createdAt,
}));
}
return [];
}, [activeDocShares, shareModalDoc, shareModalFolder]);
// Aggregated metadata map for every tag (documents where it occurs, open todos count)
const tagsMetadataMap = useMemo(() => {
const map = new Map<
string,
{
tag: string;
count: number;
docs: { id: string; title: string; icon?: string; folderPath?: string }[];
openTodosCount: number;
}
>();
for (const t of tagsSummary) {
const cleanTag = t.tag.toLowerCase();
const matchingDocs: { id: string; title: string; icon?: string; folderPath?: string }[] = [];
let openTodos = 0;
for (const d of docs) {
let hasInDoc = false;
const blocks = d.blocks || [];
for (const b of blocks) {
const content = b.content || '';
const regex = new RegExp(`(?:^|[\\s(])#(${cleanTag})(?=[\\s.,!?:;)#\\]}]|$)`, 'i');
if (regex.test(content)) {
hasInDoc = true;
if (b.type === 'todo' && !b.checked) openTodos++;
}
}
if (hasInDoc) {
matchingDocs.push({
id: d.id,
title: d.title || 'Unbenannte Notiz',
icon: d.icon,
folderPath: d.folderPath,
});
}
}
map.set(cleanTag, {
tag: t.tag,
count: t.count,
docs: matchingDocs,
openTodosCount: openTodos,
});
}
return map;
}, [tagsSummary, docs]);
// Filtered and sorted tags for the Kachel Grid view
const filteredTags = useMemo(() => {
const q = tagSearchQuery.trim().toLowerCase().replace(/^#/, '');
let list = [...tagsSummary];
if (q) {
list = list.filter((t) => t.tag.toLowerCase().includes(q));
}
if (tagSortBy === 'count') {
list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
} else {
list.sort((a, b) => a.tag.localeCompare(b.tag));
}
return list;
}, [tagsSummary, tagSearchQuery, tagSortBy]);
return (
<div className="w-full space-y-3">
{/* Top Header: Workspace Tabs & Actions */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
{/* Workspace selector tabs & left sidebar toggle */}
<div className="flex flex-wrap items-center gap-1">
<button
type="button"
disabled={subView !== 'editor'}
onClick={subView === 'editor' ? toggleSidebar : undefined}
className={`hidden sm:inline-flex h-8 w-8 items-center justify-center rounded-btn border transition-colors ${
subView !== 'editor'
? 'opacity-35 bg-canvas border-border text-faint cursor-not-allowed pointer-events-none'
: isSidebarCollapsed
? 'bg-panel border-accent text-accent cursor-pointer'
: 'bg-canvas border-border text-muted hover:text-main cursor-pointer'
}`}
title={
subView !== 'editor'
? 'Ordnerleiste nur im Notizen-Modus verfügbar'
: isSidebarCollapsed
? 'Ordnerleiste einblenden'
: 'Ordnerleiste einklappen'
}
aria-label={
subView !== 'editor'
? 'Ordnerleiste nur im Notizen-Modus verfügbar'
: isSidebarCollapsed
? 'Ordnerleiste einblenden'
: 'Ordnerleiste einklappen'
}
>
{isSidebarCollapsed ? (
<PanelLeftOpen className="w-4 h-4" />
) : (
<PanelLeftClose className="w-4 h-4" />
)}
</button>
<button
type="button"
onClick={() => setSubView('editor')}
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
subView === 'editor'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<FileText className="w-3.5 h-3.5 text-accent" />
<span>Notizen</span>
{docs.length > 0 && <span className="ml-1 badge-neutral text-[10px]">{docs.length}</span>}
</button>
<button
type="button"
onClick={() => {
setSubView('todos');
setMobileShowDetail(true);
}}
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
subView === 'todos'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Check className="w-3.5 h-3.5 text-status-ok" />
<span>To-Dos</span>
{totalOpenTodos > 0 && <span className="ml-1 badge-neutral text-[10px]">{totalOpenTodos}</span>}
</button>
<button
type="button"
onClick={() => {
setSubView('tags');
setMobileShowDetail(true);
}}
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
subView === 'tags' || subView === 'tag_detail'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Hash className="w-3.5 h-3.5 text-accent" />
<span>Hashtags</span>
{tagsSummary.length > 0 && <span className="ml-1 badge-neutral text-[10px]">{tagsSummary.length}</span>}
</button>
</div>
{/* Right Header Section: Save Status & Live Collaboration Button */}
<div className="flex items-center gap-2">
{subView === 'editor' && selectedDocId ? (
<>
{/* Save Status Pill */}
<div
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${
docSaveStatus === 'saved'
? 'bg-canvas border-border text-muted'
: docSaveStatus === 'saving'
? 'bg-panel border-accent/40 text-accent animate-pulse'
: docSaveStatus === 'unsaved'
? 'bg-status-warn/10 border-status-warn/30 text-status-warn'
: 'bg-status-err/10 border-status-err/30 text-status-err'
}`}
title={
docSaveStatus === 'saved'
? 'Alle Änderungen per HTTPS gesichert'
: docSaveStatus === 'saving'
? 'Änderungen werden per HTTPS gespeichert…'
: docSaveStatus === 'unsaved'
? 'Ungespeicherte Änderungen (Auto-Save aktiv)'
: docSaveError || 'Fehler beim Speichern'
}
>
{docSaveStatus === 'saved' && (
<>
<Check className="w-3.5 h-3.5 text-status-ok" />
<span>Gespeichert</span>
</>
)}
{docSaveStatus === 'saving' && (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
<span>Speichert…</span>
</>
)}
{docSaveStatus === 'unsaved' && (
<>
<Clock className="w-3.5 h-3.5 text-status-warn" />
<span>Ungespeichert</span>
</>
)}
{docSaveStatus === 'error' && (
<>
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
<span>Fehler</span>
</>
)}
</div>
{/* Interactive Live Collaboration Button (replaces passive radio icon) */}
{docLiveState === 'locked_by_other' ? (
<div
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-status-warn/15 border border-status-warn/40 text-status-warn select-none"
title={`Live-Zusammenarbeit aktiv durch ${docLiveInitiator || 'anderer Nutzer'}`}
>
<Lock className="w-3.5 h-3.5 shrink-0" />
<span className="truncate max-w-[120px]">Live: {docLiveInitiator || 'Gesperrt'}</span>
</div>
) : docLiveState === 'blocked' ? (
<button
type="button"
onClick={() => setShowWsBlockedModal(true)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-status-err/15 border border-status-err/40 text-status-err hover:bg-status-err/25 transition-colors cursor-pointer"
title="WebSockets im Firmennetzwerk blockiert – Hier klicken für Infos"
>
<AlertTriangle className="w-3.5 h-3.5 text-status-err animate-pulse shrink-0" />
<span>Live blockiert</span>
</button>
) : docLiveState === 'connecting' ? (
<button
type="button"
disabled
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-amber-500/15 border border-amber-500/40 text-amber-500 opacity-80 cursor-wait"
>
<Loader2 className="w-3.5 h-3.5 animate-spin text-amber-500 shrink-0" />
<span>Verbinde…</span>
</button>
) : docLiveState === 'active' ? (
<button
type="button"
onClick={() => liveToggleRef.current?.()}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-semibold bg-emerald-500/15 border border-emerald-500/40 text-emerald-500 hover:bg-emerald-500/25 transition-colors cursor-pointer"
title="Live-Zusammenarbeit beenden (Tastendruck / WebSocket)"
>
<Radio className="w-3.5 h-3.5 text-emerald-500 animate-pulse shrink-0" />
<span>Live aktiv</span>
</button>
) : (
<button
type="button"
onClick={() => liveToggleRef.current?.()}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-canvas border border-border text-muted hover:text-main hover:border-accent transition-colors cursor-pointer"
title="Live-Zusammenarbeit aktivieren (WebSockets)"
>
<Radio className="w-3.5 h-3.5 text-muted hover:text-accent shrink-0" />
<span>Live</span>
</button>
)}
</>
) : (
/* Standalone Realtime Status Indicator for Todos / Hashtags */
(() => {
const isConnected = realtimeConnected;
const isSSE = realtimeTransport === 'sse';
return (
<div
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
isConnected
? isSSE
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
: 'bg-canvas border-border text-muted'
}`}
title={
isConnected
? isSSE
? 'Synchronisiert (Server-Sent Events / HTTPS)'
: 'Synchronisiert (WebSocket online)'
: 'Offline'
}
>
<Radio
className={`w-3.5 h-3.5 ${isConnected ? 'animate-pulse' : ''}`}
/>
</div>
);
})()
)}
</div>
</div>
{/* Main Workspace Split Pane */}
<div className="flex h-[calc(100vh-7.5rem)] bg-canvas rounded-card border border-border overflow-hidden shadow-panel">
{/* LEFT MASTER SIDEBAR */}
<aside
className={`${
subView !== 'editor' || isSidebarCollapsed
? 'hidden'
: mobileShowDetail
? 'hidden lg:flex'
: 'flex'
} w-full lg:w-72 xl:w-80 shrink-0 flex-col bg-canvas border-r border-border transition-all duration-200`}
>
{/* Sidebar Header & Actions */}
<div className="p-3 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-xs font-bold text-main uppercase tracking-wider">
Notizen & Outliner
</h2>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setShowNewFolderModal(true)}
className="btn-action p-1 text-muted hover:text-main"
title="Neuer Ordner"
>
<FolderPlus className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleCreateDocument('')}
className="h-7 px-2.5 bg-canvas hover:bg-panel border border-border border-dashed rounded-btn text-xs text-muted hover:text-main inline-flex items-center gap-1 transition-colors cursor-pointer"
title="Neue Notiz anlegen"
>
<Plus className="w-3.5 h-3.5" />
<span>Notiz</span>
</button>
</div>
</div>
{/* Search Input */}
<div className="relative">
<Search className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Notizen oder #Tags suchen…"
className="input-field w-full pl-8 pr-6 py-1 text-xs"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-faint hover:text-main"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Scrollable Navigation Tree */}
<div className="flex-1 overflow-y-auto p-2 space-y-3">
{/* Drop target for moving docs or folders to root */}
{(draggingDocId || draggingFolderPath) && (
<div
onDragOver={(e) => {
e.preventDefault();
setDragOverFolder('__root__');
}}
onDragLeave={() => setDragOverFolder(null)}
onDrop={(e) => handleDropOnFolder('', e)}
className={`p-2 border-2 border-dashed rounded-btn text-center text-xs transition-colors ${
dragOverFolder === '__root__'
? 'border-accent bg-accent/20 text-main font-semibold'
: 'border-border text-muted bg-canvas'
}`}
>
📁 In Hauptebene ablegen
</div>
)}
{/* Pinned Notes */}
{pinnedDocs.length > 0 && !searchQuery && (
<div>
<div className="px-2 mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-faint">
<Pin className="w-3 h-3 text-status-warn" />
<span>Angeheftet</span>
</div>
<div className="space-y-0.5">
{pinnedDocs.map((d) => (
<div
key={`pin-${d.id}`}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
? 'bg-panel border-accent text-main font-semibold shadow-xs'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span>{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
</div>
))}
</div>
</div>
)}
{/* Folders Tree with Recursive Hierarchical Nesting */}
{folderTree.length > 0 && (
<div>
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
Ordner
</div>
<div className="space-y-0.5">
{folderTree.map((node) => (
<FolderTreeItem
key={node.fullPath}
node={node}
depth={0}
expandedFolders={expandedFolders}
setExpandedFolders={setExpandedFolders}
selectedDocId={selectedDocId}
subView={subView}
dragOverFolder={dragOverFolder}
setDragOverFolder={setDragOverFolder}
handleDropOnFolder={handleDropOnFolder}
handleFolderDragStart={handleFolderDragStart}
handleDocDragStart={handleDocDragStart}
handleCreateDocument={handleCreateDocument}
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={(id) => {
setSelectedDocId(id);
setTargetBlockId(null);
setNewlyCreatedDocId(null);
setSubView('editor');
setMobileShowDetail(true);
}}
/>
))}
</div>
</div>
)}
{/* Root Documents */}
<div>
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
Notizen (Hauptebene)
</div>
{rootDocs.length === 0 ? (
<div className="px-2 py-2 text-xs text-faint italic">
Keine Notizen auf der Hauptebene.
</div>
) : (
<div className="space-y-0.5">
{rootDocs.map((d) => (
<div
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
? 'bg-panel border-accent text-main font-semibold shadow-xs'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span>{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
)}
</div>
))}
</div>
)}
</div>
{/* Tags Explorer */}
{tagsSummary.length > 0 && (
<div>
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
Hashtags (#)
</div>
<div className="flex flex-wrap gap-1 px-1">
{tagsSummary.map((t) => (
<button
key={t.tag}
type="button"
onClick={() => handleSelectTag(t.tag)}
className={`inline-flex items-center gap-0.5 px-2 py-0.5 rounded-btn text-[10px] font-medium transition-colors ${
subView === 'tag_detail' && selectedTag === t.tag
? 'bg-accent text-on-accent'
: 'bg-panel-elevated text-muted hover:text-accent'
}`}
>
<span>#{t.tag}</span>
<span className="opacity-60 font-mono text-[9px]">{t.count}</span>
</button>
))}
</div>
</div>
)}
</div>
</aside>
{/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */}
<main
className={`${
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex'
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-canvas`}
>
{subView === 'editor' && selectedDocId ? (
<DocumentEditor
key={selectedDocId}
docId={selectedDocId}
currentUser={currentUser}
initialDoc={selectedDocObj}
availableFolders={allFolderPaths}
autoFocusTitle={newlyCreatedDocId === selectedDocId}
initialFocusedBlockId={targetBlockId}
onDocMetadataChange={handleDocMetadataChange}
onDeleteDoc={handleDeleteDocument}
onOpenShareModal={openDocShareModal}
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}
onBackMobile={() => setMobileShowDetail(false)}
onSyncStatusChange={setActiveDocSyncStatus}
onSaveStatusChange={(status, err) => {
setDocSaveStatus(status);
setDocSaveError(err || null);
}}
onLiveStateChange={(state, initiator) => {
setDocLiveState(state);
setDocLiveInitiator(initiator || null);
}}
registerLiveToggle={(handler) => {
liveToggleRef.current = handler;
}}
/>
) : subView === 'tags' ? (
/* =========================================================================
HASHTAG OVERVIEW & TILE DIRECTORY (KACHEL-ANSICHT MIT SUCHE)
========================================================================= */
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
<div className="w-full space-y-5">
{/* Header with Title and Search Controls */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Hash className="w-5 h-5 text-accent" />
<h1 className="text-xl font-bold text-main">Hashtags Übersicht</h1>
<span className="badge-neutral text-xs font-mono">{tagsSummary.length} Tags</span>
</div>
<p className="text-xs text-muted mt-0.5">
Entdecke alle Themen, Schlagworte und verknüpften Blöcke aus deinen Notizen
</p>
</div>
{/* Search & Sort Toolbar */}
<div className="flex items-center gap-2">
<div className="relative min-w-[200px] sm:min-w-[240px]">
<Search className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={tagSearchQuery}
onChange={(e) => setTagSearchQuery(e.target.value)}
placeholder="Hashtags durchsuchen…"
className="input-field w-full pl-8 pr-7 py-1.5 text-xs"
/>
{tagSearchQuery && (
<button
type="button"
onClick={() => setTagSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-faint hover:text-main cursor-pointer"
>
<X className="w-3 h-3" />
</button>
)}
</div>
{/* Sort Mode Switcher */}
<div className="flex items-center bg-canvas border border-border rounded-btn p-0.5 text-[11px]">
<button
type="button"
onClick={() => setTagSortBy('count')}
className={`px-2 py-1 rounded-btn font-semibold transition-colors cursor-pointer ${
tagSortBy === 'count'
? 'bg-panel-elevated text-main shadow-xs'
: 'text-muted hover:text-main'
}`}
title="Nach Häufigkeit sortieren"
>
Häufigkeit
</button>
<button
type="button"
onClick={() => setTagSortBy('name')}
className={`px-2 py-1 rounded-btn font-semibold transition-colors cursor-pointer ${
tagSortBy === 'name'
? 'bg-panel-elevated text-main shadow-xs'
: 'text-muted hover:text-main'
}`}
title="Alphabetisch sortieren"
>
A-Z
</button>
</div>
</div>
</div>
{/* Grid of Hashtag Tiles (Kacheln) */}
{filteredTags.length === 0 ? (
<div className="p-12 text-center text-muted text-xs space-y-2 border border-border rounded-card bg-canvas">
<Hash className="w-8 h-8 text-faint mx-auto" />
<p className="font-semibold text-main text-sm">
{tagSearchQuery
? `Keine Hashtags für "${tagSearchQuery}" gefunden`
: 'Noch keine Hashtags vorhanden'}
</p>
<p className="text-faint max-w-sm mx-auto">
{tagSearchQuery
? 'Versuche einen anderen Suchbegriff oder setze die Suche zurück.'
: 'Schreibe #tag in einen beliebigen Notiz-Bullet, um Themen automatisch als Kachel zu gruppieren.'}
</p>
{tagSearchQuery && (
<button
type="button"
onClick={() => setTagSearchQuery('')}
className="btn-action inline-flex items-center gap-1 px-3 py-1 text-xs text-accent mt-2 cursor-pointer"
>
Suche zurücksetzen
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3.5">
{filteredTags.map((t) => {
const meta = tagsMetadataMap.get(t.tag.toLowerCase());
return (
<div
key={t.tag}
onClick={() => handleSelectTag(t.tag)}
className="p-4 rounded-card border border-border bg-canvas hover:border-accent hover:shadow-panel transition-all cursor-pointer group flex flex-col justify-between space-y-3"
>
<div className="space-y-2.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="w-7 h-7 rounded-btn bg-accent/10 border border-accent/20 text-accent font-bold flex items-center justify-center shrink-0 text-sm group-hover:bg-accent group-hover:text-on-accent transition-colors">
#
</span>
<h3 className="font-bold text-sm text-main group-hover:text-accent transition-colors truncate">
#{t.tag}
</h3>
</div>
<span className="badge-neutral font-mono text-[10px] shrink-0">
{t.count} {t.count === 1 ? 'Treffer' : 'Treffer'}
</span>
</div>
{meta && meta.docs.length > 0 && (
<div className="space-y-1">
<span className="text-[10px] font-semibold text-faint uppercase tracking-wider block">
In {meta.docs.length} {meta.docs.length === 1 ? 'Notiz' : 'Notizen'}:
</span>
<div className="flex flex-wrap gap-1">
{meta.docs.slice(0, 3).map((d) => (
<span
key={d.id}
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-muted truncate max-w-[140px]"
title={d.title}
>
<span>{d.icon || '📝'}</span>
<span className="truncate">{d.title}</span>
</span>
))}
{meta.docs.length > 3 && (
<span className="text-[10px] text-faint self-center">
+{meta.docs.length - 3}
</span>
)}
</div>
</div>
)}
</div>
<div className="flex items-center justify-between pt-2.5 border-t border-border/50 text-[11px]">
<div>
{meta && meta.openTodosCount > 0 ? (
<span className="text-status-warn text-[10px] flex items-center gap-1 font-medium">
<Check className="w-2.5 h-2.5" />
{meta.openTodosCount} offene To-Dos
</span>
) : (
<span className="text-faint text-[10px]">Blöcke & Childs anzeigen</span>
)}
</div>
<span className="text-accent text-xs font-semibold group-hover:translate-x-0.5 transition-transform flex items-center gap-0.5">
<span>Details</span>
<span>→</span>
</span>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
) : subView === 'tag_detail' && selectedTag ? (
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
<div className="w-full space-y-4">
{/* Back to all tags */}
<div className="flex items-center justify-between border-b border-border pb-3">
<button
type="button"
onClick={() => setSubView('tags')}
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main cursor-pointer"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Alle Hashtag-Kacheln</span>
</button>
</div>
{/* Tag Context Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="w-7 h-7 rounded-btn bg-accent/10 border border-accent/20 text-accent font-bold flex items-center justify-center shrink-0 text-sm">
#
</span>
<h1 className="text-xl font-bold text-main">
#{selectedTag}
</h1>
</div>
<button
type="button"
onClick={() => setSubView('tags')}
className="text-xs text-accent hover:underline flex items-center gap-1 cursor-pointer"
>
<ArrowLeft className="w-3 h-3" />
<span>Zur Kachel-Übersicht</span>
</button>
</div>
{/* Tag Context Note */}
<div className="space-y-1">
<label className="text-[10px] font-bold uppercase tracking-wider text-faint">
Tag-Notiz
</label>
<textarea
rows={3}
value={tagNoteDraft}
onChange={(e) => setTagNoteDraft(e.target.value)}
onBlur={handleSaveTagNote}
placeholder={`Notizen zu #${selectedTag}…`}
className="input-field w-full p-2.5 text-xs text-main"
/>
</div>
{/* Matched Document Blocks */}
<div className="space-y-3">
<h3 className="text-[10px] font-bold uppercase tracking-wider text-faint">
Verknüpfte Outliner-Blöcke & Unterpunkte ({activeTagTotalMatches})
</h3>
{activeTagGroups.length === 0 ? (
<div className="p-8 text-center text-muted text-xs space-y-1 border border-border rounded-card bg-canvas">
<p className="font-semibold text-main">Keine Blöcke mit #{selectedTag} gefunden.</p>
</div>
) : (
activeTagGroups.map((docGroup) => {
const docObj = docs.find((d) => d.id === docGroup.docId);
return (
<div
key={docGroup.docId}
className="p-3 rounded-card border border-border bg-canvas space-y-2"
>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => handleJumpToDocBlock(docGroup.docId)}
className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer"
>
<span>{docGroup.icon || '📝'}</span>
<span>{docGroup.title}</span>
{docGroup.folderPath && (
<span className="badge-neutral text-[10px] font-normal">
📁 {docGroup.folderPath}
</span>
)}
</button>
<button
type="button"
onClick={() => handleJumpToDocBlock(docGroup.docId)}
className="text-[10px] text-accent hover:underline px-1.5 py-0.5 rounded-btn hover:bg-canvas cursor-pointer flex items-center gap-1"
title="Notiz öffnen"
>
<span>Notiz öffnen</span>
<ExternalLink className="w-2.5 h-2.5" />
</button>
</div>
<div className="space-y-2 pl-3 border-l-2 border-border/70">
{docGroup.matchedBlocks.map((mb) => (
<div key={mb.rootBlock.id} className="space-y-1">
{/* Root Bullet Point containing the hashtag */}
<div
onClick={() => handleJumpToDocBlock(docGroup.docId, mb.rootBlock.id)}
className="flex items-start justify-between gap-1.5 text-xs text-main font-medium group cursor-pointer hover:bg-panel-elevated p-1.5 rounded-btn transition-colors"
title="Klick: Zu dieser Zeile springen und markieren"
>
<div className="flex items-start gap-2 flex-1 min-w-0">
{mb.rootBlock.type === 'todo' ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (docObj) {
handleToggleRollupTodo(
docObj,
mb.rootBlock.id,
Boolean(mb.rootBlock.checked)
);
}
}}
className={`w-3.5 h-3.5 mt-0.5 checkbox-square flex items-center justify-center shrink-0 border cursor-pointer ${
mb.rootBlock.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
) : (
<span className="text-accent font-bold select-none text-sm leading-none mt-0.5">
•
</span>
)}
<span
className={`flex-1 break-words ${
mb.rootBlock.checked ? 'line-through text-faint' : ''
}`}
>
{mb.rootBlock.content}
</span>
</div>
<ExternalLink className="w-3 h-3 text-faint group-hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5" />
</div>
{/* All Children indented */}
{mb.children && mb.children.length > 0 && (
<div className="space-y-0.5">
{mb.children.map((ch) => {
const relLevel = Math.max(
1,
(ch.level || 0) - (mb.rootBlock.level || 0)
);
return (
<div
key={ch.id}
onClick={() => handleJumpToDocBlock(docGroup.docId, ch.id)}
style={{ paddingLeft: `${relLevel * 16}px` }}
className="flex items-start justify-between gap-1.5 text-xs text-muted hover:text-main group cursor-pointer hover:bg-panel-elevated p-1 rounded-btn transition-colors"
title="Klick: Zu dieser Zeile springen und markieren"
>
<div className="flex items-start gap-2 flex-1 min-w-0">
{ch.type === 'todo' ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (docObj) {
handleToggleRollupTodo(
docObj,
ch.id,
Boolean(ch.checked)
);
}
}}
className={`w-3.5 h-3.5 mt-0.5 checkbox-square flex items-center justify-center shrink-0 border cursor-pointer ${
ch.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
) : (
<span className="text-faint group-hover:text-accent font-bold select-none text-xs leading-none mt-1">
◦
</span>
)}
<span
className={`flex-1 break-words ${
ch.checked ? 'line-through text-faint' : ''
}`}
>
{ch.content}
</span>
</div>
<ExternalLink className="w-3 h-3 text-faint group-hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5" />
</div>
);
})}
</div>
)}
</div>
))}
</div>
</div>
);
})
)}
</div>
</div>
</div>
) : subView === 'todos' ? (
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
<div className="w-full space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between border-b border-border pb-3 gap-2">
<div>
<h2 className="text-lg font-bold text-main">
Globaler To-Do Rollup ({totalOpenTodos} offen)
</h2>
<p className="text-xs text-muted">
Alle Aufgaben aus deinen Notizen im gesamten Workspace
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* View Grouping Selector */}
<div className="flex items-center rounded-btn bg-panel p-0.5 border border-border shadow-xs">
<span className="text-[10px] uppercase font-bold text-faint px-1.5 hidden sm:inline">
Ansicht:
</span>
<button
type="button"
onClick={() => {
setTodoGrouping('folder_note');
localStorage.setItem('mindsift_todo_grouping', 'folder_note');
}}
className={`px-2 py-1 text-[11px] font-semibold rounded-btn transition-colors cursor-pointer flex items-center gap-1 ${
todoGrouping === 'folder_note'
? 'bg-panel-elevated text-main shadow-xs'
: 'text-muted hover:text-main'
}`}
title="Erst nach Ordner, dann nach Notiz gruppieren"
>
<Folder className="w-3 h-3 text-accent" />
<span>+</span>
<FileText className="w-3 h-3 text-accent" />
<span>Ordner & Notiz</span>
</button>
<button
type="button"
onClick={() => {
setTodoGrouping('note');
localStorage.setItem('mindsift_todo_grouping', 'note');
}}
className={`px-2 py-1 text-[11px] font-semibold rounded-btn transition-colors cursor-pointer flex items-center gap-1 ${
todoGrouping === 'note'
? 'bg-panel-elevated text-main shadow-xs'
: 'text-muted hover:text-main'
}`}
title="Direkt nach Notiz gruppieren"
>
<FileText className="w-3 h-3 text-accent" />
<span>Notiz</span>
</button>
<button
type="button"
onClick={() => {
setTodoGrouping('folder');
localStorage.setItem('mindsift_todo_grouping', 'folder');
}}
className={`px-2 py-1 text-[11px] font-semibold rounded-btn transition-colors cursor-pointer flex items-center gap-1 ${
todoGrouping === 'folder'
? 'bg-panel-elevated text-main shadow-xs'
: 'text-muted hover:text-main'
}`}
title="Nur nach Ordner gruppieren"
>
<Folder className="w-3 h-3 text-accent" />
<span>Ordner</span>
</button>
</div>
</div>
</div>
{/* Folder Drilldown Tag */}
{todoFolderDrilldown && (
<div className="flex items-center justify-between p-2 rounded-btn bg-accent/10 border border-accent text-xs">
<span>
Gefiltert nach Ordner: <strong>{todoFolderDrilldown}</strong>
</span>
<button
type="button"
onClick={() => setTodoFolderDrilldown(null)}
className="text-accent hover:underline font-semibold cursor-pointer"
>
Filter aufheben
</button>
</div>
)}
{/* Grouped Folders & To-Dos */}
{allRollupTodos.length === 0 ? (
<div className="p-8 text-center text-muted text-xs space-y-1">
<Sparkles className="w-6 h-6 text-accent mx-auto mb-1" />
<p className="font-semibold text-main">Keine To-Dos in Notizen gefunden!</p>
<p className="text-faint">
Tippe in einem beliebigen Dokument <code>/todo</code> oder <code>[] </code> ein.
</p>
</div>
) : todoGrouping === 'note' ? (
/* Grouped by Note directly */
<div className="space-y-3">
{Array.from(rollupTodosByNote.entries()).map(([docId, { doc, items }]) => {
const isCollapsed = collapsedTodoNotes.has(docId);
const openCount = items.filter((i) => !i.checked).length;
return (
<div
key={docId}
className="border border-border rounded-card bg-canvas overflow-hidden"
>
<div className="p-2 bg-canvas border-b border-border flex items-center justify-between">
<div
onClick={() => {
setCollapsedTodoNotes((prev) => {
const next = new Set(prev);
if (next.has(docId)) next.delete(docId);
else next.add(docId);
return next;
});
}}
className="flex items-center gap-2 cursor-pointer select-none"
>
{isCollapsed ? (
<ChevronRight className="w-3.5 h-3.5 text-faint" />
) : (
<ChevronDown className="w-3.5 h-3.5 text-accent" />
)}
<span className="text-sm">{doc.icon || '📝'}</span>
<span className="font-bold text-xs text-main">{doc.title}</span>
{doc.folderPath && (
<span className="badge-neutral text-[10px]">📁 {doc.folderPath}</span>
)}
<span className="badge-neutral text-[10px] font-mono">
{openCount} offen
</span>
</div>
<button
type="button"
onClick={() => handleJumpToDocBlock(doc.id)}
className="text-[10px] text-accent hover:underline px-1.5 py-0.5 rounded-btn hover:bg-canvas cursor-pointer flex items-center gap-1"
title="Zur Notiz springen"
>
<span>Notiz öffnen</span>
<ExternalLink className="w-2.5 h-2.5" />
</button>
</div>
{!isCollapsed && (
<div className="p-2 space-y-1">
{items.map((item) => (
<div
key={`${item.doc.id}-${item.blockId}`}
className="flex items-center justify-between gap-2 p-1.5 rounded-btn hover:bg-panel-elevated text-xs transition-colors group cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleToggleRollupTodo(item.doc, item.blockId, item.checked);
}}
className={`w-3.5 h-3.5 checkbox-square flex items-center justify-center transition-colors border cursor-pointer ${
item.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
<span
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
className={`truncate flex-1 ${
item.checked
? 'line-through text-faint'
: 'text-main font-medium group-hover:text-accent'
}`}
>
{item.content}
</span>
</div>
<button
type="button"
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
className="text-faint hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity p-1"
title="Zu dieser Zeile springen"
>
<ExternalLink className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
</div>
);
})}
</div>
) : todoGrouping === 'folder_note' ? (
/* Grouped by Folder -> grouped by Note */
<div className="space-y-3">
{Array.from(rollupTodosByFolder.entries()).map(([folderName, items]) => {
const isCollapsed = collapsedTodoFolders.has(folderName);
const openCount = items.filter((i) => !i.checked).length;
// Subgroup by doc
const docGroupsMap = new Map<string, { doc: Document; items: typeof items }>();
for (const it of items) {
const ex = docGroupsMap.get(it.doc.id);
if (ex) ex.items.push(it);
else docGroupsMap.set(it.doc.id, { doc: it.doc, items: [it] });
}
return (
<div
key={folderName}
className="border border-border rounded-card bg-canvas overflow-hidden"
>
{/* Folder Header */}
<div className="p-2 bg-canvas border-b border-border flex items-center justify-between">
<div
onClick={() => {
setCollapsedTodoFolders((prev) => {
const next = new Set(prev);
if (next.has(folderName)) next.delete(folderName);
else next.add(folderName);
return next;
});
}}
className="flex items-center gap-2 cursor-pointer select-none"
>
{isCollapsed ? (
<ChevronRight className="w-3.5 h-3.5 text-faint" />
) : (
<ChevronDown className="w-3.5 h-3.5 text-accent" />
)}
<Folder className="w-3.5 h-3.5 text-accent" />
<span className="font-bold text-xs text-main">{folderName}</span>
<span className="badge-neutral text-[10px] font-mono">
{openCount} offen
</span>
</div>
<button
type="button"
onClick={() => setTodoFolderDrilldown(folderName)}
className="text-[10px] text-muted hover:text-main px-1.5 py-0.5 rounded-btn hover:bg-canvas cursor-pointer"
title="Diesen Ordner isolieren"
>
Nur diesen Ordner
</button>
</div>
{/* Folder Notes Subgroups */}
{!isCollapsed && (
<div className="p-2 space-y-3">
{Array.from(docGroupsMap.values()).map(({ doc, items: docItems }) => {
const docOpenCount = docItems.filter((i) => !i.checked).length;
const isDocCollapsed = collapsedTodoNotes.has(doc.id);
return (
<div
key={doc.id}
className="border border-border/60 rounded-btn p-2 bg-panel-elevated/30 space-y-1.5"
>
<div className="flex items-center justify-between pb-1 border-b border-border/40">
<div
onClick={() => {
setCollapsedTodoNotes((prev) => {
const next = new Set(prev);
if (next.has(doc.id)) next.delete(doc.id);
else next.add(doc.id);
return next;
});
}}
className="flex items-center gap-1.5 text-xs font-semibold text-main hover:text-accent cursor-pointer select-none"
>
{isDocCollapsed ? (
<ChevronRight className="w-3 h-3 text-faint" />
) : (
<ChevronDown className="w-3 h-3 text-accent" />
)}
<span>{doc.icon || '📝'}</span>
<span>{doc.title}</span>
<span className="badge-neutral text-[9px] font-mono">
{docOpenCount} offen
</span>
</div>
<button
type="button"
onClick={() => handleJumpToDocBlock(doc.id)}
className="text-[10px] text-accent hover:underline px-1.5 py-0.5 rounded-btn hover:bg-canvas cursor-pointer flex items-center gap-1"
title="Notiz öffnen"
>
<span>Notiz öffnen</span>
<ExternalLink className="w-2.5 h-2.5" />
</button>
</div>
{!isDocCollapsed && (
<div className="space-y-0.5">
{docItems.map((item) => (
<div
key={`${item.doc.id}-${item.blockId}`}
className="flex items-center justify-between gap-2 p-1 rounded-btn hover:bg-canvas text-xs transition-colors group cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleToggleRollupTodo(
item.doc,
item.blockId,
item.checked
);
}}
className={`w-3.5 h-3.5 checkbox-square flex items-center justify-center transition-colors border cursor-pointer ${
item.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
<span
onClick={() =>
handleJumpToDocBlock(item.doc.id, item.blockId)
}
className={`truncate flex-1 ${
item.checked
? 'line-through text-faint'
: 'text-main font-medium group-hover:text-accent'
}`}
>
{item.content}
</span>
</div>
<button
type="button"
onClick={() =>
handleJumpToDocBlock(item.doc.id, item.blockId)
}
className="text-faint hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity p-1"
title="Zu dieser Zeile springen"
>
<ExternalLink className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
) : (
/* Grouped by Folder (Flat tasks inside folder) */
<div className="space-y-3">
{Array.from(rollupTodosByFolder.entries()).map(([folderName, items]) => {
const isCollapsed = collapsedTodoFolders.has(folderName);
const openCount = items.filter((i) => !i.checked).length;
return (
<div
key={folderName}
className="border border-border rounded-card bg-canvas overflow-hidden"
>
{/* Folder Header */}
<div className="p-2 bg-canvas border-b border-border flex items-center justify-between">
<div
onClick={() => {
setCollapsedTodoFolders((prev) => {
const next = new Set(prev);
if (next.has(folderName)) next.delete(folderName);
else next.add(folderName);
return next;
});
}}
className="flex items-center gap-2 cursor-pointer select-none"
>
{isCollapsed ? (
<ChevronRight className="w-3.5 h-3.5 text-faint" />
) : (
<ChevronDown className="w-3.5 h-3.5 text-accent" />
)}
<Folder className="w-3.5 h-3.5 text-accent" />
<span className="font-bold text-xs text-main">{folderName}</span>
<span className="badge-neutral text-[10px] font-mono">
{openCount} offen
</span>
</div>
<button
type="button"
onClick={() => setTodoFolderDrilldown(folderName)}
className="text-[10px] text-muted hover:text-main px-1.5 py-0.5 rounded-btn hover:bg-canvas cursor-pointer"
title="Diesen Ordner isolieren"
>
Nur diesen Ordner
</button>
</div>
{/* Folder Tasks */}
{!isCollapsed && (
<div className="p-2 space-y-1">
{items.map((item) => (
<div
key={`${item.doc.id}-${item.blockId}`}
className="flex items-center justify-between gap-2 p-1.5 rounded-btn hover:bg-panel-elevated text-xs transition-colors group cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleToggleRollupTodo(item.doc, item.blockId, item.checked);
}}
className={`w-3.5 h-3.5 checkbox-square flex items-center justify-center transition-colors border cursor-pointer ${
item.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
<span
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
className={`truncate flex-1 ${
item.checked
? 'line-through text-faint'
: 'text-main font-medium group-hover:text-accent'
}`}
>
{item.content}
</span>
</div>
<button
type="button"
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
className="text-[11px] text-accent hover:underline flex items-center gap-1 shrink-0 cursor-pointer"
title="Zu dieser Zeile springen"
>
<span>{item.doc.icon || '📝'}</span>
<span className="truncate max-w-[120px]">{item.doc.title}</span>
<ExternalLink className="w-2.5 h-2.5 opacity-60" />
</button>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-canvas">
<FileText className="w-10 h-10 text-faint mb-2" />
<h3 className="text-sm font-semibold text-main mb-1">
Keine Notiz ausgewählt
</h3>
<p className="text-xs text-muted max-w-sm mb-3">
{isSidebarCollapsed
? 'Die Ordnerleiste ist eingeklappt. Blende sie ein oder erstelle eine neue Notiz.'
: 'Wähle links in der Seitenleiste eine bestehende Notiz aus oder erstelle eine neue.'}
</p>
<div className="flex items-center gap-2">
{isSidebarCollapsed && (
<button
type="button"
onClick={toggleSidebar}
className="btn-action inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold cursor-pointer"
>
<PanelLeftOpen className="w-3.5 h-3.5" />
<span>Ordner anzeigen</span>
</button>
)}
<button
type="button"
onClick={() => handleCreateDocument('')}
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold"
>
<Plus className="w-3.5 h-3.5" />
<span>Notiz erstellen</span>
</button>
</div>
</div>
)}
</main>
{/* New Folder Modal */}
{showNewFolderModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-overlay backdrop-blur-sm p-4">
<form
onSubmit={handleCreateFolderSubmit}
className="w-full max-w-md panel-surface shadow-panel border border-border rounded-card p-4 space-y-3"
>
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-main">
Neuen Ordner anlegen
</h3>
<button
type="button"
onClick={() => setShowNewFolderModal(false)}
className="p-1 rounded-btn text-faint hover:text-main"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted">
Ordnerpfad (z.B. <code>Projekte/2026</code>)
</label>
<input
type="text"
autoFocus
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Projekte"
className="input-field w-full px-2.5 py-1.5 text-xs"
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted cursor-pointer">
<input
type="checkbox"
checked={newFolderIsShared}
onChange={(e) => setNewFolderIsShared(e.target.checked)}
className="checkbox-square"
/>
<span>Als geteilten Team-Ordner freigeben</span>
</label>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={() => setShowNewFolderModal(false)}
className="btn-action px-3 py-1 text-xs"
>
Abbrechen
</button>
<button
type="submit"
className="btn-primary px-3 py-1 text-xs font-semibold"
>
Erstellen
</button>
</div>
</form>
</div>
)}
{/* Unified Sharing Modal */}
{(shareModalDoc || shareModalFolder) && (
<UnifiedShareModal
isOpen={true}
onClose={() => {
setShareModalDoc(null);
setShareModalFolder(null);
}}
resourceName={shareModalDoc ? shareModalDoc.title : shareModalFolder?.name || ''}
resourceIcon={shareModalDoc ? shareModalDoc.icon : '📁'}
isOwner={
shareModalDoc
? (shareModalDoc.isOwner || shareModalDoc.permission === 'owner')
: Boolean(shareModalFolder?.isOwner)
}
currentUserId={currentUser?.id}
shares={unifiedShares}
teams={teams}
collaborators={collaborators}
onAddShare={async (targetType, targetId, permission) => {
const perm = permission === 'editor' ? 'write' : 'read';
if (shareModalDoc) {
await api.createDocShare(
shareModalDoc.id,
targetType === 'team'
? { teamId: targetId, permission: perm }
: { userId: targetId, permission: perm }
);
const updatedShares = await api.getDocShares(shareModalDoc.id);
setActiveDocShares(updatedShares);
} else if (shareModalFolder) {
await api.createFolderShare(shareModalFolder.id, {
targetType,
targetId,
permission: perm,
});
const updatedFolder = await api.getSharedFolder(shareModalFolder.id);
setShareModalFolder(updatedFolder);
}
}}
onRemoveShare={async (shareId) => {
if (shareModalDoc) {
await api.deleteDocShare(shareModalDoc.id, shareId);
const updatedShares = await api.getDocShares(shareModalDoc.id);
setActiveDocShares(updatedShares);
} else if (shareModalFolder) {
await api.deleteFolderShare(shareModalFolder.id, shareId);
const updatedFolder = await api.getSharedFolder(shareModalFolder.id);
setShareModalFolder(updatedFolder);
}
}}
/>
)}
{/* WebSocket Blocked Modal */}
{showWsBlockedModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150">
<div className="w-full max-w-md bg-canvas border border-border rounded-card shadow-modal p-6 space-y-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-status-err/15 flex items-center justify-center shrink-0">
<AlertTriangle className="w-5 h-5 text-status-err" />
</div>
<div className="flex-1">
<h3 className="text-sm font-bold text-main">
WebSockets in diesem Netzwerk blockiert
</h3>
<p className="text-xs text-muted mt-1 leading-relaxed">
Deine Firewall oder dein Firmen-Proxy blockiert WebSocket-Verbindungen (<code className="text-accent bg-panel px-1 py-0.5 rounded">ws://</code> / <code className="text-accent bg-panel px-1 py-0.5 rounded">wss://</code>).
</p>
</div>
</div>
<div className="bg-surface-subtle border border-border rounded-btn p-3 text-xs space-y-2 text-main">
<div className="flex items-center gap-2 text-status-ok font-semibold">
<Check className="w-4 h-4 shrink-0" />
<span>Kein Datenverlust: Auto-Save per HTTPS ist aktiv!</span>
</div>
<p className="text-muted leading-relaxed">
Jede getippte Zeile und alle Änderungen werden zuverlässig über Standard-HTTPS-Anfragen im Hintergrund gespeichert.
</p>
<p className="text-muted text-[11px] leading-relaxed">
Lediglich die gleichzeitige Tastendruck-Echtzeitanzeige mit anderen Nutzern im selben Moment ist ohne WebSockets eingeschränkt.
</p>
</div>
<div className="flex justify-end pt-2">
<button
type="button"
onClick={() => setShowWsBlockedModal(false)}
className="btn-action px-4 py-1.5 text-xs font-semibold bg-accent text-on-accent hover:opacity-90 transition-opacity cursor-pointer"
>
Verstanden
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
};