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 = ({