folder in summary

This commit is contained in:
2025-11-21 15:41:55 +01:00
parent a08e975da1
commit dd7dae5f9d
7 changed files with 201 additions and 86 deletions
+56 -29
View File
@@ -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 = (
<DocumentViewerPanel
variant="sidebar"
onCollapsePanel={onClose}
@@ -101,6 +109,16 @@ export const useWorkspaceSurface = ({
{...restDetailProps}
/>
);
if (folderNodes && ensureFolderData) {
return (
<FolderManagerProvider folderNodes={folderNodes} ensureFolderData={ensureFolderData}>
{viewer}
</FolderManagerProvider>
);
}
return (
<>{viewer}</>
);
})()
: null;
@@ -140,35 +158,44 @@ export const useWorkspaceSurface = ({
onUpdateTitle,
onUpdateIssued,
resolveFolderPath,
folderNodes,
ensureFolderData,
} = detailExtras;
return {
content: (
<DocumentViewerPanel
document={previewWorkspaceDocument || null}
documentLink={documentLink}
hydrateDocument={ensurePreviewData}
tagLookupById={tagLookupById}
tagOptions={tagOptions}
onTagAdd={onTagAdd}
onTagRemove={onTagRemove}
correspondents={correspondents}
onCorrespondentAdd={onCorrespondentAdd}
onCorrespondentRemove={onCorrespondentRemove}
onUpdateTitle={onUpdateTitle}
onUpdateIssued={onUpdateIssued}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
ensurePreviewData={ensurePreviewData}
resolveApiPath={resolveApiPath}
notifyApiError={notifyApiError}
sidebarToggle={sidebarToggle}
onClosePanel={closeDocumentPreview}
resolveFolderPath={resolveFolderPath}
/>
),
detail: null,
};
const viewer = (
<DocumentViewerPanel
document={previewWorkspaceDocument || null}
documentLink={documentLink}
hydrateDocument={ensurePreviewData}
tagLookupById={tagLookupById}
tagOptions={tagOptions}
onTagAdd={onTagAdd}
onTagRemove={onTagRemove}
correspondents={correspondents}
onCorrespondentAdd={onCorrespondentAdd}
onCorrespondentRemove={onCorrespondentRemove}
onUpdateTitle={onUpdateTitle}
onUpdateIssued={onUpdateIssued}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
ensurePreviewData={ensurePreviewData}
resolveApiPath={resolveApiPath}
notifyApiError={notifyApiError}
sidebarToggle={sidebarToggle}
onClosePanel={closeDocumentPreview}
resolveFolderPath={resolveFolderPath}
/>
);
const content = folderNodes && ensureFolderData
? (
<FolderManagerProvider folderNodes={folderNodes} ensureFolderData={ensureFolderData}>
{viewer}
</FolderManagerProvider>
)
: viewer;
return { content, detail: null };
}, [
showPreviewWorkspace,
previewWorkspaceDocument,
+4 -16
View File
@@ -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<void>;
detailPanelControlRef: MutableRefObject<{ open?: (args?: { documentIds?: Identifier[] }) => void; close?: () => void } | null>;
detailFolderFetchRef: MutableRefObject<Set<Identifier | 'root'>>;
documentLinks: Map<Identifier, DocumentLink>;
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,
};
@@ -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> | boolean;
onUpdateIssued?: (docId: Identifier | undefined, timestamp: number | null) => Promise<boolean> | boolean;
onFolderNavigate?: (folderId: string | null) => void;
layout?: 'default' | 'compact';
}
@@ -456,8 +460,10 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
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<DocumentSummarySectionProps> = ({
return rows;
}, [document?.current_version?.version_number]);
const resolvedFolderId = document?.folder_id ?? null;
const [folderName, setFolderName] = useState<string | null>(() => 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<DocumentSummarySectionProps> = ({
/>
);
const folderValueContent = (
<Link
className="document-summary__folder-link"
to={folderHref}
onClick={handleFolderClick}
>
{folderName}
</Link>
);
const summaryRowOverrides = {
title: { valueContent: titleMetaDisplay, error: titleError },
issued: { valueContent: issuedDisplay, error: issuedError },
tags: { valueContent: tagsValueContent },
correspondents: { valueContent: correspondentsValueContent },
folder: { valueContent: folderValueContent },
} as Record<string, { valueContent?: React.ReactNode | null; error?: string | null }>;
const baseRows: MetaItem[] = [...summaryRows, ...extraSummaryRows].map((row) => {
+7 -2
View File
@@ -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<TagEntry>(doc.tags);
const correspondents = sanitizeArray<CorrespondentEntry>(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 },
@@ -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<string>;
}
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<FolderManager>(defaultManager);
interface FolderManagerProviderProps {
folderNodes?: Map<string | 'root', { name?: string | null }>;
ensureFolderData?: (folderId: string | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise<void>;
children: ReactNode;
}
export const FolderManagerProvider: React.FC<FolderManagerProviderProps> = ({
folderNodes,
ensureFolderData,
children,
}) => {
const value = useMemo<FolderManager>(() => {
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 (
<FolderManagerContext.Provider value={value}>
{children}
</FolderManagerContext.Provider>
);
};
export const useFolderManager = (): FolderManager => useContext(FolderManagerContext);
export default FolderManagerContext;
@@ -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,
+25 -35
View File
@@ -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<unknown>;
getDocumentAsset?: (doc: DocumentLike | null, type: string) => AssetLike | null;
ensurePreviewData?: (docId: string | number, options?: { signal?: AbortSignal }) => Promise<DocumentLike | null>;
@@ -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<DocumentViewerPanelProps> = ({
document,
documentLink,
tagLookupById,
tagOptions,
onTagAdd,
@@ -168,6 +162,16 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
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<DocumentViewerPanelProps> = ({
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued,
onFolderNavigate: navigateToFolder,
}),
[
tagLookupById,
@@ -192,6 +197,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued,
navigateToFolder,
],
);
@@ -251,7 +257,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
const [zoomOverlayOpen, setZoomOverlayOpen] = useState(false);
const fallbackDocumentLink = useMemo(() => {
const resolvedDocumentLink = useMemo(() => {
if (!document) {
return null;
}
@@ -272,14 +278,12 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
};
}, [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<DocumentViewerPanelProps> = ({
useEffect(() => {
setZoomOverlayOpen(false);
}, [effectiveDocumentLink?.url, document?.id]);
}, [resolvedDocumentLink?.url, document?.id]);
const panelRef = useRef<HTMLDivElement | HTMLFormElement | HTMLElement | null>(null);
const isStackedLayout = useViewerLayoutMode(panelRef, document?.id);
@@ -344,19 +348,6 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
];
}, [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<DocumentViewerPanelProps> = ({
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<DocumentViewerPanelProps> = ({
<section className={viewerClassName}>
<DocumentViewerLayout
document={document}
documentLink={effectiveDocumentLink}
documentLink={resolvedDocumentLink}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentTabConfig={contentTabConfig}