frontend: folders
This commit is contained in:
@@ -323,6 +323,8 @@ const DetailPanel = ({
|
|||||||
onCorrespondentAdd,
|
onCorrespondentAdd,
|
||||||
onCorrespondentRemove,
|
onCorrespondentRemove,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
|
onFolderNavigate = null,
|
||||||
|
resolveFolderPath = null,
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
}) => {
|
}) => {
|
||||||
const selectedCount = selectedDocuments.length;
|
const selectedCount = selectedDocuments.length;
|
||||||
@@ -670,6 +672,20 @@ const DetailPanel = ({
|
|||||||
return sortCorrespondents(singleDoc.correspondents || []);
|
return sortCorrespondents(singleDoc.correspondents || []);
|
||||||
}, [singleDoc]);
|
}, [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(() => {
|
const bulkCorrespondents = useMemo(() => {
|
||||||
if (selectedDocuments.length <= 1) {
|
if (selectedDocuments.length <= 1) {
|
||||||
const doc = selectedDocuments[0];
|
const doc = selectedDocuments[0];
|
||||||
@@ -1063,6 +1079,53 @@ const DetailPanel = ({
|
|||||||
<strong>Pages:</strong> {pageCountValue}
|
<strong>Pages:</strong> {pageCountValue}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
<div>
|
||||||
<strong>Original filename:</strong>{' '}
|
<strong>Original filename:</strong>{' '}
|
||||||
{singleDoc.original_name}
|
{singleDoc.original_name}
|
||||||
|
|||||||
+209
-1
@@ -594,6 +594,7 @@ const AppLayout = () => {
|
|||||||
const selectionInitializedRef = useRef(false);
|
const selectionInitializedRef = useRef(false);
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
const prefetchedFoldersRef = useRef(new Set(['root']));
|
const prefetchedFoldersRef = useRef(new Set(['root']));
|
||||||
|
const detailFolderFetchRef = useRef(new Set());
|
||||||
const selectionAnchorRef = useRef(initialRowSelection[initialRowSelection.length - 1] || null);
|
const selectionAnchorRef = useRef(initialRowSelection[initialRowSelection.length - 1] || null);
|
||||||
const selectionOrderRef = useRef(initialRowSelection);
|
const selectionOrderRef = useRef(initialRowSelection);
|
||||||
|
|
||||||
@@ -643,6 +644,7 @@ const AppLayout = () => {
|
|||||||
dragCounterRef.current = 0;
|
dragCounterRef.current = 0;
|
||||||
prefetchedFoldersRef.current = new Set(['root']);
|
prefetchedFoldersRef.current = new Set(['root']);
|
||||||
breadcrumbFetchRef.current = new Set();
|
breadcrumbFetchRef.current = new Set();
|
||||||
|
detailFolderFetchRef.current = new Set();
|
||||||
bootstrapInitializedRef.current = false;
|
bootstrapInitializedRef.current = false;
|
||||||
selectionInitializedRef.current = false;
|
selectionInitializedRef.current = false;
|
||||||
}, [assetManager]);
|
}, [assetManager]);
|
||||||
@@ -1868,19 +1870,134 @@ const AppLayout = () => {
|
|||||||
greedyPrefetchFolders(pendingIds).catch(() => {});
|
greedyPrefetchFolders(pendingIds).catch(() => {});
|
||||||
}, [folderContents, greedyPrefetchFolders]);
|
}, [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(
|
const selectFolder = useCallback(
|
||||||
async (folderId, { replace = false, immediate = false } = {}) => {
|
async (folderId, { replace = false, immediate = false } = {}) => {
|
||||||
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
|
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
|
||||||
|
|
||||||
|
await ensureFolderAncestorsLoaded(targetId);
|
||||||
|
expandFolderAncestors(targetId);
|
||||||
|
|
||||||
if (!navigate || immediate) {
|
if (!navigate || immediate) {
|
||||||
await loadFolder(targetId, { preserveSearch: isFilterActive });
|
await loadFolder(targetId, { preserveSearch: isFilterActive });
|
||||||
|
setSelectedFolder(targetId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
|
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
|
||||||
navigate(path, { replace });
|
navigate(path, { replace });
|
||||||
},
|
},
|
||||||
[loadFolder, navigate, isFilterActive],
|
[
|
||||||
|
ensureFolderAncestorsLoaded,
|
||||||
|
expandFolderAncestors,
|
||||||
|
navigate,
|
||||||
|
loadFolder,
|
||||||
|
isFilterActive,
|
||||||
|
setSelectedFolder,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const initializeAfterLogin = useCallback(async () => {
|
const initializeAfterLogin = useCallback(async () => {
|
||||||
@@ -4450,6 +4567,95 @@ const AppLayout = () => {
|
|||||||
});
|
});
|
||||||
}, [missingBreadcrumbAncestors, ensureFolderData]);
|
}, [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(() => {
|
const selectedPreviewEntry = useMemo(() => {
|
||||||
if (!selectedDocument) {
|
if (!selectedDocument) {
|
||||||
return null;
|
return null;
|
||||||
@@ -4598,7 +4804,9 @@ const AppLayout = () => {
|
|||||||
onCorrespondentAdd: handleCorrespondentAdd,
|
onCorrespondentAdd: handleCorrespondentAdd,
|
||||||
onCorrespondentRemove: handleCorrespondentRemove,
|
onCorrespondentRemove: handleCorrespondentRemove,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
|
onFolderNavigate: selectFolder,
|
||||||
onClose: clearDocumentSelection,
|
onClose: clearDocumentSelection,
|
||||||
|
resolveFolderPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
const skeuoWorkspaceProps = useMemo(
|
const skeuoWorkspaceProps = useMemo(
|
||||||
|
|||||||
@@ -71,6 +71,25 @@ body {
|
|||||||
overflow: hidden;
|
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 {
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
@@ -1821,6 +1840,32 @@ button.danger:hover:not([disabled]) {
|
|||||||
color: var(--muted);
|
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 {
|
.detail-panel .doc-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user