Files
papercrate/frontend/src/detail/DetailPanel.jsx
T
2025-10-31 00:28:48 +01:00

1512 lines
46 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
DownloadIcon,
EditIcon,
ArrowLeftIcon,
ArrowRightIcon,
ChevronsRightIcon,
AnalyzeIcon,
WindowMaximizeIcon,
TextScanIcon,
} from '../ui/icons';
import { getTagColorStyle } from '../utils/colors';
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';
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 sortCorrespondents = (entries = []) =>
entries
.filter((entry) => entry && entry.name)
.map(({ id, name, count }) => ({ id, name, count }))
.sort((a, b) => a.name.localeCompare(b.name));
const resolveDocumentType = (doc) => {
if (!doc) {
return null;
}
const type = doc.document_type;
const fallbackId = doc.document_type_id ?? null;
if (!type) {
return null;
}
if (typeof type === 'object' && typeof type.name === 'string') {
const name = type.name.trim();
if (!name) {
return null;
}
return { id: type.id ?? fallbackId, name };
}
return null;
};
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
<div className="correspondent-list">
{entries.length ? (
entries.map((entry, index) => {
const key = entry.id ?? `${entry.name}-${index}`;
return (
<span key={key} className="correspondent-pill">
<span className="correspondent-pill__label">
<span>
{entry.name}
{showCount && entry.count ? ` (${entry.count})` : ''}
</span>
</span>
{onRemove ? (
<button
type="button"
className="correspondent-pill__remove"
onClick={() => onRemove(entry)}
aria-label={`Remove ${entry.name}`}
>
×
</button>
) : null}
</span>
);
})
) : (
<span className="meta">No correspondents yet.</span>
)}
</div>
);
const TagSection = ({
title,
tags = [],
onRemove,
onAdd,
emptyMessage = 'No tags yet.',
addPlaceholder = 'Add or create tag',
addButtonLabel = 'Add',
datalistId,
datalistOptions = [],
className,
}) => (
<div className={className}>
<dt>{title}</dt>
<div className="tag-list">
{tags.length ? (
tags.map((tag) => {
const key = tag.id ?? tag.label;
const style = getTagColorStyle(tag.color);
const removable = Boolean(onRemove);
const className = removable ? 'badge tag-chip tag-chip--removable' : 'badge tag-chip';
return (
<span key={key} className={className} style={style || undefined}>
<span className="tag-chip__label">{tag.label}</span>
{removable ? (
<button
type="button"
className="tag-chip__remove"
onClick={() => onRemove(tag)}
aria-label={`Remove tag ${tag.label}`}
>
×
</button>
) : null}
</span>
);
})
) : (
<span className="meta">{emptyMessage}</span>
)}
</div>
{onAdd ? (
<form
className="inline"
onSubmit={(event) => {
event.preventDefault();
const input = event.currentTarget.elements.tag;
const value = input.value.trim();
if (!value) return;
onAdd({ value, input });
}}
>
<input name="tag" placeholder={addPlaceholder} list={datalistId} />
<button type="submit">{addButtonLabel}</button>
{datalistId ? (
<datalist id={datalistId}>
{datalistOptions.map((option) => (
<option key={option.id} />
))}
</datalist>
) : null}
</form>
) : null}
</div>
);
const CorrespondentSection = ({
title,
entries = [],
onRemove,
onAdd,
showCount = false,
addPlaceholder = 'Add or create correspondent',
addButtonLabel = 'Add',
datalistId,
datalistOptions = [],
className,
}) => (
<div className={className}>
<dt>{title}</dt>
<CorrespondentPills entries={entries} onRemove={onRemove} showCount={showCount} />
{onAdd ? (
<form
className="correspondent-form"
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
const nameInput = form.elements.correspondent;
const value = nameInput.value.trim();
if (!value) return;
onAdd({ name: value, input: nameInput });
form.reset();
}}
>
<input
name="correspondent"
placeholder={addPlaceholder}
list={datalistId}
/>
<button type="submit">{addButtonLabel}</button>
{datalistId ? (
<datalist id={datalistId}>
{datalistOptions.map((name) => (
<option key={name} value={name} />
))}
</datalist>
) : null}
</form>
) : null}
</div>
);
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 <span className="meta">{emptyMessage}</span>;
}
return (
<div className="preview-stack preview-stack--stacked">
{preparedItems.map(({ entry, angle }, index) => {
const transform = hasMultiple
? `translate(-50%, -50%) rotate(${angle}deg)`
: 'translate(-50%, -50%)';
const isFront = index === 0;
return (
<div
key={entry.id || index}
className={`preview-stack__item orientation-${entry.orientation || 'landscape'}`}
style={{
zIndex: preparedItems.length - index,
transform,
}}
aria-hidden={
hasMultiple && !onItemActivate && !onOpenPreview && !onZoomPreview
? 'true'
: undefined
}
>
<img
src={entry.url}
alt={entry.alt}
className="preview-stack__image"
onClick={(event) => {
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);
}
}
}}
/>
</div>
);
})}
</div>
);
};
const DetailPanel = ({
selectedDocuments = [],
tags = [],
tagLookupById = new Map(),
onTagAdd,
onTagRemove,
onRegenerateThumbnails,
onOpenPreview,
onBulkTagAdd,
onBulkTagRemove,
onBulkReanalyze,
onBulkCorrespondentAdd,
onBulkCorrespondentRemove,
onPromoteSelection,
onUpdateTitle = async () => false,
ensureAssetUrl = null,
getDocumentAsset = () => null,
ensurePreviewData = () => Promise.resolve(),
correspondents = [],
onCorrespondentAdd,
onCorrespondentRemove,
documentTypes = [],
onDocumentTypeSet,
onDocumentTypeClear,
onBulkDocumentTypeSet,
onBulkDocumentTypeClear,
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, hasOcr: singleHasOcr, openOcr } = 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 [titleEditDocId, setTitleEditDocId] = useState(null);
const [titleDraft, setTitleDraft] = useState('');
const [titleSaving, setTitleSaving] = useState(false);
const [titleError, setTitleError] = useState(null);
const [zoomedPreview, setZoomedPreview] = useState(null);
const bulkDocumentIds = useMemo(
() => selectedDocuments.map((doc) => doc?.id).filter(Boolean),
[selectedDocuments],
);
useEffect(() => {
if (!singleDoc) {
setTitleEditDocId(null);
setTitleDraft('');
setTitleError(null);
setTitleSaving(false);
return;
}
if (titleEditDocId && titleEditDocId !== singleDoc.id) {
setTitleEditDocId(null);
setTitleDraft('');
setTitleError(null);
setTitleSaving(false);
}
}, [singleDoc, titleEditDocId]);
useEffect(() => {
setZoomedPreview(null);
}, [selectionKey]);
const startTitleEdit = useCallback(() => {
if (!singleDoc) return;
setTitleEditDocId(singleDoc.id);
setTitleDraft(singleDoc.title || singleDoc.original_name);
setTitleError(null);
}, [singleDoc]);
const cancelTitleEdit = useCallback(() => {
setTitleEditDocId(null);
setTitleDraft('');
setTitleError(null);
setTitleSaving(false);
}, []);
const submitTitleEdit = useCallback(
async (event) => {
event.preventDefault();
if (!singleDoc) return;
const trimmed = titleDraft.trim();
if (!trimmed) {
setTitleError('Title cannot be empty.');
return;
}
setTitleSaving(true);
try {
const ok = await onUpdateTitle(singleDoc.id, trimmed);
if (ok) {
setTitleEditDocId(null);
setTitleDraft('');
setTitleError(null);
} else {
setTitleError('Failed to update title.');
}
} finally {
setTitleSaving(false);
}
},
[singleDoc, titleDraft, onUpdateTitle],
);
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 availableCorrespondents = useMemo(
() => (Array.isArray(correspondents) ? correspondents : []),
[correspondents],
);
const correspondentOptions = useMemo(() => {
const seen = new Set();
return availableCorrespondents.reduce((options, entry) => {
if (typeof entry?.name !== 'string') {
return options;
}
const name = entry.name.trim();
if (!name) {
return options;
}
const lower = name.toLowerCase();
if (seen.has(lower)) {
return options;
}
seen.add(lower);
options.push(name);
return options;
}, []);
}, [availableCorrespondents]);
const documentTypeOptions = useMemo(() => {
const seen = new Set();
return (documentTypes || []).reduce((options, entry) => {
if (typeof entry?.name !== 'string') {
return options;
}
const name = entry.name.trim();
if (!name) {
return options;
}
const lower = name.toLowerCase();
if (seen.has(lower)) {
return options;
}
seen.add(lower);
options.push(name);
return options;
}, []);
}, [documentTypes]);
const singleDocumentType = useMemo(() => resolveDocumentType(singleDoc), [singleDoc]);
const singleCorrespondents = useMemo(() => {
if (!singleDoc) return [];
return sortCorrespondents(singleDoc.correspondents || []);
}, [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 folderLabel = detailSummary.folderLabel;
const folderDisplayNode = useMemo(() => {
if (!singleDoc) {
return folderLabel || '—';
}
if (!singleFolderPath?.length) {
return folderLabel || '—';
}
return (
<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>
);
}, [singleDoc, singleFolderPath, folderLabel, onFolderNavigate]);
const detailInfoRows = useMemo(() => {
if (!singleDoc) {
return [];
}
const allowedKeys = new Set(['uploaded', 'size', 'type', 'issued', 'pages', 'created', 'updated', 'folder']);
const rows = detailSummary.summaryRows
.filter((row) => {
if (!allowedKeys.has(row.key)) {
return false;
}
if (row.key === 'pages') {
return Number.isFinite(detailSummary.pageCount);
}
if (row.key === 'folder') {
return Boolean(singleFolderPath?.length);
}
return true;
})
.map((row) => (row.key === 'folder' ? { ...row, value: folderDisplayNode } : row));
rows.push({
key: 'original-name',
label: 'Original filename',
value: singleDoc.original_name || '—',
});
return rows;
}, [singleDoc, detailSummary, folderDisplayNode, singleFolderPath]);
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 bulkDocumentTypes = useMemo(() => {
if (!selectedDocuments.length) {
return [];
}
const map = new Map();
selectedDocuments.forEach((doc) => {
const type = resolveDocumentType(doc);
if (!type) return;
const key = type.id ?? type.name.toLowerCase();
if (!map.has(key)) {
map.set(key, { id: type.id ?? null, name: type.name, count: 0 });
}
map.get(key).count += 1;
});
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
}, [selectedDocuments]);
const bulkDocumentTypeSummary = useMemo(() => {
if (!bulkDocumentTypes.length) {
return '';
}
return bulkDocumentTypes
.map((entry) => {
if (!entry?.name) {
return null;
}
const suffix = entry.count === selectedDocuments.length ? '' : ` (${entry.count})`;
return `${entry.name}${suffix}`;
})
.filter(Boolean)
.join(', ');
}, [bulkDocumentTypes, selectedDocuments.length]);
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 <p className="meta">Select a document to view metadata, tags and actions.</p>;
}
const displayName = singleDoc.title || singleDoc.original_name;
const isEditingTitle = titleEditDocId === singleDoc.id;
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
const metadata =
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
const effectiveCardinality = singleEffectiveCardinality;
const canGoPrev = singlePreviewNavigator.canGoPrev;
const canGoNext = singlePreviewNavigator.canGoNext;
const hasPreviewImage = singleHasPreview;
const interceptNavPointer = (event) => {
event.preventDefault();
event.stopPropagation();
};
return (
<>
<div className="preview-pane preview-pane--stack">
<PreviewStack
items={singlePreviewItems}
maxItems={1}
emptyMessage="Preview loading…"
onItemActivate={handlePreviewActivate}
onOpenPreview={onOpenPreview}
onZoomPreview={handleSingleZoom}
/>
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
<div className="preview-pane__nav preview-pane__nav--overlay">
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--prev"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
singlePreviewNavigator.goPrev();
}}
onPointerDown={interceptNavPointer}
onPointerUp={interceptNavPointer}
onMouseDown={interceptNavPointer}
onMouseUp={interceptNavPointer}
disabled={!canGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--next"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
singlePreviewNavigator.goNext();
}}
onPointerDown={interceptNavPointer}
onPointerUp={interceptNavPointer}
onMouseDown={interceptNavPointer}
onMouseUp={interceptNavPointer}
disabled={!canGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div>
) : null}
</div>
<div className="doc-title-row">
{isEditingTitle ? (
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
<input
value={titleDraft}
onChange={(event) => {
setTitleDraft(event.target.value);
if (titleError) {
setTitleError(null);
}
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelTitleEdit();
}
}}
aria-label="Document title"
autoFocus
/>
<button type="submit" disabled={titleSaving}>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelTitleEdit}
disabled={titleSaving}
>
Cancel
</button>
</form>
) : (
<>
<h3 style={{ margin: 0 }}>{displayName}</h3>
<button
type="button"
className="icon-button"
onClick={startTitleEdit}
aria-label="Edit title"
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
</>
)}
</div>
{titleError ? <div className="status-inline error">{titleError}</div> : null}
<div className="meta">
{detailInfoRows.map((row) => {
const rawValue = row.value;
const displayValue =
rawValue === null || rawValue === undefined || rawValue === '' ? '—' : rawValue;
return (
<div key={row.key}>
<strong>{row.label}:</strong>{' '}
{displayValue}
</div>
);
})}
</div>
<div className="detail-field">
<div className="detail-field__label">Document type</div>
<div className="detail-field__value">
{singleDocumentType?.name ? (
<span>{singleDocumentType.name}</span>
) : (
<span className="meta">None assigned.</span>
)}
{singleDocumentType && onDocumentTypeClear ? (
<button
type="button"
className="secondary"
onClick={() => onDocumentTypeClear?.({ documentId: singleDoc.id })}
>
Clear
</button>
) : null}
</div>
{onDocumentTypeSet ? (
<form
className="inline"
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
const input = form.elements.documentType;
const value = input?.value?.trim();
if (!value) {
return;
}
onDocumentTypeSet?.({ document: singleDoc, name: value, input });
}}
>
<input
name="documentType"
placeholder="Assign or create type"
list="document-type-catalog-single"
defaultValue=""
/>
<button type="submit">Set</button>
<datalist id="document-type-catalog-single">
{documentTypeOptions.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</form>
) : null}
</div>
<TagSection
title="Tags"
tags={tagsForDoc.map((tag) => ({
id: tag.id,
label: tag.label,
color: tag.color || tagLookupById.get(tag.id)?.color,
}))}
onRemove={(tag) => onTagRemove(singleDoc.id, tag.id)}
onAdd={({ value, input }) => onTagAdd(singleDoc, value, input)}
datalistId="tag-catalog-single"
datalistOptions={tags}
/>
<CorrespondentSection
title="Correspondents"
entries={singleCorrespondents}
onRemove={(entry) =>
onCorrespondentRemove?.({
documentId: singleDoc.id,
correspondentId: entry.id,
})
}
onAdd={({ name, input }) =>
onCorrespondentAdd?.({
document: singleDoc,
name,
input,
})
}
datalistId="correspondent-catalog-single"
datalistOptions={correspondentOptions}
/>
{metadata && (
<div>
<dt>Metadata</dt>
<pre className="detail-metadata__block">{JSON.stringify(metadata, null, 2)}</pre>
</div>
)}
</>
);
};
const renderBulk = () => {
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
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 (
<>
<div className="preview-pane preview-pane--stack">
<PreviewStack
items={stackPreviews}
emptyMessage="No previews available."
onItemActivate={handlePreviewActivate}
onOpenPreview={onOpenPreview}
onZoomPreview={handleStackZoom}
/>
{topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? (
<div className="preview-pane__nav preview-pane__nav--overlay">
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--prev"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
stackPreviewNavigator.goPrev();
}}
onPointerDown={interceptTopNavPointer}
onPointerUp={interceptTopNavPointer}
onMouseDown={interceptTopNavPointer}
onMouseUp={interceptTopNavPointer}
disabled={!topCanGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--next"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
stackPreviewNavigator.goNext();
}}
onPointerDown={interceptTopNavPointer}
onPointerUp={interceptTopNavPointer}
onMouseDown={interceptTopNavPointer}
onMouseUp={interceptTopNavPointer}
disabled={!topCanGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div>
) : null}
</div>
<h3 style={{ margin: 0 }}>{countLabel}</h3>
<div className="meta">
<div>
<strong>Total size (stack):</strong> {sizeLabel}
</div>
</div>
<TagSection
title="Tags"
tags={bulkTagUnion}
emptyMessage="No tags assigned."
onRemove={(tag) => onBulkTagRemove?.({ label: tag.label, documentIds })}
onAdd={({ value, input }) =>
onBulkTagAdd?.({ label: value, input, documentIds })
}
addPlaceholder="Add tag to selection"
addButtonLabel="Add tag"
datalistId="tag-catalog-bulk"
datalistOptions={tags}
className="bulk-tags"
/>
<div className="detail-field">
<div className="detail-field__label">Document type</div>
<div className="detail-field__value">
<span>{bulkDocumentTypeSummary || 'None assigned.'}</span>
{onBulkDocumentTypeClear && bulkDocumentTypes.length > 0 ? (
<button
type="button"
className="secondary"
onClick={() => onBulkDocumentTypeClear?.({ documentIds })}
>
Clear
</button>
) : null}
</div>
{onBulkDocumentTypeSet ? (
<form
className="inline"
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
const input = form.elements.documentType;
const value = input?.value?.trim();
if (!value) {
return;
}
onBulkDocumentTypeSet?.({ name: value, input, documentIds });
}}
>
<input
name="documentType"
placeholder="Assign or create type"
list="document-type-catalog-bulk"
defaultValue=""
/>
<button type="submit">Set</button>
<datalist id="document-type-catalog-bulk">
{documentTypeOptions.map((name) => (<option key={name} value={name} />))}
</datalist>
</form>
) : null}
</div>
<CorrespondentSection
title="Correspondents"
entries={bulkCorrespondents}
onRemove={handleBulkCorrespondentRemove}
onAdd={({ name, input }) =>
onBulkCorrespondentAdd?.({ name, input, documentIds })
}
addPlaceholder="Add correspondent to selection"
datalistId="correspondent-catalog-bulk"
datalistOptions={correspondentOptions}
showCount
className="bulk-correspondents"
/>
</>
);
};
const isBulkSelection = selectedCount > 1;
const showOcrAction = Boolean(singleDoc && singleHasOcr);
return (
<>
<aside className="detail-panel panel">
<div className="panel-header">
<button
type="button"
className="icon-button"
onClick={onClose}
aria-label="Close detail panel"
title="Close detail panel"
>
<ChevronsRightIcon />
</button>
{singleDoc ? (
<button
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
onOpenPreview(singleDoc.id);
}}
aria-label="Open preview"
title="Open preview"
disabled={!singleHasPreview}
>
<WindowMaximizeIcon />
</button>
) : null}
<h3 className="panel-header__title">{headerTitle}</h3>
<div className="panel-actions__spacer" />
{isBulkSelection && onBulkReanalyze ? (
<button
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
onBulkReanalyze(bulkDocumentIds);
}}
aria-label="Re-run analysis for selection"
title="Re-run analysis for selection"
>
<AnalyzeIcon />
</button>
) : null}
{singleDoc && singleDownloadHref ? (
<a
className="icon-button"
href={singleDownloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Download document"
title="Download document"
onClick={(event) => event.stopPropagation()}
>
<DownloadIcon />
</a>
) : null}
{showOcrAction ? (
<button
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
openOcr().catch(() => {});
}}
aria-label="View OCR text"
title="View OCR text"
>
<TextScanIcon />
</button>
) : null}
{singleDoc ? (
<button
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
onRegenerateThumbnails(singleDoc.id);
}}
aria-label="Re-run analysis"
title="Re-run analysis"
>
<AnalyzeIcon />
</button>
) : null}
</div>
<div className="panel-body">
{selectedCount <= 1 ? renderSingle() : renderBulk()}
</div>
</aside>
<PreviewZoomOverlay
open={Boolean(zoomDisplay?.url)}
display={zoomDisplay}
onClose={closeZoomPreview}
/>
</>
);
};
export default DetailPanel;