frontend: folders
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user