1333 lines
40 KiB
React
1333 lines
40 KiB
React
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 PreviewZoomOverlay from './PreviewZoomOverlay';
|
||
import { openOcrTextInNewTab } from '../utils/ocr';
|
||
|
||
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
|
||
.map((entry) => ({
|
||
id: entry.id,
|
||
name: entry.name || '',
|
||
count: entry.count,
|
||
}))
|
||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||
|
||
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,
|
||
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 singleDownloadHref = useMemo(() => {
|
||
if (!singleDoc) return null;
|
||
const downloadPath = singleDoc.current_version?.download_path;
|
||
if (!downloadPath || !resolveApiPath) {
|
||
return null;
|
||
}
|
||
return resolveApiPath(downloadPath);
|
||
}, [singleDoc, resolveApiPath]);
|
||
|
||
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 hasOcrAsset = useMemo(
|
||
() => Boolean(singleDoc && getDocumentAsset(singleDoc, 'ocr-text')),
|
||
[singleDoc, getDocumentAsset],
|
||
);
|
||
|
||
const openOcr = useCallback(async () => {
|
||
if (!singleDoc) {
|
||
return;
|
||
}
|
||
try {
|
||
await openOcrTextInNewTab({
|
||
document: singleDoc,
|
||
ensurePreviewData,
|
||
getDocumentAsset,
|
||
ensureAssetUrl,
|
||
});
|
||
} catch (error) {
|
||
/* noop */
|
||
}
|
||
}, [singleDoc, ensurePreviewData, getDocumentAsset, ensureAssetUrl]);
|
||
|
||
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) => {
|
||
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,
|
||
});
|
||
}
|
||
});
|
||
});
|
||
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
|
||
.map((entry) => (entry?.name || '').trim())
|
||
.filter((name) => {
|
||
if (!name) return false;
|
||
const lower = name.toLowerCase();
|
||
if (seen.has(lower)) {
|
||
return false;
|
||
}
|
||
seen.add(lower);
|
||
return true;
|
||
});
|
||
}, [availableCorrespondents]);
|
||
|
||
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 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) 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 <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 sizeBytes = Number(singleDoc.current_version?.size_bytes) || 0;
|
||
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : '—';
|
||
const issuedAt = singleDoc.issued_at
|
||
? new Date(singleDoc.issued_at).toLocaleString()
|
||
: '—';
|
||
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
|
||
const pageCountRaw = singleDoc.current_version?.metadata?.page_count;
|
||
const pageCountValue =
|
||
typeof pageCountRaw === 'number'
|
||
? pageCountRaw
|
||
: pageCountRaw != null && pageCountRaw !== ''
|
||
? Number.parseInt(pageCountRaw, 10)
|
||
: null;
|
||
const hasPageCount = Number.isFinite(pageCountValue) && pageCountValue >= 0;
|
||
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">
|
||
<div>
|
||
<strong>Uploaded:</strong>{' '}
|
||
{singleDoc.uploaded_at ? new Date(singleDoc.uploaded_at).toLocaleString() : '—'}
|
||
</div>
|
||
<div>
|
||
<strong>Size:</strong>{' '}
|
||
{sizeLabel}
|
||
</div>
|
||
<div>
|
||
<strong>Type:</strong> {singleDoc.content_type || 'Unknown'}
|
||
</div>
|
||
<div>
|
||
<strong>Issued:</strong> {issuedAt}
|
||
</div>
|
||
{hasPageCount ? (
|
||
<div>
|
||
<strong>Pages:</strong> {pageCountValue}
|
||
</div>
|
||
) : null}
|
||
{singleFolderPath?.length ? (
|
||
<div>
|
||
<strong>Folder:</strong>{' '}
|
||
<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>
|
||
</div>
|
||
) : null}
|
||
<div>
|
||
<strong>Original filename:</strong>{' '}
|
||
{singleDoc.original_name}
|
||
</div>
|
||
</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"
|
||
/>
|
||
<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 && hasOcrAsset);
|
||
|
||
return (
|
||
<>
|
||
<aside className="detail-panel panel">
|
||
<div className="panel-header">
|
||
<div className="panel-actions">
|
||
<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}
|
||
<div className="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();
|
||
}}
|
||
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>
|
||
<div className="panel-body">
|
||
{selectedCount <= 1 ? renderSingle() : renderBulk()}
|
||
</div>
|
||
</aside>
|
||
<PreviewZoomOverlay
|
||
open={Boolean(zoomDisplay?.url)}
|
||
display={zoomDisplay}
|
||
onClose={closeZoomPreview}
|
||
/>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default DetailPanel;
|