From b260065245fffa15b5659a742b86517e5fd31c66 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Mon, 27 Oct 2025 01:31:06 +0100 Subject: [PATCH] frontend: folders --- frontend/src/detail/DetailPanel.jsx | 63 +++++++++ frontend/src/index.jsx | 210 +++++++++++++++++++++++++++- frontend/src/styles.css | 45 ++++++ 3 files changed, 317 insertions(+), 1 deletion(-) diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index d5bcda6..4d3f0e0 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -323,6 +323,8 @@ const DetailPanel = ({ onCorrespondentAdd, onCorrespondentRemove, resolveApiPath, + onFolderNavigate = null, + resolveFolderPath = null, onClose = () => {}, }) => { const selectedCount = selectedDocuments.length; @@ -670,6 +672,20 @@ const DetailPanel = ({ return sortCorrespondents(singleDoc.correspondents || []); }, [singleDoc]); + const singleFolderPath = useMemo(() => { + if (!singleDoc?.folder_id) { + return null; + } + if (typeof resolveFolderPath !== 'function') { + return null; + } + const segments = resolveFolderPath(singleDoc.folder_id); + if (!Array.isArray(segments) || !segments.some((segment) => segment?.id && segment.id !== 'root')) { + return null; + } + return segments; + }, [singleDoc?.folder_id, resolveFolderPath]); + const bulkCorrespondents = useMemo(() => { if (selectedDocuments.length <= 1) { const doc = selectedDocuments[0]; @@ -1063,6 +1079,53 @@ const DetailPanel = ({ Pages: {pageCountValue} ) : null} + {singleFolderPath?.length ? ( +
+ Folder:{' '} + + {singleFolderPath.map((segment, index) => { + const label = segment?.name || '…'; + const targetId = segment?.id || null; + const key = `${targetId || label}-${index}`; + const isClickable = Boolean(targetId) && typeof onFolderNavigate === 'function'; + const href = !isClickable + ? null + : targetId === 'root' + ? '/documents' + : `/documents/folder/${targetId}`; + return ( + + {index > 0 ? / : null} + {isClickable ? ( + { + if ( + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + onFolderNavigate(targetId); + }} + > + {label} + + ) : ( + {label} + )} + + ); + })} + +
+ ) : null}
Original filename:{' '} {singleDoc.original_name} diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index ad0e5d3..210e146 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -594,6 +594,7 @@ const AppLayout = () => { const selectionInitializedRef = useRef(false); const dragCounterRef = useRef(0); const prefetchedFoldersRef = useRef(new Set(['root'])); + const detailFolderFetchRef = useRef(new Set()); const selectionAnchorRef = useRef(initialRowSelection[initialRowSelection.length - 1] || null); const selectionOrderRef = useRef(initialRowSelection); @@ -643,6 +644,7 @@ const AppLayout = () => { dragCounterRef.current = 0; prefetchedFoldersRef.current = new Set(['root']); breadcrumbFetchRef.current = new Set(); + detailFolderFetchRef.current = new Set(); bootstrapInitializedRef.current = false; selectionInitializedRef.current = false; }, [assetManager]); @@ -1868,19 +1870,134 @@ const AppLayout = () => { greedyPrefetchFolders(pendingIds).catch(() => {}); }, [folderContents, greedyPrefetchFolders]); + const expandFolderAncestors = useCallback( + (targetId) => { + if (!targetId || targetId === 'root') { + setFolderNodes((prev) => { + if (prev.get('root')?.expanded) { + return prev; + } + const next = new Map(prev); + const rootNode = next.get('root'); + if (rootNode) { + next.set('root', { ...rootNode, expanded: true }); + } + return next; + }); + return; + } + + setFolderNodes((prev) => { + const next = new Map(prev); + let currentId = targetId; + let guard = 0; + while (currentId && !next.has(currentId) && guard < 32) { + guard += 1; + const node = prev.get(currentId); + if (!node) { + break; + } + currentId = node.parentId ?? 'root'; + } + + currentId = targetId; + guard = 0; + while (currentId && guard < 32) { + guard += 1; + const node = next.get(currentId); + if (!node) { + break; + } + if (!node.expanded && currentId !== targetId) { + next.set(currentId, { ...node, expanded: true }); + } + currentId = node.parentId ?? 'root'; + if (!currentId || currentId === 'root') { + const rootNode = next.get('root'); + if (rootNode && !rootNode.expanded) { + next.set('root', { ...rootNode, expanded: true }); + } + break; + } + } + return next; + }); + }, + []); + + const ensureFolderAncestorsLoaded = useCallback( + async (targetId) => { + if (!targetId || targetId === 'root') { + return; + } + + const visited = new Set(); + const stack = []; + + let currentId = targetId; + let guard = 0; + while (currentId && currentId !== 'root' && guard < 32) { + guard += 1; + if (visited.has(currentId)) { + break; + } + visited.add(currentId); + + const node = folderNodes.get(currentId); + if (!node) { + stack.push(currentId); + break; + } + + const parentId = node.parentId ?? 'root'; + if (!parentId || parentId === 'root') { + break; + } + stack.push(parentId); + currentId = parentId; + } + + while (stack.length) { + const ancestorId = stack.pop(); + if (!ancestorId || ancestorId === 'root') { + continue; + } + if (!folderNodes.has(ancestorId)) { + try { + await ensureFolderData(ancestorId, { includeDocuments: false, force: false }); + } catch (error) { + console.warn('Failed to ensure ancestor folder for navigation', ancestorId, error); + } + } + } + }, + [folderNodes, ensureFolderData], + ); + const selectFolder = useCallback( async (folderId, { replace = false, immediate = false } = {}) => { const targetId = folderId && folderId !== 'root' ? folderId : 'root'; + await ensureFolderAncestorsLoaded(targetId); + expandFolderAncestors(targetId); + if (!navigate || immediate) { await loadFolder(targetId, { preserveSearch: isFilterActive }); + setSelectedFolder(targetId); return; } const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`; navigate(path, { replace }); }, - [loadFolder, navigate, isFilterActive], + [ + ensureFolderAncestorsLoaded, + expandFolderAncestors, + navigate, + loadFolder, + isFilterActive, + setSelectedFolder, + ], ); const initializeAfterLogin = useCallback(async () => { @@ -4450,6 +4567,95 @@ const AppLayout = () => { }); }, [missingBreadcrumbAncestors, ensureFolderData]); + useEffect(() => { + if (!orderedSelectedDocuments.length) { + return; + } + + const visited = new Set(); + + orderedSelectedDocuments.forEach((doc) => { + const folderId = doc?.folder_id; + if (!folderId) { + return; + } + let currentId = folderId; + let guard = 0; + while (currentId && currentId !== 'root' && guard < 32) { + guard += 1; + if (visited.has(currentId)) { + break; + } + visited.add(currentId); + const node = folderNodes.get(currentId); + if (!node) { + if (!detailFolderFetchRef.current.has(currentId)) { + detailFolderFetchRef.current.add(currentId); + ensureFolderData(currentId, { force: false, includeDocuments: false }) + .catch((error) => { + console.warn('Failed to preload folder metadata for detail path', currentId, error); + }) + .finally(() => { + detailFolderFetchRef.current.delete(currentId); + }); + } + break; + } + + const parentId = node.parentId ?? 'root'; + if (!parentId || parentId === 'root') { + break; + } + currentId = parentId; + } + }); + }, [orderedSelectedDocuments, folderNodes, ensureFolderData]); + + const resolveFolderPath = useCallback( + (folderId) => { + if (!folderId || folderId === 'root') { + return []; + } + + const segments = []; + const visited = new Set(); + let currentId = folderId; + let guard = 0; + + while (currentId && guard < 32 && !visited.has(currentId)) { + guard += 1; + visited.add(currentId); + + if (currentId === 'root') { + break; + } + + const node = folderNodes.get(currentId); + if (!node) { + segments.push({ id: currentId, name: '…' }); + break; + } + + segments.push({ id: node.id, name: node.name || 'Folder' }); + + const parentId = node.parentId ?? 'root'; + if (!parentId || parentId === 'root') { + segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); + break; + } + + currentId = parentId; + } + + if (!segments.some((segment) => segment.id === 'root')) { + segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); + } + + return segments.reverse(); + }, + [folderNodes], + ); + const selectedPreviewEntry = useMemo(() => { if (!selectedDocument) { return null; @@ -4598,7 +4804,9 @@ const AppLayout = () => { onCorrespondentAdd: handleCorrespondentAdd, onCorrespondentRemove: handleCorrespondentRemove, resolveApiPath, + onFolderNavigate: selectFolder, onClose: clearDocumentSelection, + resolveFolderPath, }; const skeuoWorkspaceProps = useMemo( diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 34d2565..31420f9 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -71,6 +71,25 @@ body { overflow: hidden; } +a { + color: var(--accent); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.18em; + transition: color 0.15s ease, text-decoration-color 0.15s ease; +} + +a:visited { + color: color-mix(in oklch, var(--accent) 75%, var(--fg) 25%); +} + +a:hover, +a:focus-visible { + color: var(--accent-hover); + outline: none; + text-decoration-color: currentColor; +} + #app { height: 100%; background: var(--bg); @@ -1821,6 +1840,32 @@ button.danger:hover:not([disabled]) { color: var(--muted); } +.detail-panel .detail-folder-path { + display: inline-flex; + flex-wrap: wrap; + gap: 0.25rem; + align-items: center; +} + +.detail-panel .detail-folder-path__link { + color: var(--accent); + text-decoration: none; +} + +.detail-panel .detail-folder-path__link:hover, +.detail-panel .detail-folder-path__link:focus-visible { + text-decoration: underline; + outline: none; +} + +.detail-panel .detail-folder-path__separator { + color: var(--muted); +} + +.detail-panel .detail-folder-path__segment { + color: var(--fg); +} + .detail-panel .doc-title-row { display: flex; align-items: center;