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; setExpandedFolders: React.Dispatch>>; 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 = ({ 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 (
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' }`} >
{isExpanded && (
{/* Subfolders in this folder */} {node.subfolders.map((childNode) => ( ))} {/* Notes directly in this folder */} {node.docs.map((d) => (
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' }`} > {d.icon || '📝'} {d.title || 'Unbenannte Notiz'}
))} {!hasContent && (
Leer
)}
)}
); }; export const WorkspaceView: React.FC = ({ currentUser, initialDocId, onStatsChange, }) => { const [docs, setDocs] = useState([]); const [folders, setFolders] = useState([]); const [sharedFolders, setSharedFolders] = useState([]); const [tagsSummary, setTagsSummary] = useState([]); const [selectedDocId, setSelectedDocId] = useState(initialDocId || null); const [targetBlockId, setTargetBlockId] = useState(null); const [newlyCreatedDocId, setNewlyCreatedDocId] = useState(null); const [subView, setSubView] = useState('editor'); const [selectedTag, setSelectedTag] = useState(null); const [tagDetails, setTagDetails] = useState(null); const [tagNoteDraft, setTagNoteDraft] = useState(''); const [tagSearchQuery, setTagSearchQuery] = useState(''); const [tagSortBy, setTagSortBy] = useState<'count' | 'name'>('count'); const [searchQuery, setSearchQuery] = useState(''); const [expandedFolders, setExpandedFolders] = useState>({}); const [showNewFolderModal, setShowNewFolderModal] = useState(false); const [newFolderName, setNewFolderName] = useState(''); const [newFolderIsShared, setNewFolderIsShared] = useState(false); const [mobileShowDetail, setMobileShowDetail] = useState(Boolean(initialDocId)); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { 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(() => isRealtimeConnected()); const [realtimeTransport, setRealtimeTransport] = useState(() => getRealtimeTransport()); const [activeDocSyncStatus, setActiveDocSyncStatus] = useState('connected'); const [docSaveStatus, setDocSaveStatus] = useState('saved'); const [docSaveError, setDocSaveError] = useState(null); const [docLiveState, setDocLiveState] = useState('idle'); const [docLiveInitiator, setDocLiveInitiator] = useState(null); const [showWsBlockedModal, setShowWsBlockedModal] = useState(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(null); const [draggingFolderPath, setDraggingFolderPath] = useState(null); const [dragOverFolder, setDragOverFolder] = useState(null); // Rollup drilldown & View Mode const [todoFolderDrilldown, setTodoFolderDrilldown] = useState(null); const [todoGrouping, setTodoGrouping] = useState<'folder_note' | 'note' | 'folder'>(() => { return (localStorage.getItem('mindsift_todo_grouping') as any) || 'folder_note'; }); const [collapsedTodoFolders, setCollapsedTodoFolders] = useState>(new Set()); const [collapsedTodoNotes, setCollapsedTodoNotes] = useState>(new Set()); // Sharing Modal State const [shareModalDoc, setShareModalDoc] = useState(null); const [shareModalFolder, setShareModalFolder] = useState(null); const [activeDocShares, setActiveDocShares] = useState([]); const [teams, setTeams] = useState([]); const [collaborators, setCollaborators] = useState([]); const isCreatingRef = useRef(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: , variant: 'accent', onClick: () => handleCreateDocument(''), }, { id: 'new-folder', label: 'Neuer Ordner', icon: , 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 = {}; 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 = {}; 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 = {}; 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(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(); const allPathsSet = new Set(); 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(); 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(); 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 (
{/* Top Header: Workspace Tabs & Actions */}
{/* Workspace selector tabs & left sidebar toggle */}
{/* Right Header Section: Save Status & Live Collaboration Button */}
{subView === 'editor' && selectedDocId ? ( <> {/* Save Status Pill */}
{docSaveStatus === 'saved' && ( <> Gespeichert )} {docSaveStatus === 'saving' && ( <> Speichert… )} {docSaveStatus === 'unsaved' && ( <> Ungespeichert )} {docSaveStatus === 'error' && ( <> Fehler )}
{/* Interactive Live Collaboration Button (replaces passive radio icon) */} {docLiveState === 'locked_by_other' ? (
Live: {docLiveInitiator || 'Gesperrt'}
) : docLiveState === 'blocked' ? ( ) : docLiveState === 'connecting' ? ( ) : docLiveState === 'active' ? ( ) : ( )} ) : ( /* Standalone Realtime Status Indicator for Todos / Hashtags */ (() => { const isConnected = realtimeConnected; const isSSE = realtimeTransport === 'sse'; return (
); })() )}
{/* Main Workspace Split Pane */}
{/* LEFT MASTER SIDEBAR */} {/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */}
{subView === 'editor' && selectedDocId ? ( 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) ========================================================================= */
{/* Header with Title and Search Controls */}

Hashtags Übersicht

{tagsSummary.length} Tags

Entdecke alle Themen, Schlagworte und verknüpften Blöcke aus deinen Notizen

{/* Search & Sort Toolbar */}
setTagSearchQuery(e.target.value)} placeholder="Hashtags durchsuchen…" className="input-field w-full pl-8 pr-7 py-1.5 text-xs" /> {tagSearchQuery && ( )}
{/* Sort Mode Switcher */}
{/* Grid of Hashtag Tiles (Kacheln) */} {filteredTags.length === 0 ? (

{tagSearchQuery ? `Keine Hashtags für "${tagSearchQuery}" gefunden` : 'Noch keine Hashtags vorhanden'}

{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.'}

{tagSearchQuery && ( )}
) : (
{filteredTags.map((t) => { const meta = tagsMetadataMap.get(t.tag.toLowerCase()); return (
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" >
#

#{t.tag}

{t.count} {t.count === 1 ? 'Treffer' : 'Treffer'}
{meta && meta.docs.length > 0 && (
In {meta.docs.length} {meta.docs.length === 1 ? 'Notiz' : 'Notizen'}:
{meta.docs.slice(0, 3).map((d) => ( {d.icon || '📝'} {d.title} ))} {meta.docs.length > 3 && ( +{meta.docs.length - 3} )}
)}
{meta && meta.openTodosCount > 0 ? ( {meta.openTodosCount} offene To-Dos ) : ( Blöcke & Childs anzeigen )}
Details →
); })}
)}
) : subView === 'tag_detail' && selectedTag ? (
{/* Back to all tags */}
{/* Tag Context Header */}
#

#{selectedTag}

{/* Tag Context Note */}