From 4850f9dfad168f2aa30aee19af9fa12ed12eccf1 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 27 Oct 2026 07:09:48 +0100 Subject: [PATCH] feat(workspace): recursive folder tree, link modal redesign, todo grouping & line jumping, hashtag child listing --- .../src/features/workspace/DocumentEditor.tsx | 41 +- frontend/src/features/workspace/LinkModal.tsx | 69 +- .../src/features/workspace/OutlinerBlock.tsx | 6 +- .../src/features/workspace/OutlinerTree.tsx | 26 + .../src/features/workspace/WorkspaceView.tsx | 877 ++++++++++++++---- internal/storage/sqlite.go | 24 +- 6 files changed, 811 insertions(+), 232 deletions(-) diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 2429168..cc287af 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -26,6 +26,8 @@ export interface DocumentEditorProps { currentUser?: User | null; initialDoc?: Document | null; availableFolders: string[]; + autoFocusTitle?: boolean; + initialFocusedBlockId?: string | null; onDocMetadataChange: (updated: Document) => void; onDeleteDoc: (docId: string) => void; onOpenShareModal: (doc: Document) => void; @@ -143,6 +145,8 @@ export const DocumentEditor: React.FC = ({ currentUser, initialDoc, availableFolders, + autoFocusTitle, + initialFocusedBlockId, onDocMetadataChange, onDeleteDoc, onOpenShareModal, @@ -161,6 +165,18 @@ export const DocumentEditor: React.FC = ({ const [showFolderMenu, setShowFolderMenu] = useState(false); const [zoomedBlockId, setZoomedBlockId] = useState(null); + useEffect(() => { + if (autoFocusTitle || (doc?.title === 'Neue Notiz' && !doc?.markdownCache)) { + const timer = setTimeout(() => { + if (titleInputRef.current) { + titleInputRef.current.focus(); + titleInputRef.current.select(); + } + }, 60); + return () => clearTimeout(timer); + } + }, [autoFocusTitle]); + useEffect(() => { if (zoomedBlockId && !blocks.some((b) => b.id === zoomedBlockId)) { setZoomedBlockId(null); @@ -932,6 +948,7 @@ export const DocumentEditor: React.FC = ({ onMoveBlock={handleMoveBlock} onTagClick={onSelectTag} onWikiLinkClick={onWikiLinkClick} + initialFocusedBlockId={initialFocusedBlockId} /> )} @@ -941,12 +958,12 @@ export const DocumentEditor: React.FC = ({ ========================================================================= */ <> {/* Document Icon + Title Header */} -
+
@@ -979,23 +996,6 @@ export const DocumentEditor: React.FC = ({ />
- {/* Extracted #Tags Summary Bar */} - {extractedTags.length > 0 && ( -
- - {extractedTags.map((tag) => ( - - ))} -
- )} - {/* Logseq Outliner Tree */} = ({ onMoveBlock={handleMoveBlock} onTagClick={onSelectTag} onWikiLinkClick={onWikiLinkClick} + initialFocusedBlockId={initialFocusedBlockId} /> )} diff --git a/frontend/src/features/workspace/LinkModal.tsx b/frontend/src/features/workspace/LinkModal.tsx index d6b6d7c..5c44b0d 100644 --- a/frontend/src/features/workspace/LinkModal.tsx +++ b/frontend/src/features/workspace/LinkModal.tsx @@ -111,22 +111,29 @@ export const LinkModal: React.FC = ({ return (
{ if (e.target === e.currentTarget) onClose(); }} > -
- {/* Modal Header */} -
-
- -

Link einfügen

+
+ {/* Header */} +
+
+
+ +
+
+

Link einfügen

+

+ Notiz intern verknüpfen oder externen Web-Link einbetten +

+
{/* Tab Switcher */} -
+
{/* Modal Body */} -
+
{tab === 'note' ? (
@@ -172,8 +179,8 @@ export const LinkModal: React.FC = ({ type="text" value={search} onChange={(e) => setSearch(e.target.value)} - placeholder="Notiz suchen…" - className="w-full bg-input border border-border focus:border-accent text-main text-xs pl-8 pr-3 py-1.5 rounded-btn focus:outline-none transition-colors" + placeholder="Notiz nach Titel oder Ordner filtern…" + className="input-field w-full text-xs pl-8 pr-3 py-2 text-main" />
@@ -183,22 +190,22 @@ export const LinkModal: React.FC = ({ Notizen werden geladen…
) : ( -
+
{filteredDocs.map((d) => ( + +
+ + + +
+
+ + {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 transition-colors ${ + selectedDocId === d.id && subView === 'editor' + ? 'bg-accent text-on-accent font-semibold' + : 'text-main hover:bg-panel-elevated' + }`} + > + {d.icon || '📝'} + {d.title || 'Unbenannte Notiz'} +
+ ))} + + {!hasContent && ( +
+ Leer +
+ )} +
+ )} +
+ ); +}; + export const WorkspaceView: React.FC = ({ currentUser, initialDocId, @@ -52,6 +243,8 @@ export const WorkspaceView: React.FC = ({ 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); @@ -69,9 +262,13 @@ export const WorkspaceView: React.FC = ({ const [draggingFolderPath, setDraggingFolderPath] = useState(null); const [dragOverFolder, setDragOverFolder] = useState(null); - // Rollup drilldown + // 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); @@ -174,6 +371,8 @@ export const WorkspaceView: React.FC = ({ ], }); setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev])); + setNewlyCreatedDocId(created.id); + setTargetBlockId(null); setSelectedDocId(created.id); setSubView('editor'); setMobileShowDetail(true); @@ -192,6 +391,14 @@ export const WorkspaceView: React.FC = ({ [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('')); @@ -451,6 +658,55 @@ export const WorkspaceView: React.FC = ({ 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) => 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 filteredDocs = useMemo(() => { const q = searchQuery.trim().toLowerCase(); if (!q) return docs; @@ -472,23 +728,48 @@ export const WorkspaceView: React.FC = ({ [filteredDocs] ); - // Global To-Dos rollup grouped by folder - const rollupTodosByFolder = useMemo(() => { - const folderMap = new Map(); + // 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() !== '') { - const arr = folderMap.get(fName) || []; - arr.push({ doc: d, blockId: b.id, content: b.content, checked: Boolean(b.checked) }); - folderMap.set(fName, arr); + list.push({ doc: d, blockId: b.id, content: b.content, checked: Boolean(b.checked) }); } } } - return folderMap; + 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) { @@ -670,139 +951,39 @@ export const WorkspaceView: React.FC = ({
)} - {/* Folders Tree with Drag & Drop */} - {allFolderPaths.length > 0 && ( + {/* Folders Tree with Recursive Hierarchical Nesting */} + {folderTree.length > 0 && (
Ordner
- {allFolderPaths.map((folderPath) => { - const isExpanded = Boolean(expandedFolders[folderPath]); - const folderDocs = filteredDocs.filter((d) => d.folderPath === folderPath); - const sharedFolderObj = sharedFolders.find( - (sf) => sf.mountPath === folderPath || sf.name === folderPath - ); - const isDragOver = dragOverFolder === folderPath; - - return ( -
{ - e.preventDefault(); - e.stopPropagation(); - setDragOverFolder(folderPath); - }} - onDragLeave={(e) => { - e.preventDefault(); - if (dragOverFolder === folderPath) setDragOverFolder(null); - }} - onDrop={(e) => handleDropOnFolder(folderPath, e)} - className="space-y-0.5" - > -
handleFolderDragStart(e, folderPath)} - className={`group flex items-center justify-between px-2 py-1 rounded-btn text-xs cursor-pointer transition-colors ${ - isDragOver - ? 'bg-accent/20 border-l-2 border-accent text-main font-semibold' - : 'hover:bg-panel-elevated text-muted' - }`} - > - - -
- - - -
-
- - {isExpanded && ( -
- {folderDocs.length === 0 ? ( -
- Leer -
- ) : ( - folderDocs.map((d) => ( -
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 transition-colors ${ - selectedDocId === d.id && subView === 'editor' - ? 'bg-accent text-on-accent font-semibold' - : 'text-main hover:bg-panel-elevated' - }`} - > - {d.icon || '📝'} - - {d.title || 'Unbenannte Notiz'} - -
- )) - )} -
- )} -
- ); - })} + {folderTree.map((node) => ( + { + setSelectedDocId(id); + setTargetBlockId(null); + setNewlyCreatedDocId(null); + setSubView('editor'); + setMobileShowDetail(true); + }} + /> + ))}
)} @@ -886,6 +1067,8 @@ export const WorkspaceView: React.FC = ({ currentUser={currentUser} initialDoc={selectedDocObj} availableFolders={allFolderPaths} + autoFocusTitle={newlyCreatedDocId === selectedDocId} + initialFocusedBlockId={targetBlockId} onDocMetadataChange={handleDocMetadataChange} onDeleteDoc={handleDeleteDocument} onOpenShareModal={openDocShareModal} @@ -906,7 +1089,7 @@ export const WorkspaceView: React.FC = ({ @@ -930,33 +1113,82 @@ export const WorkspaceView: React.FC = ({ {/* Matched Document Blocks */}

- Verknüpfte Outliner-Blöcke ({tagDetails?.totalMatches || 0}) + Verknüpfte Outliner-Blöcke & Unterpunkte ({tagDetails?.totalMatches || 0})

{tagDetails?.documents.map((docGroup) => (
-
+
{docGroup.matchedBlocks.map((mb) => ( -
-
• {mb.rootBlock.content}
- {mb.children.map((ch) => ( -
- ◦ {ch.content} +
+ {/* Root Bullet Point containing the hashtag */} +
handleJumpToDocBlock(docGroup.docId, mb.rootBlock.id)} + className="flex items-start gap-1.5 text-xs text-main font-medium group cursor-pointer hover:bg-panel-elevated p-1 rounded-btn transition-colors" + title="Klick: Zu dieser Zeile springen" + > + • + {mb.rootBlock.content} +
+ + {/* All Children indented */} + {mb.children && mb.children.length > 0 && ( +
+ {mb.children.map((ch) => { + const relLevel = Math.max( + 1, + (ch.level || 0) - (mb.rootBlock.level || 0) + ); + return ( +
handleJumpToDocBlock(docGroup.docId, ch.id)} + style={{ paddingLeft: `${relLevel * 16}px` }} + className="flex items-start 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" + > + {ch.type === 'todo' ? ( + + + + ) : ( + + ◦ + + )} + + {ch.content} + +
+ ); + })}
- ))} + )}
))}
@@ -968,7 +1200,7 @@ export const WorkspaceView: React.FC = ({ ) : subView === 'todos' ? (
-
+

Globaler To-Do Rollup ({totalOpenTodos} offen) @@ -977,13 +1209,69 @@ export const WorkspaceView: React.FC = ({ Alle Aufgaben aus deinen Notizen im gesamten Workspace

- + +
+ {/* View Grouping Selector */} +
+ + + +
+ + +
{/* Folder Drilldown Tag */} @@ -995,7 +1283,7 @@ export const WorkspaceView: React.FC = ({ @@ -1003,7 +1291,7 @@ export const WorkspaceView: React.FC = ({ )} {/* Grouped Folders & To-Dos */} - {rollupTodosByFolder.size === 0 ? ( + {allRollupTodos.length === 0 ? (

Keine To-Dos in Notizen gefunden!

@@ -1011,7 +1299,246 @@ export const WorkspaceView: React.FC = ({ Tippe in einem beliebigen Dokument /todo oder [] ein.

+ ) : todoGrouping === 'note' ? ( + /* Grouped by Note directly */ +
+ {Array.from(rollupTodosByNote.entries()).map(([docId, { doc, items }]) => { + const isCollapsed = collapsedTodoNotes.has(docId); + const openCount = items.filter((i) => !i.checked).length; + + return ( +
+
+
{ + 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 ? ( + + ) : ( + + )} + {doc.icon || '📝'} + {doc.title} + {doc.folderPath && ( + 📁 {doc.folderPath} + )} + + {openCount} offen + +
+ + +
+ + {!isCollapsed && ( +
+ {items.map((item) => ( +
+
+ + 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} + +
+ +
+ ))} +
+ )} +
+ ); + })} +
+ ) : todoGrouping === 'folder_note' ? ( + /* Grouped by Folder -> grouped by Note */ +
+ {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(); + 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 ( +
+ {/* Folder Header */} +
+
{ + 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 ? ( + + ) : ( + + )} + + {folderName} + + {openCount} offen + +
+ + +
+ + {/* Folder Notes Subgroups */} + {!isCollapsed && ( +
+ {Array.from(docGroupsMap.values()).map(({ doc, items: docItems }) => { + const docOpenCount = docItems.filter((i) => !i.checked).length; + return ( +
+
+ +
+ +
+ {docItems.map((item) => ( +
+
+ + + 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} + +
+ +
+ ))} +
+
+ ); + })} +
+ )} +
+ ); + })} +
) : ( + /* Grouped by Folder (Flat tasks inside folder) */
{Array.from(rollupTodosByFolder.entries()).map(([folderName, items]) => { const isCollapsed = collapsedTodoFolders.has(folderName); @@ -1050,7 +1577,7 @@ export const WorkspaceView: React.FC = ({ handleJumpToDocBlock(item.doc.id, item.blockId)} + className={`truncate flex-1 ${ item.checked ? 'line-through text-faint' - : 'text-main font-medium' + : 'text-main font-medium group-hover:text-accent' }`} > {item.content} @@ -1091,11 +1620,9 @@ export const WorkspaceView: React.FC = ({