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, PanelLeftClose, PanelLeftOpen, Clock, AlertTriangle, Loader2, CalendarDays, Minimize2, } from 'lucide-react'; import { Document, DocumentBlock, MergeResult, DocShare, SharedFolder, TagDetails, TagSummary, Team, TrashedDocument, UnifiedShare, User, JournalDay, } from '../../lib/types'; import { api, onWebSocketMessage, triggerHaptic, } from '../../lib/api'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { FormSheet } from '../../components/FormSheet'; import { sendDocOps } from '../../lib/docSync/oneoff'; import { useMobileActions } from '../../context/MobileActionContext'; import { DocumentEditor, DocSaveStatus, DocHeaderStatus } from './DocumentEditor'; import { PresenceAvatars } from './PresenceAvatars'; import { TrashView } from './TrashView'; import { JournalView } from './JournalView'; import { JournalCalendar } from './JournalCalendar'; import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession } from '../../lib/journal'; import { MergeDialog } from './MergeDialog'; import { CountBadge } from '../../components/CountBadge'; import { extractHashtags } from '../../lib/hashtags'; interface WorkspaceViewProps { currentUser?: User | null; initialDocId?: string | null; initialBlockId?: string | null; onStatsChange: () => void; onOpenTask?: (taskId: string) => void; // Opens the global search (command palette); the notes sidebar has no search of its own onOpenSearch?: () => void; } type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash' | 'journal'; 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; handleOpenNewFolder: (parentPath?: string, e?: React.MouseEvent) => void; handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void; handleDeleteFolder: (folderPath: string) => void; onSelectDoc: (id: string) => void; // Dropping a note onto another one offers to merge them docDropProps: (targetId: string) => React.HTMLAttributes; mergeHoverId: string | null; } const FolderTreeItem: React.FC = ({ node, depth, expandedFolders, setExpandedFolders, selectedDocId, subView, dragOverFolder, setDragOverFolder, handleDropOnFolder, handleFolderDragStart, handleDocDragStart, handleCreateDocument, handleOpenNewFolder, handleShareFolderClick, handleDeleteFolder, onSelectDoc, docDropProps, mergeHoverId, }) => { const hasContent = node.subfolders.length > 0 || node.docs.length > 0; const isExpanded = Boolean(hasContent && expandedFolders[node.fullPath]); const isDragOver = dragOverFolder === node.fullPath; 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-11 sm:h-7 rounded-btn text-sm sm: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)} {...docDropProps(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 min-h-11 sm:min-h-0 rounded-btn text-left text-sm sm:text-xs cursor-pointer border transition-colors ${ mergeHoverId === d.id ? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main' : selectedDocId === d.id && subView === 'editor' ? 'bg-panel-elevated text-main font-semibold border-transparent' : 'border-transparent text-main hover:bg-panel-elevated' }`} > {d.icon || '📝'} {d.title || 'Unbenannte Notiz'}
))} {!hasContent && (
Leer
)}
)}
); }; export const WorkspaceView: React.FC = ({ currentUser, initialDocId, initialBlockId, onStatsChange, onOpenTask, onOpenSearch, }) => { const [docs, setDocs] = useState([]); const [folders, setFolders] = useState([]); const [sharedFolders, setSharedFolders] = useState([]); const [serverTagsSummary, setServerTagsSummary] = useState([]); // Dynamically compute hashtag counts directly from in-memory documents const tagsSummary = useMemo(() => { if (docs.length === 0) return serverTagsSummary; const tagMap = new Map(); for (const d of docs) { for (const b of d.blocks || []) { for (const clean of extractHashtags(b.content)) { tagMap.set(clean, (tagMap.get(clean) || 0) + 1); } } } const list: TagSummary[] = []; tagMap.forEach((count, tag) => { list.push({ tag, count }); }); return list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); }, [docs, serverTagsSummary]); const [selectedDocId, setSelectedDocId] = useState( initialDocId || readWorkspaceSession().docId || null ); const [targetBlockId, setTargetBlockId] = useState(initialBlockId || null); const [newlyCreatedDocId, setNewlyCreatedDocId] = useState(null); // Journal open and the last regular note survive switching to other tabs (the view remounts) const [subView, setSubView] = useState(() => !initialDocId && isJournalEnabled() && readWorkspaceSession().journal ? 'journal' : 'editor' ); const [trash, setTrash] = useState([]); // Bumped after a restore so the open editor reloads the note without the trash banner const [editorNonce, setEditorNonce] = useState(0); const reloadTrash = useCallback(() => { api.getTrash().then(setTrash).catch(() => {}); }, []); useEffect(() => { reloadTrash(); }, [reloadTrash]); // Journal: one note per day, listed separately from the folder tree (can be switched off per device) const [journalEnabled] = useState(isJournalEnabled); const [journalDays, setJournalDays] = useState([]); const [journalExtra, setJournalExtra] = useState([]); const [journalActive, setJournalActive] = useState(todayISO); const [journalJump, setJournalJump] = useState<{ date: string; nonce: number } | null>(null); const reloadJournal = useCallback(() => { if (!journalEnabled) return; api.getJournal().then(setJournalDays).catch(() => {}); }, [journalEnabled]); useEffect(() => { reloadJournal(); }, [reloadJournal]); // After typing, refresh which days have text (calendar dots) – at most every few seconds const journalReloadTimer = useRef(null); const reloadJournalSoon = useCallback(() => { if (journalReloadTimer.current !== null) return; journalReloadTimer.current = window.setTimeout(() => { journalReloadTimer.current = null; reloadJournal(); }, 3000); }, [reloadJournal]); useEffect(() => () => { if (journalReloadTimer.current !== null) window.clearTimeout(journalReloadTimer.current); }, []); const journalDocIds = useMemo(() => new Set(journalDays.map((d) => d.docId)), [journalDays]); 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 [expandedFolders, setExpandedFolders] = useState>(() => { try { const saved = localStorage.getItem('mindsift:workspace_expanded_folders'); return saved ? JSON.parse(saved) : {}; } catch { return {}; } }); useEffect(() => { try { localStorage.setItem('mindsift:workspace_expanded_folders', JSON.stringify(expandedFolders)); } catch {} }, [expandedFolders]); const [showNewFolderModal, setShowNewFolderModal] = useState(false); const [newFolderCoords, setNewFolderCoords] = useState<{ x: number; y: number } | null>(null); const [newFolderParentPath, setNewFolderParentPath] = useState(''); const [newFolderName, setNewFolderName] = useState(''); const handleOpenNewFolder = (parentPath = '', e?: React.MouseEvent) => { setNewFolderParentPath(parentPath); setNewFolderName(''); if (e) { setNewFolderCoords({ x: Math.min(e.clientX, window.innerWidth - 270), y: Math.min(e.clientY, window.innerHeight - 200), }); } else { setNewFolderCoords({ x: 100, y: 150 }); } setShowNewFolderModal(true); }; const [mobileShowDetail, setMobileShowDetail] = useState( Boolean(initialDocId) || subView === 'journal' ); 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; }); }, []); const [docSaveStatus, setDocSaveStatus] = useState('saved'); const [docSaveError, setDocSaveError] = useState(null); const [docHeaderStatus, setDocHeaderStatus] = useState(null); const [docActionsTarget, setDocActionsTarget] = useState(null); // Distraction-free mode: only the note, full window, reading width const [focusMode, setFocusMode] = useState(false); // Focus mode: the folder sidebar slides in from the left (button, title click or mouse at the edge) const [zenSidebar, setZenSidebar] = useState<'closed' | 'click' | 'hover'>('closed'); const [zenActionsTarget, setZenActionsTarget] = useState(null); // Drag and Drop state const [draggingDocId, setDraggingDocId] = useState(null); // Merging notes: dialog (picker/confirmation) and a toast with undo afterwards const [mergeDialog, setMergeDialog] = useState<{ sourceId: string; targetId?: string } | null>(null); const [mergeHoverId, setMergeHoverId] = useState(null); const [toast, setToast] = useState<{ text: string; actions: { label: string; run: () => void }[] } | null>(null); const toastTimerRef = useRef(null); const showToast = useCallback((text: string, actions: { label: string; run: () => void }[] = []) => { setToast({ text, actions }); if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current); toastTimerRef.current = window.setTimeout(() => setToast(null), actions.length ? 10000 : 4000); }, []); useEffect(() => () => { if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current); }, []); const [draggingFolderPath, setDraggingFolderPath] = useState(null); const [dragOverFolder, setDragOverFolder] = useState(null); // A drag that ends without a drop (released elsewhere, Escape) fires only dragend: clear every drag state useEffect(() => { const clear = () => { setDraggingDocId(null); setDraggingFolderPath(null); setDragOverFolder(null); setMergeHoverId(null); }; document.addEventListener('dragend', clear); return () => document.removeEventListener('dragend', clear); }, []); // Rollup drilldown & View Mode const [todoFolderDrilldown, setTodoFolderDrilldown] = useState(null); const [todoGrouping, setTodoGrouping] = useState<'grouped' | 'list'>(() => { const saved = localStorage.getItem('mindsift_todo_grouping'); return saved === 'list' ? 'list' : 'grouped'; }); 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, journalRes] = await Promise.all([ api.getDocuments(), api.getFolders(), api.getSharedFolders().catch(() => []), api.getWorkspaceTags().catch(() => []), isJournalEnabled() ? api.getJournal().catch(() => [] as JournalDay[]) : Promise.resolve([] as JournalDay[]), ]); const journalIds = new Set(journalRes.map((d) => d.docId)); setDocs(docsRes); setFolders(foldersRes); setSharedFolders(sharedFoldersRes); setServerTagsSummary(tagsRes); setSelectedDocId((prevId) => { if (prevId && docsRes.some((d) => d.id === prevId)) { return prevId; } // Not a journal day: those open in the journal const first = docsRes.find((d) => !(d.isOwner && journalIds.has(d.id))); return first ? first.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)); reloadTrash(); } else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') { api.getFolders().then(setFolders).catch(() => {}); api.getSharedFolders().then(setSharedFolders).catch(() => {}); api.getDocuments().then(setDocs).catch(() => {}); reloadTrash(); } }); return unsub; }, [currentUser, reloadTrash]); 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); }, []); const openJournalDay = useCallback(async (date: string): Promise => { try { const doc = await api.openJournalDay(date); setDocs((prev) => (prev.some((d) => d.id === doc.id) ? prev : [doc, ...prev])); setJournalDays((prev) => [ ...prev.filter((d) => d.date !== date), { date, docId: doc.id, empty: !(doc.content || '').trim() }, ].sort((a, b) => b.date.localeCompare(a.date)) ); return doc; } catch { return null; } }, []); const jumpToJournalDay = useCallback((date: string) => { setJournalExtra((prev) => (prev.includes(date) ? prev : [...prev, date])); setJournalActive(date); setJournalJump({ date, nonce: Date.now() }); }, []); const openJournal = useCallback(() => { reloadJournal(); setSubView('journal'); setMobileShowDetail(true); }, [reloadJournal]); // A second click closes the journal and shows the regular note again const toggleJournal = useCallback(() => { if (subView === 'journal') { setSubView('editor'); setSelectedDocId((prev) => prev ?? treeDocsRef.current[0]?.id ?? null); } else { openJournal(); } }, [subView, openJournal]); const openTrash = useCallback(() => { reloadTrash(); setSubView('trash'); setMobileShowDetail(true); }, [reloadTrash]); 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(); reloadTrash(); } catch { // ignore } }, [onStatsChange, selectedDocId, reloadTrash] ); const handleOpenTrashedDoc = (docId: string) => { setSelectedDocId(docId); setTargetBlockId(null); setNewlyCreatedDocId(null); setSubView('editor'); setMobileShowDetail(true); }; const handleRestoreDocument = useCallback( async (docId: string) => { await api.restoreDocument(docId); const [freshDocs, freshFolders] = await Promise.all([api.getDocuments(), api.getFolders()]); setDocs(freshDocs); setFolders(freshFolders); setEditorNonce((n) => n + 1); reloadTrash(); onStatsChange(); }, [reloadTrash, onStatsChange] ); const handlePurgeDocument = async (docId: string) => { await api.purgeDocument(docId); if (selectedDocId === docId) setSelectedDocId(null); reloadTrash(); }; const handleEmptyTrash = async () => { await api.emptyTrash(); if (selectedDocId && trash.some((t) => t.id === selectedDocId)) setSelectedDocId(null); reloadTrash(); }; 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 cleanName = newFolderName.trim().replace(/^\/+|\/+$/g, ''); if (!cleanName) return; const cleanPath = newFolderParentPath ? `${newFolderParentPath}/${cleanName}` : cleanName; try { const res = await api.createFolder(cleanPath); 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(''); setNewFolderParentPath(''); setShowNewFolderModal(false); } catch (err: any) { alert(err.message || 'Fehler beim Erstellen des Ordners'); } }; 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(); reloadTrash(); } 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 requestMerge = (sourceId: string, targetId?: string) => { const source = docs.find((d) => d.id === sourceId); if (!source?.isOwner) { showToast('Nur eigene Notizen lassen sich in eine andere einfügen'); return; } setMergeDialog({ sourceId, targetId }); }; // Dropping a dragged note onto another note (instead of a folder) offers to merge them const docDropProps = (targetId: string): React.HTMLAttributes => ({ onDragOver: (e) => { if (!draggingDocId || draggingDocId === targetId) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'move'; if (mergeHoverId !== targetId) setMergeHoverId(targetId); }, onDragLeave: () => setMergeHoverId((cur) => (cur === targetId ? null : cur)), onDrop: (e) => { const sourceId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId; if (!sourceId || sourceId === targetId) return; e.preventDefault(); e.stopPropagation(); setDraggingDocId(null); setMergeHoverId(null); setDragOverFolder(null); requestMerge(sourceId, targetId); }, }); const handleMerged = async (result: MergeResult) => { setMergeDialog(null); triggerHaptic(20); setDocs((prev) => prev.filter((d) => d.id !== result.sourceId)); setSelectedDocId(result.targetId); setTargetBlockId(null); setSubView('editor'); setMobileShowDetail(true); setEditorNonce((n) => n + 1); reloadTrash(); showToast(`„${result.sourceTitle || 'Notiz'}“ wurde angehängt`, [ { label: 'Rückgängig', run: async () => { try { await api.undoMerge(result); await loadWorkspaceData(); setSelectedDocId(result.sourceId); setEditorNonce((n) => n + 1); reloadTrash(); showToast('Zusammenführen rückgängig gemacht'); } catch (err: any) { showToast(err?.message || 'Rückgängig fehlgeschlagen'); } }, }, ]); }; 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, blockId?: string) => { const clean = pageTitle.trim().toLowerCase(); const match = docs.find((d) => d.title.trim().toLowerCase() === clean); if (match && blockId) { handleJumpToDocBlock(match.id, blockId); } else if (match) { setSelectedDocId(match.id); setSubView('editor'); setMobileShowDetail(true); } else { handleCreateDocument(''); } }, [docs, handleCreateDocument, handleJumpToDocBlock] ); 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 { const [result] = await sendDocOps(doc.id, [{ kind: 'set_props', blockId, patch: { checked: !currentChecked } }]); if (result?.status !== 'applied') loadWorkspaceData(); } catch { loadWorkspaceData(); } }; // Own journal notes appear under "Journal", not in the folder tree const treeDocs = useMemo( () => (journalDocIds.size ? docs.filter((d) => !(d.isOwner && journalDocIds.has(d.id))) : docs), [docs, journalDocIds] ); const treeDocsRef = useRef(treeDocs); treeDocsRef.current = treeDocs; useEffect(() => { writeWorkspaceSession({ journal: subView === 'journal', docId: selectedDocId }); }, [subView, selectedDocId]); const canFocus = subView === 'editor' && Boolean(selectedDocId); useEffect(() => { if (!canFocus) setFocusMode(false); }, [canFocus]); // Leaving focus mode or picking another note closes the sliding sidebar useEffect(() => { setZenSidebar('closed'); }, [focusMode, selectedDocId]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'f' && canFocus) { e.preventDefault(); setFocusMode((v) => !v); } else if (e.key === 'Escape' && focusMode && !e.defaultPrevented) { if (zenSidebar !== 'closed') setZenSidebar('closed'); else setFocusMode(false); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [canFocus, focusMode, zenSidebar]); 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 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 = treeDocs.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, treeDocs, sharedFolders]); const pinnedDocs = useMemo( () => treeDocs.filter((d) => d.isPinned), [treeDocs] ); const rootDocs = useMemo( () => treeDocs.filter((d) => !d.folderPath && !d.isPinned), [treeDocs] ); // 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 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]); // Register mobile FAB actions and the menu at the top; on phones the menu also switches between // notes, to-dos and hashtags (like the lists in Tasks and Shopping lists), the tab row is hidden there useEffect(() => { setPrimaryAction(() => handleCreateDocument('')); setGroups([ { id: 'workspace-views', title: 'Ansicht', items: [ { id: 'view-notes', label: 'Notizen', icon: , active: subView === 'editor' || subView === 'journal' || subView === 'trash', onClick: () => { setSubView('editor'); setMobileShowDetail(false); }, }, { id: 'view-todos', label: 'To-Dos', icon: , badge: totalOpenTodos || undefined, active: subView === 'todos', onClick: () => { setSubView('todos'); setMobileShowDetail(true); }, }, { id: 'view-tags', label: 'Hashtags', icon: , badge: tagsSummary.length || undefined, active: subView === 'tags' || subView === 'tag_detail', onClick: () => { setSubView('tags'); setMobileShowDetail(true); }, }, ], }, { 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), }, { id: 'trash', label: 'Papierkorb', icon: , badge: trash.length, onClick: openTrash, }, ], }, ]); return () => { setGroups([]); setPrimaryAction(undefined); }; }, [handleCreateDocument, setGroups, setPrimaryAction, trash.length, openTrash, subView, totalOpenTodos, tagsSummary.length]); // 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 */} {/* Phones: the views are switched in the menu at the top, so the row is hidden there */}
{/* Workspace selector tabs & left sidebar toggle */}
{/* Right Header Section: Live/Share Status, Avatars & Save Status (on phones it sits compactly in the note's own header) */}
{subView === 'editor' && selectedDocId ? ( <> {/* Fixed order: people first (they come and go without moving the buttons), status, actions */} {docHeaderStatus && ( )} {/* Live connection pill */} {docHeaderStatus?.showLive && ( {docHeaderStatus.channel === 'longpoll' ? 'Live (Long-Polling)' : docHeaderStatus.channel === 'interval' ? 'Abgleich alle 3 s' : 'Live'} )} {/* Share pill */} {selectedDocObj && !selectedDocObj.isArchived && (() => { const isOwner = selectedDocObj.isOwner !== false && (selectedDocObj.userId === currentUser?.id || !selectedDocObj.userId || currentUser?.role === 'admin'); const sharesCount = selectedDocObj.sharedWith?.length || 0; const isShared = sharesCount > 0 || !isOwner || Boolean(selectedDocObj.sharedFolderId); const ownerDisplayName = selectedDocObj.ownerName || 'Eigentümer'; if (!isShared) return null; return isOwner ? ( ) : ( ); })()} {/* Save status: icon only, the text is the tooltip */}
{docSaveStatus === 'saved' && ( <> )} {docSaveStatus === 'saving' && ( <> )} {docSaveStatus === 'unsaved' && ( <> )} {docSaveStatus === 'error' && ( <> )}
) : null} {/* The open note renders undo/redo, share and its menu here on large screens */} {subView === 'editor' && selectedDocId && ( )}
{/* Main Workspace Split Pane */}
{focusMode && ( <> {/* Header slides in when the mouse touches the top edge (large screens) */}
{/* Wide screens: touching the left edge opens the sidebar (the note sits far from it) */}
setZenSidebar((v) => (v === 'closed' ? 'hover' : v))} aria-hidden /> {zenSidebar === 'click' && (
setZenSidebar('closed')} aria-hidden /> )} )} {/* LEFT MASTER SIDEBAR */} {/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */}
{subView === 'journal' && journalEnabled ? ( docs.find((d) => d.id === id)} currentUser={currentUser} availableFolders={allFolderPaths} onOpenShareModal={openDocShareModal} onDocMetadataChange={handleDocMetadataChange} onDeleteDoc={handleDeleteDocument} onSelectTag={handleSelectTag} onWikiLinkClick={handleWikiLinkClick} onOpenDocBlock={handleJumpToDocBlock} onOpenTask={onOpenTask} onBackMobile={() => setMobileShowDetail(false)} onDaySaved={reloadJournalSoon} /> ) : subView === 'trash' ? ( { setSubView('editor'); setMobileShowDetail(false); }} /> ) : subView === 'editor' && selectedDocId ? ( setMergeDialog({ sourceId: d.id })} onDocCreated={(created) => { setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev])); if (created.folderPath) { const parts = created.folderPath.split('/'); const open: Record = {}; parts.forEach((_, i) => { open[parts.slice(0, i + 1).join('/')] = true; }); setExpandedFolders((prev) => ({ ...prev, ...open })); } onStatsChange(); }} onDocRemoved={(id) => { setDocs((prev) => prev.filter((d) => d.id !== id)); reloadTrash(); onStatsChange(); }} onBackMobile={() => setMobileShowDetail(false)} onOpenTask={onOpenTask} onSaveStatusChange={(status, err) => { setDocSaveStatus(status); setDocSaveError(err || null); }} onHeaderStatusChange={setDocHeaderStatus} actionsTarget={focusMode ? zenActionsTarget : docActionsTarget} focusMode={focusMode} onToggleFocusMode={() => setFocusMode((v) => !v)} /> ) : 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 && ( )}
) : (
{/* As many columns as fit with at least 18rem each, so tag names and note titles have room */} {filteredTags.map((t) => { const meta = tagsMetadataMap.get(t.tag.toLowerCase()); return (
handleSelectTag(t.tag)} className="p-4 rounded-card border border-border bg-panel hover:border-accent hover:bg-panel-elevated transition-all cursor-pointer group flex flex-col justify-between space-y-3" >
#

{t.tag.replace(/^#/, '')}

{t.count} Treffer
{meta && meta.docs.length > 0 && (
Aktuellste Notiz:
{meta.docs[0].icon || '📝'} {meta.docs[0].title || 'Unbenannte Notiz'}
)}
{meta && meta.openTodosCount > 0 && ( {meta.openTodosCount} offene To-Dos )}
Details →
); })}
)}
) : subView === 'tag_detail' && selectedTag ? (
{/* Back to all tags */}
{/* Tag Context Header */}
#

#{selectedTag}

{/* Tag Context Note */}