diff --git a/frontend/src/app/useWorkspaceSurface.tsx b/frontend/src/app/useWorkspaceSurface.tsx
index 99ed49a..6b94b9c 100644
--- a/frontend/src/app/useWorkspaceSurface.tsx
+++ b/frontend/src/app/useWorkspaceSurface.tsx
@@ -4,6 +4,7 @@ import { SidebarExpandIcon } from '../ui/icons';
import DocumentsPanel from '../documents/panel/DocumentsPanel';
import DocumentViewerPanel from '../preview/DocumentViewerPanel';
import { usePanelManager } from './PanelManagerContext';
+import { FolderManagerProvider } from '../folders/FolderManagerContext';
type Identifier = string | number;
@@ -91,8 +92,15 @@ export const useWorkspaceSurface = ({
const detail = detailPanelOpen && detailPanelProps
? (() => {
- const { onClose, onOpenPreview, tags: tagOptions, ...restDetailProps } = detailPanelProps;
- return (
+ const {
+ onClose,
+ onOpenPreview,
+ tags: tagOptions,
+ folderNodes,
+ ensureFolderData,
+ ...restDetailProps
+ } = detailPanelProps;
+ const viewer = (
);
+ if (folderNodes && ensureFolderData) {
+ return (
+
+ {viewer}
+
+ );
+ }
+ return (
+ <>{viewer}>
+ );
})()
: null;
@@ -140,35 +158,44 @@ export const useWorkspaceSurface = ({
onUpdateTitle,
onUpdateIssued,
resolveFolderPath,
+ folderNodes,
+ ensureFolderData,
} = detailExtras;
- return {
- content: (
-
- ),
- detail: null,
- };
+ const viewer = (
+
+ );
+
+ const content = folderNodes && ensureFolderData
+ ? (
+
+ {viewer}
+
+ )
+ : viewer;
+
+ return { content, detail: null };
}, [
showPreviewWorkspace,
previewWorkspaceDocument,
diff --git a/frontend/src/detail/useDetailWorkspace.ts b/frontend/src/detail/useDetailWorkspace.ts
index 115684c..91a050e 100644
--- a/frontend/src/detail/useDetailWorkspace.ts
+++ b/frontend/src/detail/useDetailWorkspace.ts
@@ -25,11 +25,6 @@ interface FolderNode {
parentId?: Identifier | 'root';
}
-type DocumentLink = {
- url?: string;
- contentType?: string | null;
-} | null;
-
interface UseDetailWorkspaceArgs {
documents: DocumentLike[];
selectionOrder: string[];
@@ -39,7 +34,6 @@ interface UseDetailWorkspaceArgs {
ensureFolderData: (folderId: Identifier | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise;
detailPanelControlRef: MutableRefObject<{ open?: (args?: { documentIds?: Identifier[] }) => void; close?: () => void } | null>;
detailFolderFetchRef: MutableRefObject>;
- documentLinks: Map;
previewDocumentId?: Identifier | null;
activePreviewId?: Identifier | null;
openDocumentPreview?: (args: { documentIds: Identifier[] }) => void;
@@ -67,7 +61,6 @@ interface UseDetailWorkspaceResult {
inspectDocument: (docId: Identifier | null) => void;
previewActive: boolean;
previewWorkspaceDocument: DocumentLike | null;
- documentLink: DocumentLink;
resolveThumbnailUrlForDoc: (doc: DocumentLike | null) => string | null;
resolveFolderPath: (folderId?: Identifier | 'root') => Array<{ id: Identifier | 'root'; name: string }>;
}
@@ -81,7 +74,6 @@ const useDetailWorkspace = ({
ensureFolderData,
detailPanelControlRef,
detailFolderFetchRef,
- documentLinks,
previewDocumentId,
activePreviewId,
openDocumentPreview,
@@ -239,11 +231,6 @@ const useDetailWorkspace = ({
[folderNodes],
);
- const documentLink = useMemo(
- () => (detailPanelDocument ? documentLinks.get(detailPanelDocument.id) || null : null),
- [detailPanelDocument, documentLinks],
- );
-
const previewWorkspaceDocument = useMemo(() => {
if (!previewDocumentId) {
return null;
@@ -285,7 +272,6 @@ const useDetailWorkspace = ({
tagLookupById,
onTagAdd: handleDocumentTagAdd,
onTagRemove: handleTagRemove,
- documentLink,
onOpenPreview: openDocumentPreview,
activePreviewId,
onUpdateTitle: handleDocumentTitleUpdate,
@@ -299,6 +285,8 @@ const useDetailWorkspace = ({
onFolderNavigate: selectFolder,
onClose: handleDetailPanelClose,
resolveFolderPath,
+ folderNodes,
+ ensureFolderData,
}),
[
activePreviewId,
@@ -313,11 +301,12 @@ const useDetailWorkspace = ({
handleDocumentIssuedUpdate,
handleDocumentTitleUpdate,
handleTagRemove,
+ folderNodes,
+ ensureFolderData,
openDocumentPreview,
resolveApiPath,
resolveFolderPath,
selectFolder,
- documentLink,
tags,
tagLookupById,
],
@@ -332,7 +321,6 @@ const useDetailWorkspace = ({
inspectDocument,
previewActive,
previewWorkspaceDocument,
- documentLink,
resolveThumbnailUrlForDoc,
resolveFolderPath,
};
diff --git a/frontend/src/documents/DocumentSummarySection.tsx b/frontend/src/documents/DocumentSummarySection.tsx
index e73bd75..258be49 100644
--- a/frontend/src/documents/DocumentSummarySection.tsx
+++ b/frontend/src/documents/DocumentSummarySection.tsx
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
+import { Link } from 'react-router-dom';
import { EditIcon, IconX, CheckIcon, PlusIcon } from '../ui/icons';
import SelectionAssignmentMenu, {
SelectionAssignmentMenuItem,
@@ -12,6 +13,7 @@ import {
} from '../utils/date';
import { describeDocumentSummary, type DocumentSummaryRow } from './documentSummary';
import { isPlainObject } from '../utils/typeGuards';
+import { useFolderManager } from '../folders/FolderManagerContext';
type Identifier = string | number;
@@ -31,6 +33,7 @@ interface DocumentLike {
id?: Identifier;
title?: string;
issued_at?: string | null;
+ folder_id?: string | null;
current_version?: { version_number?: number } | null;
tags?: TagEntry[];
correspondents?: CorrespondentEntry[];
@@ -71,6 +74,7 @@ export interface DocumentSummarySectionProps {
onCorrespondentRemove?: (payload: { documentId: Identifier | undefined; correspondentId: Identifier | undefined }) => void;
onUpdateTitle?: (docId: Identifier | undefined, title: string) => Promise | boolean;
onUpdateIssued?: (docId: Identifier | undefined, timestamp: number | null) => Promise | boolean;
+ onFolderNavigate?: (folderId: string | null) => void;
layout?: 'default' | 'compact';
}
@@ -456,8 +460,10 @@ const DocumentSummarySection: React.FC = ({
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued,
+ onFolderNavigate,
layout = 'default',
}) => {
+ const folderManager = useFolderManager();
const isCompactLayout = layout === 'compact';
const summaryRows = useMemo(() => describeDocumentSummary(document), [document]);
const issuedDateLabel = useMemo(
@@ -499,6 +505,39 @@ const DocumentSummarySection: React.FC = ({
return rows;
}, [document?.current_version?.version_number]);
+ const resolvedFolderId = document?.folder_id ?? null;
+
+ const [folderName, setFolderName] = useState(() => folderManager.getNameSync(resolvedFolderId));
+
+ useEffect(() => {
+ let active = true;
+ const cached = folderManager.getNameSync(resolvedFolderId);
+ setFolderName(cached);
+ if (!cached && resolvedFolderId != null) {
+ folderManager.resolveName(resolvedFolderId).then((name) => {
+ if (active) {
+ setFolderName(name);
+ }
+ }).catch(() => {});
+ }
+ return () => {
+ active = false;
+ };
+ }, [resolvedFolderId, folderManager]);
+
+ const folderHref = resolvedFolderId == null ? '/documents' : `/documents/folder/${resolvedFolderId}`;
+
+ const handleFolderClick = useCallback(
+ (event: React.MouseEvent) => {
+ if (!onFolderNavigate) {
+ return;
+ }
+ event.preventDefault();
+ onFolderNavigate(resolvedFolderId);
+ },
+ [onFolderNavigate, resolvedFolderId],
+ );
+
const [titleDraft, setTitleDraft] = useState('');
const [titleSaving, setTitleSaving] = useState(false);
const [titleError, setTitleError] = useState(null);
@@ -741,11 +780,22 @@ const DocumentSummarySection: React.FC = ({
/>
);
+ const folderValueContent = (
+
+ {folderName}
+
+ );
+
const summaryRowOverrides = {
title: { valueContent: titleMetaDisplay, error: titleError },
issued: { valueContent: issuedDisplay, error: issuedError },
tags: { valueContent: tagsValueContent },
correspondents: { valueContent: correspondentsValueContent },
+ folder: { valueContent: folderValueContent },
} as Record;
const baseRows: MetaItem[] = [...summaryRows, ...extraSummaryRows].map((row) => {
diff --git a/frontend/src/documents/documentSummary.ts b/frontend/src/documents/documentSummary.ts
index 9e6467f..eac86d8 100644
--- a/frontend/src/documents/documentSummary.ts
+++ b/frontend/src/documents/documentSummary.ts
@@ -1,5 +1,6 @@
import { formatFileSize } from '../utils/format';
import { formatDateTime as defaultFormatDateTime } from '../utils/date';
+import { DEFAULT_FOLDER_NAME } from '../app/appLayoutUtils';
interface DocumentPageMetadata {
page_count?: number | string | null;
@@ -24,11 +25,13 @@ export interface SummaryDocument {
original_name?: string | null;
filename?: string | null;
content_type?: string | null;
+ folder_id?: string | null;
+ folder_name?: string;
current_version?: DocumentVersion | null;
created_at?: string | null;
updated_at?: string | null;
issued_at?: string | null;
- folder_path?: string | null;
+ folder_path?: string;
tags?: TagEntry[] | null;
correspondents?: CorrespondentEntry[] | null;
}
@@ -37,7 +40,7 @@ interface DescribeSummaryOptions {
formatDateTime?: typeof defaultFormatDateTime;
}
-export type DocumentSummaryRowType = 'text' | 'editable-title' | 'editable-issued' | 'tags' | 'correspondents';
+export type DocumentSummaryRowType = 'text' | 'editable-title' | 'editable-issued' | 'tags' | 'correspondents' | 'folder';
export interface DocumentSummaryRow {
key: string;
@@ -86,6 +89,7 @@ export const describeDocumentSummary = (document?: SummaryDocument | null, optio
const metadata = doc.current_version?.metadata || null;
const pageCount = coercePageCount(metadata);
const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—';
+ const folderLabel = doc.folder_id == null ? DEFAULT_FOLDER_NAME : `Folder ${doc.folder_id}`;
const tags = sanitizeArray(doc.tags);
const correspondents = sanitizeArray(doc.correspondents);
const tagLabels = tags.map((tag) => tag.label).filter(Boolean) as string[];
@@ -99,6 +103,7 @@ export const describeDocumentSummary = (document?: SummaryDocument | null, optio
{ key: 'issued', label: 'Issued', value: formatDateLabel(doc.issued_at), kind: 'editable-issued' },
{ key: 'created', label: 'Created at', value: formatDateLabel(doc.created_at) },
{ key: 'updated', label: 'Updated at', value: formatDateLabel(doc.updated_at) },
+ { key: 'folder', label: 'Folder', value: folderLabel, kind: 'folder' },
{ key: 'size', label: 'Size', value: sizeLabel },
{ key: 'content-type', label: 'Content type', value: doc.content_type || 'Unknown' },
{ key: 'pages', label: 'Pages', value: pageCountLabel },
diff --git a/frontend/src/folders/FolderManagerContext.tsx b/frontend/src/folders/FolderManagerContext.tsx
new file mode 100644
index 0000000..9fbb1a6
--- /dev/null
+++ b/frontend/src/folders/FolderManagerContext.tsx
@@ -0,0 +1,59 @@
+import React, { createContext, useContext, useMemo, type ReactNode } from 'react';
+import { DEFAULT_FOLDER_NAME } from '../app/appLayoutUtils';
+
+type FolderId = string | null;
+
+export interface FolderManager {
+ getNameSync: (folderId: FolderId) => string | null;
+ resolveName: (folderId: FolderId) => Promise;
+}
+
+const defaultManager: FolderManager = {
+ getNameSync: (folderId) => (folderId == null ? DEFAULT_FOLDER_NAME : `Folder ${folderId}`),
+ resolveName: async (folderId) => (folderId == null ? DEFAULT_FOLDER_NAME : `Folder ${folderId}`),
+};
+
+const FolderManagerContext = createContext(defaultManager);
+
+interface FolderManagerProviderProps {
+ folderNodes?: Map;
+ ensureFolderData?: (folderId: string | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise;
+ children: ReactNode;
+}
+
+export const FolderManagerProvider: React.FC = ({
+ folderNodes,
+ ensureFolderData,
+ children,
+}) => {
+ const value = useMemo(() => {
+ if (!folderNodes || !ensureFolderData) {
+ return defaultManager;
+ }
+
+ const getNameSync = (folderId: FolderId) => {
+ if (folderId == null) return DEFAULT_FOLDER_NAME;
+ return folderNodes.get(folderId)?.name ?? null;
+ };
+
+ const resolveName = async (folderId: FolderId) => {
+ const cached = getNameSync(folderId);
+ if (cached) return cached;
+ if (folderId == null) return DEFAULT_FOLDER_NAME;
+ await ensureFolderData(folderId, { includeDocuments: false });
+ return getNameSync(folderId) ?? `Folder ${folderId}`;
+ };
+
+ return { getNameSync, resolveName };
+ }, [folderNodes, ensureFolderData]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useFolderManager = (): FolderManager => useContext(FolderManagerContext);
+
+export default FolderManagerContext;
diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.ts b/frontend/src/hooks/documents/useDocumentsWorkspace.ts
index 921fcd9..ccc850d 100644
--- a/frontend/src/hooks/documents/useDocumentsWorkspace.ts
+++ b/frontend/src/hooks/documents/useDocumentsWorkspace.ts
@@ -1233,7 +1233,6 @@ const useDocumentsWorkspace = ({
inspectDocument,
previewActive,
previewWorkspaceDocument,
- documentLink,
resolveFolderPath,
} = useDetailWorkspace({
documents: viewDocuments,
@@ -1244,7 +1243,6 @@ const useDocumentsWorkspace = ({
ensureFolderData,
detailPanelControlRef,
detailFolderFetchRef,
- documentLinks,
previewDocumentId,
activePreviewId,
openDocumentPreview: openDocumentPreviewForDetail,
@@ -1599,7 +1597,6 @@ const useDocumentsWorkspace = ({
revokePasskey,
previewActive,
previewWorkspaceDocument,
- documentLink,
previewDocumentId,
closeDocumentPreview,
handleThumbnailRegeneration,
@@ -1650,7 +1647,6 @@ const useDocumentsWorkspace = ({
revokePasskey,
previewActive,
previewWorkspaceDocument,
- documentLink,
previewDocumentId,
closeDocumentPreview,
handleThumbnailRegeneration,
diff --git a/frontend/src/preview/DocumentViewerPanel.tsx b/frontend/src/preview/DocumentViewerPanel.tsx
index a67623d..65a5ca2 100644
--- a/frontend/src/preview/DocumentViewerPanel.tsx
+++ b/frontend/src/preview/DocumentViewerPanel.tsx
@@ -34,6 +34,7 @@ interface DocumentLike {
title?: string;
content_type?: string | null;
issued_at?: string | null;
+ folder_id?: string | null;
correspondents?: Array<{ id?: string | number; name?: string }>;
current_version?: {
version_number?: number;
@@ -56,11 +57,6 @@ interface AssetLike {
interface DocumentViewerPanelProps extends DocumentSummarySectionProps {
document: DocumentLike | null;
- documentLink?: {
- url?: string;
- contentType?: string | null;
- filename?: string | null;
- } | null;
ensureAssetUrl?: (docId: string | number, asset: AssetLike, options?: { force?: boolean }) => Promise;
getDocumentAsset?: (doc: DocumentLike | null, type: string) => AssetLike | null;
ensurePreviewData?: (docId: string | number, options?: { signal?: AbortSignal }) => Promise;
@@ -78,7 +74,6 @@ interface DocumentViewerPanelProps extends DocumentSummarySectionProps {
export const createDocumentViewerHeaderActions = ({
document,
actionState,
- documentLink,
onZoom,
canZoom = false,
}) => {
@@ -86,7 +81,7 @@ export const createDocumentViewerHeaderActions = ({
return null;
}
- const downloadHref = actionState?.downloadHref || documentLink?.url;
+ const downloadHref = actionState?.downloadHref;
if (!downloadHref && !(canZoom && onZoom)) {
return null;
}
@@ -122,7 +117,6 @@ export const createDocumentViewerHeaderActions = ({
const DocumentViewerPanel: React.FC = ({
document,
- documentLink,
tagLookupById,
tagOptions,
onTagAdd,
@@ -168,6 +162,16 @@ const DocumentViewerPanel: React.FC = ({
return Boolean(getDocumentAsset(document, 'ocr-text'));
}, [document, getDocumentAsset]);
+ const navigateToFolder = useCallback(
+ (folderId) => {
+ const target = folderId == null
+ ? '/documents'
+ : `/documents/folder/${folderId}`;
+ navigate(target);
+ },
+ [navigate],
+ );
+
const summaryProps = useMemo(
() => ({
tagLookupById,
@@ -180,6 +184,7 @@ const DocumentViewerPanel: React.FC = ({
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued,
+ onFolderNavigate: navigateToFolder,
}),
[
tagLookupById,
@@ -192,6 +197,7 @@ const DocumentViewerPanel: React.FC = ({
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued,
+ navigateToFolder,
],
);
@@ -251,7 +257,7 @@ const DocumentViewerPanel: React.FC = ({
const [zoomOverlayOpen, setZoomOverlayOpen] = useState(false);
- const fallbackDocumentLink = useMemo(() => {
+ const resolvedDocumentLink = useMemo(() => {
if (!document) {
return null;
}
@@ -272,14 +278,12 @@ const DocumentViewerPanel: React.FC = ({
};
}, [document, resolveApiPath]);
- const effectiveDocumentLink = documentLink?.url ? documentLink : fallbackDocumentLink;
-
const handleZoomOpen = useCallback(() => {
- if (!effectiveDocumentLink?.url) {
+ if (!resolvedDocumentLink?.url) {
return;
}
setZoomOverlayOpen(true);
- }, [effectiveDocumentLink?.url]);
+ }, [resolvedDocumentLink?.url]);
const handleZoomClose = useCallback(() => {
setZoomOverlayOpen(false);
@@ -287,7 +291,7 @@ const DocumentViewerPanel: React.FC = ({
useEffect(() => {
setZoomOverlayOpen(false);
- }, [effectiveDocumentLink?.url, document?.id]);
+ }, [resolvedDocumentLink?.url, document?.id]);
const panelRef = useRef(null);
const isStackedLayout = useViewerLayoutMode(panelRef, document?.id);
@@ -344,19 +348,6 @@ const DocumentViewerPanel: React.FC = ({
];
}, [document, resolveFolderPath]);
- const handleBreadcrumbNavigate = useCallback(
- (crumb) => {
- if (!crumb?.id) {
- return;
- }
- const target = crumb.id === 'root'
- ? '/documents'
- : `/documents/folder/${crumb.id}`;
- navigate(target);
- },
- [navigate],
- );
-
const breadcrumbTrailEntries = useMemo(() => {
if (!breadcrumbs.length) {
return [];
@@ -365,28 +356,27 @@ const DocumentViewerPanel: React.FC = ({
return breadcrumbs.map((crumb, index) => ({
id: crumb.id,
label: crumb.name,
- onClick: index < lastIndex ? () => handleBreadcrumbNavigate(crumb) : null,
+ onClick: index < lastIndex ? () => navigateToFolder(crumb.id) : null,
}));
- }, [breadcrumbs, handleBreadcrumbNavigate]);
+ }, [breadcrumbs, navigateToFolder]);
const zoomDisplay = useMemo(() => {
- if (!effectiveDocumentLink?.url || !document) {
+ if (!resolvedDocumentLink?.url || !document) {
return null;
}
const docContentType = document.content_type;
const versionContentType = document.current_version?.version?.content_type;
- const normalizedContentType = effectiveDocumentLink.contentType || docContentType || versionContentType || null;
+ const normalizedContentType = resolvedDocumentLink.contentType || docContentType || versionContentType || null;
return {
- url: effectiveDocumentLink.url,
+ url: resolvedDocumentLink.url,
alt: document.title,
contentType: normalizedContentType || undefined,
};
- }, [effectiveDocumentLink?.url, effectiveDocumentLink?.contentType, document]);
+ }, [resolvedDocumentLink?.url, resolvedDocumentLink?.contentType, document]);
const headerActions = createDocumentViewerHeaderActions({
document,
actionState,
- documentLink: effectiveDocumentLink,
onZoom: zoomDisplay ? handleZoomOpen : null,
canZoom: Boolean(zoomDisplay),
});
@@ -486,7 +476,7 @@ const DocumentViewerPanel: React.FC = ({