import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { DownloadIcon, ArrowLeftIcon, ArrowRightIcon, ChevronsRightIcon, AnalyzeIcon, WindowMaximizeIcon, } from '../ui/icons'; import PanelHeader from '../ui/PanelHeader'; import { formatFileSize } from '../utils/format'; import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; import { useAssetNavigator } from '../hooks/useAssetNavigator'; import { describeDocumentSummary } from '../documents/documentSummary'; import { createDocumentActionState } from '../documents/documentActions'; import PreviewZoomOverlay from './PreviewZoomOverlay'; import DocumentSummarySection, { TagSection, CorrespondentSection, sortCorrespondents, buildCorrespondentOptions, } from '../documents/DocumentSummarySection'; import BreadcrumbTrail from '../ui/BreadcrumbTrail'; const MAX_PREVIEW_STACK_ITEMS = 15; const derivePreviewOrientation = (metadata) => { const width = Number(metadata?.width); const height = Number(metadata?.height); if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { return width >= height ? 'landscape' : 'portrait'; } return 'landscape'; }; const computeStackAngle = (docId, index) => { if (index === 0) return 0; let hash = 0; const source = docId || `stack-${index}`; for (let i = 0; i < source.length; i += 1) { hash = (hash * 31 + source.charCodeAt(i)) % 997; } const magnitude = Math.max(3, (hash % 13) + 3); const sign = index % 2 === 0 ? 1 : -1; return magnitude * sign; }; const PreviewStack = ({ items = [], maxItems = MAX_PREVIEW_STACK_ITEMS, emptyMessage = 'Preview unavailable', onItemActivate, onOpenPreview, onZoomPreview, }) => { const limited = useMemo(() => items.slice(0, maxItems), [items, maxItems]); const hasMultiple = limited.length > 1; const preparedItems = useMemo( () => limited.map((entry, index) => ({ entry, angle: index === 0 ? 0 : computeStackAngle(entry.id, index), })), [limited], ); if (!limited.length) { return {emptyMessage}; } return (
{preparedItems.map(({ entry, angle }, index) => { const transform = hasMultiple ? `translate(-50%, -50%) rotate(${angle}deg)` : 'translate(-50%, -50%)'; const isFront = index === 0; return (
{entry.alt} { event.stopPropagation(); if (isFront) { if (onZoomPreview) { onZoomPreview(entry); } else if (onOpenPreview) { onOpenPreview(entry.id); } else if (onItemActivate) { onItemActivate(entry.id); } } else if (onItemActivate) { onItemActivate(entry.id); } }} onKeyDown={(event) => { if (!onItemActivate && !onOpenPreview && !onZoomPreview) return; if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); if (isFront) { if (onZoomPreview) { onZoomPreview(entry); } else if (onOpenPreview) { onOpenPreview(entry.id); } else { onItemActivate?.(entry.id); } } else { onItemActivate?.(entry.id); } } }} />
); })}
); }; const DetailPanel = ({ selectedDocuments = [], tags = [], tagLookupById = new Map(), onTagAdd, onTagRemove, onRegenerateThumbnails, onOpenPreview, onBulkTagAdd, onBulkTagRemove, onBulkReanalyze, onBulkCorrespondentAdd, onBulkCorrespondentRemove, onPromoteSelection, onUpdateTitle = async () => false, onUpdateIssued = async () => false, ensureAssetUrl = null, getDocumentAsset = () => null, ensurePreviewData = () => Promise.resolve(), correspondents = [], onCorrespondentAdd, onCorrespondentRemove, resolveApiPath, onFolderNavigate = null, resolveFolderPath = null, onClose = () => {}, }) => { const selectedCount = selectedDocuments.length; const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null; const singleDocId = singleDoc?.id || null; const selectionKey = useMemo( () => selectedDocuments.map((doc) => doc?.id ?? '').join('|'), [selectedDocuments], ); const { downloadHref: singleDownloadHref } = useMemo( () => createDocumentActionState({ document: singleDoc, resolveApiPath, ensurePreviewData, ensureAssetUrl, getDocumentAsset, }), [singleDoc, resolveApiPath, ensurePreviewData, ensureAssetUrl, getDocumentAsset], ); const detailSummary = useMemo(() => describeDocumentSummary(singleDoc), [singleDoc]); const headerTitle = useMemo(() => { if (selectedCount === 0) { return 'Document details'; } if (selectedCount === 1) { return detailSummary.title; } return `${selectedCount} document${selectedCount === 1 ? '' : 's'}`; }, [selectedCount, detailSummary]); const headerBreadcrumbs = useMemo(() => { if (selectedCount !== 1 || !singleDoc || typeof resolveFolderPath !== 'function') { return null; } const handleNavigate = (folderId) => { if (!folderId || typeof onFolderNavigate !== 'function') { return; } onFolderNavigate(folderId); }; const folderSegments = resolveFolderPath(singleDoc.folder_id); const normalizedSegments = Array.isArray(folderSegments) ? folderSegments .filter((segment) => segment && segment.id && segment.name) .map((segment) => ({ id: segment.id, label: segment.name, onClick: segment.id ? () => handleNavigate(segment.id) : null, })) : []; const documentLabel = detailSummary.title || 'Document'; return [ ...normalizedSegments, { id: singleDoc.id || 'current-document', label: documentLabel, }, ]; }, [selectedCount, singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]); const [zoomedPreview, setZoomedPreview] = useState(null); const bulkDocumentIds = useMemo( () => selectedDocuments.map((doc) => doc?.id).filter(Boolean), [selectedDocuments], ); useEffect(() => { setZoomedPreview(null); }, [selectionKey]); const handlePreviewActivate = useCallback( (docId) => { if (!docId) return; onPromoteSelection?.(docId); }, [onPromoteSelection], ); const singlePreviewNavigator = useAssetNavigator({ document: singleDoc, assetType: 'preview', ensureAssetUrl, getAsset: getDocumentAsset, prefetch: 3, }); const makePreviewItem = useCallback( (doc, ordinal = 1) => { if (!doc) return null; const asset = getDocumentAsset(doc, 'preview'); const assetView = createAssetView(asset); const object = assetView.getObject(ordinal); let url = object?.url || null; if (!url) { url = resolveDocumentAssetUrl(doc, 'preview', { ensureAssetUrl, getAsset: getDocumentAsset, ensureOptions: { start: ordinal, limit: 1 }, objectOrdinal: ordinal, }); } if (!url) { return null; } const metadata = object?.metadata || assetView.getPrimaryMetadata() || {}; const orientation = derivePreviewOrientation(metadata); return { id: doc.id, url, orientation, alt: doc.title, }; }, [ensureAssetUrl, getDocumentAsset], ); const stackDocuments = useMemo(() => { if (!selectedDocuments.length) return []; const seen = new Set(); const ordered = []; for (let index = selectedDocuments.length - 1; index >= 0; index -= 1) { const doc = selectedDocuments[index]; if (!doc?.id || seen.has(doc.id)) continue; seen.add(doc.id); ordered.push(doc); if (ordered.length >= MAX_PREVIEW_STACK_ITEMS) { break; } } return ordered; }, [selectedDocuments]); const stackTopDocument = stackDocuments[0] || null; const stackTopDocId = stackTopDocument?.id || null; const stackPreviewNavigator = useAssetNavigator({ document: stackTopDocument, assetType: 'preview', ensureAssetUrl, getAsset: getDocumentAsset, prefetch: 3, }); const singlePreviewItems = useMemo(() => { if (!singleDoc) return []; const url = singlePreviewNavigator.currentUrl; if (!url) { return []; } const orientation = derivePreviewOrientation(singlePreviewNavigator.currentMetadata); return [ { id: singleDoc.id, url, orientation, alt: singleDoc.title, }, ]; }, [singleDoc, singlePreviewNavigator.currentUrl, singlePreviewNavigator.currentMetadata]); const stackPreviews = useMemo(() => { if (!stackDocuments.length) { return []; } return stackDocuments .map((doc) => { if (!doc) return null; if (stackTopDocument && doc.id === stackTopDocument.id) { const url = stackPreviewNavigator.currentUrl; if (!url) { return null; } const orientation = derivePreviewOrientation(stackPreviewNavigator.currentMetadata); return { id: doc.id, url, orientation, alt: doc.title, }; } return makePreviewItem(doc, 1); }) .filter(Boolean); }, [ stackDocuments, stackTopDocument, stackPreviewNavigator.currentUrl, stackPreviewNavigator.currentMetadata, makePreviewItem, ]); const singleCardinality = singlePreviewNavigator.cardinality; const singleEffectiveCardinality = singleCardinality || (singlePreviewNavigator.currentUrl ? 1 : 0); const singleHasPreview = Boolean(singlePreviewNavigator.currentUrl); const topDocId = stackTopDocument?.id || null; const topCardinality = stackPreviewNavigator.cardinality; const topEffectiveCardinality = topCardinality || (stackPreviewNavigator.currentUrl ? 1 : 0); const topHasPreview = Boolean(stackPreviewNavigator.currentUrl); const bulkTagUnion = useMemo(() => { if (!selectedDocuments.length) return []; const tagMap = new Map(); selectedDocuments.forEach((doc) => { (doc.tags || []).forEach((tag) => { if (!tag?.label) return; const label = tag.label.trim(); if (!label) return; if (!tagMap.has(label)) { const fallback = tagLookupById.get(tag.id); tagMap.set(label, { id: tag.id, label, color: tag.color ?? fallback?.color ?? null, }); } }); }); return [...tagMap.values()].sort((a, b) => a.label.localeCompare(b.label)); }, [selectedDocuments, tagLookupById]); const stackTotalSizeBytes = useMemo(() => { if (!stackPreviews.length) return 0; const byId = new Map(selectedDocuments.map((doc) => [doc.id, doc])); return stackPreviews.reduce((sum, item) => { const source = byId.get(item.id); const bytes = source?.current_version?.size_bytes; return sum + (typeof bytes === 'number' ? bytes : 0); }, 0); }, [stackPreviews, selectedDocuments]); const correspondentOptions = useMemo( () => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []), [correspondents], ); const singleCorrespondents = useMemo(() => { if (!singleDoc) return []; return sortCorrespondents(singleDoc.correspondents || []); }, [singleDoc]); const bulkCorrespondents = useMemo(() => { if (selectedDocuments.length <= 1) { const doc = selectedDocuments[0]; return doc ? sortCorrespondents(doc.correspondents || []) : []; } const map = new Map(); selectedDocuments.forEach((doc) => { if (!doc?.id) return; (doc.correspondents || []).forEach((entry) => { if (!entry?.id || typeof entry.name !== 'string') return; if (!map.has(entry.id)) { map.set(entry.id, { id: entry.id, name: entry.name, documentIds: new Set(), }); } map.get(entry.id).documentIds.add(doc.id); }); }); return [...map.values()] .map((entry) => ({ id: entry.id, name: entry.name, documentIds: [...entry.documentIds], count: entry.documentIds.size, })) .sort((a, b) => a.name.localeCompare(b.name)); }, [selectedDocuments]); const handleBulkCorrespondentRemove = useCallback( (entry) => { if (!entry?.id) return; if (onBulkCorrespondentRemove) { return onBulkCorrespondentRemove({ assignments: [ { correspondent_id: entry.id, }, ], documentIds: entry.documentIds, }); } if (!onCorrespondentRemove) return; const targets = entry.documentIds && entry.documentIds.length ? entry.documentIds : selectedDocuments .filter((doc) => (doc.correspondents || []).some((item) => item.id === entry.id)) .map((doc) => doc.id); return Promise.all( targets.map((documentId) => onCorrespondentRemove({ documentId, correspondentId: entry.id, }), ), ).catch(() => {}); }, [onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments], ); const openZoomPreview = useCallback((config) => { if (!config) return; setZoomedPreview({ mode: config.mode, docId: config.docId ?? null, }); }, []); const closeZoomPreview = useCallback(() => { setZoomedPreview(null); }, []); const handleSingleZoom = useCallback( (entry) => { if (!singleHasPreview) return; const targetId = entry?.id ?? singleDocId; if (!targetId) return; openZoomPreview({ mode: 'single', docId: targetId }); }, [openZoomPreview, singleHasPreview, singleDocId], ); const handleStackZoom = useCallback( (entry) => { if (!stackTopDocId || entry?.id !== stackTopDocId) return; if (!topHasPreview) return; openZoomPreview({ mode: 'stack', docId: stackTopDocId }); }, [openZoomPreview, stackTopDocId, topHasPreview], ); const zoomDisplay = useMemo(() => { if (!zoomedPreview) { return null; } if ( zoomedPreview.mode === 'single' && singleDocId && singleDoc && singleHasPreview && zoomedPreview.docId === singleDocId ) { return { url: singlePreviewNavigator.currentUrl, alt: singleDoc.title, canGoPrev: singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoPrev), canGoNext: singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoNext), goPrev: singlePreviewNavigator.goPrev, goNext: singlePreviewNavigator.goNext, }; } if ( zoomedPreview.mode === 'stack' && stackTopDocId && stackTopDocument && zoomedPreview.docId === stackTopDocId && topHasPreview ) { return { url: stackPreviewNavigator.currentUrl, alt: stackTopDocument.title, canGoPrev: topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoPrev), canGoNext: topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoNext), goPrev: stackPreviewNavigator.goPrev, goNext: stackPreviewNavigator.goNext, }; } return null; }, [ zoomedPreview, singleDoc, singleDocId, singleHasPreview, singlePreviewNavigator.currentUrl, singlePreviewNavigator.canGoPrev, singlePreviewNavigator.canGoNext, singlePreviewNavigator.goPrev, singlePreviewNavigator.goNext, singleEffectiveCardinality, stackTopDocument, stackTopDocId, topHasPreview, stackPreviewNavigator.currentUrl, stackPreviewNavigator.canGoPrev, stackPreviewNavigator.canGoNext, stackPreviewNavigator.goPrev, stackPreviewNavigator.goNext, topEffectiveCardinality, ]); useEffect(() => { if (zoomedPreview && !zoomDisplay) { setZoomedPreview(null); } }, [zoomedPreview, zoomDisplay]); const { documentId: singleNavigatorDocId, asset: singleNavigatorAsset, ordinal: singleNavigatorOrdinal, canGoPrev: singleNavigatorCanGoPrev, canGoNext: singleNavigatorCanGoNext, cardinality: singleNavigatorCardinality, } = singlePreviewNavigator; const { documentId: stackNavigatorDocId, asset: stackNavigatorAsset, ordinal: stackNavigatorOrdinal, canGoPrev: stackNavigatorCanGoPrev, canGoNext: stackNavigatorCanGoNext, cardinality: stackNavigatorCardinality, } = stackPreviewNavigator; useEffect(() => { if (typeof ensureAssetUrl !== 'function') { return; } const warmNavigator = (navigator) => { const { documentId, asset, ordinal, canGoPrev, canGoNext, cardinality, } = navigator; if (!documentId || !asset || !Number.isFinite(ordinal)) { return; } const requests = []; if (canGoPrev) { const prevOrdinal = Math.max(1, ordinal - 1); if (!cardinality || prevOrdinal <= cardinality) { requests.push( ensureAssetUrl(documentId, asset, { start: prevOrdinal, limit: 1, objectOrdinal: prevOrdinal, }), ); } } if (canGoNext) { const nextOrdinal = ordinal + 1; if (!cardinality || nextOrdinal <= cardinality) { requests.push( ensureAssetUrl(documentId, asset, { start: nextOrdinal, limit: 1, objectOrdinal: nextOrdinal, }), ); } } requests.forEach((promise) => promise?.catch?.(() => {})); }; const singleWarmState = { documentId: singleNavigatorDocId, asset: singleNavigatorAsset, ordinal: singleNavigatorOrdinal, canGoPrev: singleNavigatorCanGoPrev, canGoNext: singleNavigatorCanGoNext, cardinality: singleNavigatorCardinality, }; const stackWarmState = { documentId: stackNavigatorDocId, asset: stackNavigatorAsset, ordinal: stackNavigatorOrdinal, canGoPrev: stackNavigatorCanGoPrev, canGoNext: stackNavigatorCanGoNext, cardinality: stackNavigatorCardinality, }; warmNavigator(singleWarmState); warmNavigator(stackWarmState); }, [ ensureAssetUrl, singleNavigatorDocId, singleNavigatorAsset, singleNavigatorOrdinal, singleNavigatorCanGoPrev, singleNavigatorCanGoNext, singleNavigatorCardinality, stackNavigatorDocId, stackNavigatorAsset, stackNavigatorOrdinal, stackNavigatorCanGoPrev, stackNavigatorCanGoNext, stackNavigatorCardinality, ]); const renderSingle = () => { if (!singleDoc) { return

Select a document to view metadata, tags and actions.

; } const effectiveCardinality = singlePreviewNavigator.cardinality || (singlePreviewNavigator.currentUrl ? 1 : 0); const canGoPrev = singlePreviewNavigator.canGoPrev; const canGoNext = singlePreviewNavigator.canGoNext; const hasPreviewImage = Boolean(singlePreviewNavigator.currentUrl); const interceptNavPointer = (event) => { event.preventDefault(); event.stopPropagation(); }; return ( <>
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
) : null}
onTagAdd(doc, value, extras)} onTagRemove={(docId, tagId) => onTagRemove(docId, tagId)} correspondents={singleCorrespondents} correspondentOptions={correspondentOptions} onCorrespondentAdd={onCorrespondentAdd} onCorrespondentRemove={onCorrespondentRemove} onUpdateTitle={onUpdateTitle} onUpdateIssued={onUpdateIssued} /> ); }; const renderBulk = () => { const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`; const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—'; const headerLabel = `${countLabel}${sizeLabel ? ` (${sizeLabel})` : ''}`; const topDocIdLocal = topDocId; const topCardinalityLocal = topEffectiveCardinality; const topHasPreview = Boolean(stackPreviewNavigator.currentUrl); const topCanGoPrev = stackPreviewNavigator.canGoPrev; const topCanGoNext = stackPreviewNavigator.canGoNext; const interceptTopNavPointer = (event) => { event.preventDefault(); event.stopPropagation(); }; const documentIds = bulkDocumentIds; return ( <>
{topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? (
) : null}

{headerLabel}

onBulkTagRemove?.({ label: tag.label, documentIds })} onAdd={({ value }) => onBulkTagAdd?.({ label: value, input: null, documentIds }) } addPlaceholder="Add tag to selection" addButtonLabel="Add tag" datalistOptions={tags} className="bulk-tags" /> onBulkCorrespondentAdd?.({ name, input: null, documentIds }) } addPlaceholder="Add correspondent to selection" datalistOptions={correspondentOptions} showCount className="bulk-correspondents" /> ); }; const isBulkSelection = selectedCount > 1; const headerLeading = [ ( ), ]; if (singleDoc) { headerLeading.push( , ); } const headerActions = []; if (isBulkSelection && onBulkReanalyze) { headerActions.push( , ); } if (singleDoc && singleDownloadHref) { headerActions.push( event.stopPropagation()} > , ); } if (singleDoc) { headerActions.push( , ); } return ( <> ); }; export default DetailPanel;