frontend: folders

This commit is contained in:
2025-10-27 01:31:06 +01:00
parent 264316eb29
commit b260065245
3 changed files with 317 additions and 1 deletions
+63
View File
@@ -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 = ({
<strong>Pages:</strong> {pageCountValue}
</div>
) : null}
{singleFolderPath?.length ? (
<div>
<strong>Folder:</strong>{' '}
<span className="detail-folder-path">
{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 (
<React.Fragment key={key}>
{index > 0 ? <span className="detail-folder-path__separator">/</span> : null}
{isClickable ? (
<a
href={href}
className="detail-folder-path__link"
onClick={(event) => {
if (
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
event.stopPropagation();
onFolderNavigate(targetId);
}}
>
{label}
</a>
) : (
<span className="detail-folder-path__segment">{label}</span>
)}
</React.Fragment>
);
})}
</span>
</div>
) : null}
<div>
<strong>Original filename:</strong>{' '}
{singleDoc.original_name}
+209 -1
View File
@@ -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(
+45
View File
@@ -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;