backend dry

This commit is contained in:
2025-10-29 11:52:19 +01:00
parent 788858f0c1
commit 2a17f2d333
10 changed files with 180 additions and 211 deletions
+12 -105
View File
@@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
DownloadIcon,
EditIcon,
@@ -15,6 +14,7 @@ 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;
@@ -300,6 +300,7 @@ const DetailPanel = ({
onUpdateTitle = async () => false,
ensureAssetUrl = null,
getDocumentAsset = () => null,
ensurePreviewData = () => Promise.resolve(),
correspondents = [],
onCorrespondentAdd,
onCorrespondentRemove,
@@ -329,10 +330,6 @@ const DetailPanel = ({
const [titleDraft, setTitleDraft] = useState('');
const [titleSaving, setTitleSaving] = useState(false);
const [titleError, setTitleError] = useState(null);
const [ocrOpen, setOcrOpen] = useState(false);
const [ocrUrl, setOcrUrl] = useState(null);
const [ocrLoading, setOcrLoading] = useState(false);
const [ocrError, setOcrError] = useState(null);
const [zoomedPreview, setZoomedPreview] = useState(null);
useEffect(() => {
@@ -341,10 +338,6 @@ const DetailPanel = ({
setTitleDraft('');
setTitleError(null);
setTitleSaving(false);
setOcrOpen(false);
setOcrLoading(false);
setOcrError(null);
setOcrUrl(null);
return;
}
@@ -356,13 +349,6 @@ const DetailPanel = ({
}
}, [singleDoc, titleEditDocId]);
useEffect(() => {
setOcrOpen(false);
setOcrLoading(false);
setOcrError(null);
setOcrUrl(null);
}, [singleDoc?.id]);
useEffect(() => {
setZoomedPreview(null);
}, [selectionKey]);
@@ -420,59 +406,21 @@ const DetailPanel = ({
[singleDoc, getDocumentAsset],
);
const loadOcrUrl = useCallback(async () => {
const openOcr = useCallback(async () => {
if (!singleDoc) {
return;
}
const asset = getDocumentAsset(singleDoc, 'ocr-text');
if (!asset) {
setOcrError('No OCR text available for this document.');
setOcrUrl(null);
return;
}
setOcrLoading(true);
setOcrError(null);
try {
let entry = asset;
let url = entry?.url ||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
if (!url && typeof ensureAssetUrl === 'function') {
const ensured = await ensureAssetUrl(singleDoc.id, asset, { force: false });
if (ensured) {
entry = ensured;
}
url = entry?.url ||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
}
if (!url) {
throw new Error('OCR text URL is unavailable.');
}
setOcrUrl(url);
await openOcrTextInNewTab({
document: singleDoc,
ensurePreviewData,
getDocumentAsset,
ensureAssetUrl,
});
} catch (error) {
setOcrError(error.message || 'Failed to load OCR text.');
setOcrUrl(null);
} finally {
setOcrLoading(false);
/* noop */
}
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
const openOcrModal = useCallback(() => {
if (!singleDoc) {
return;
}
setOcrOpen(true);
loadOcrUrl();
}, [singleDoc, loadOcrUrl]);
const closeOcrModal = useCallback(() => {
setOcrOpen(false);
}, []);
}, [singleDoc, ensurePreviewData, getDocumentAsset, ensureAssetUrl]);
const singlePreviewNavigator = useAssetNavigator({
document: singleDoc,
@@ -1138,47 +1086,6 @@ const DetailPanel = ({
<pre className="detail-metadata__block">{JSON.stringify(metadata, null, 2)}</pre>
</div>
)}
{hasOcrAsset && ocrOpen
? createPortal(
<div className="modal-backdrop" role="presentation" onClick={closeOcrModal}>
<div
className="modal modal--ocr"
role="dialog"
aria-modal="true"
aria-label="OCR text"
onClick={(event) => event.stopPropagation()}
>
<div className="modal__header">
<h3>OCR Text</h3>
<button
type="button"
className="icon-button ghost"
onClick={closeOcrModal}
aria-label="Close OCR text"
>
×
</button>
</div>
<div className="modal__body detail-ocr__content">
{ocrLoading ? (
<div className="meta">Loading OCR text</div>
) : ocrError ? (
<div className="status-inline error">{ocrError}</div>
) : ocrUrl ? (
<iframe
title="OCR text"
src={ocrUrl}
className="detail-ocr__frame"
/>
) : (
<div className="meta">OCR text empty.</div>
)}
</div>
</div>
</div>,
document.body,
)
: null}
</>
);
};
@@ -1349,7 +1256,7 @@ const DetailPanel = ({
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
openOcrModal();
openOcr();
}}
aria-label="View OCR text"
title="View OCR text"
+2 -10
View File
@@ -1127,6 +1127,7 @@ export const createDocumentsSurface = ({
onNavigateParent,
renderSidebarToggle,
detailProps,
detailOpen = false,
}) => {
const {
currentFolderName,
@@ -1176,16 +1177,7 @@ export const createDocumentsSurface = ({
)
: null;
const detail = (() => {
if (!detailProps) {
return null;
}
const count = detailProps.selectedDocuments?.length || 0;
if (!count) {
return null;
}
return <DetailPanel {...detailProps} />;
})();
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
return {
key: 'documents',
+33 -2
View File
@@ -735,6 +735,14 @@ const AppLayout = () => {
[selectedRowKeys],
);
const [detailPanelOpen, setDetailPanelOpen] = useState(() => selectedDocumentIds.length > 0);
useEffect(() => {
if (selectedDocumentIds.length > 0 && !detailPanelOpen) {
setDetailPanelOpen(true);
}
}, [selectedDocumentIds, detailPanelOpen]);
const resetWorkspaceState = useCallback(() => {
const rootNode = createRootNode();
setFolderNodes(new Map([[rootNode.id, rootNode]]));
@@ -762,6 +770,7 @@ const AppLayout = () => {
setActiveTagFilters([]);
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
setActivePreviewId(null);
setDetailPanelOpen(false);
assetManager.reset();
setPreviewEntries(() => new Map());
previewInflightRef.current = new Map();
@@ -4845,6 +4854,11 @@ const AppLayout = () => {
const lastScrolledDetailDocRef = useRef(null);
useEffect(() => {
if (!detailPanelOpen) {
lastScrolledDetailDocRef.current = null;
return;
}
if (!orderedSelectedDocuments.length) {
lastScrolledDetailDocRef.current = null;
return;
@@ -4870,6 +4884,10 @@ const AppLayout = () => {
return;
}
if (typeof document === 'undefined') {
return;
}
const targetElement =
document.getElementById(`document-row-${lastSelectedId}`) ||
document.getElementById(`document-card-${lastSelectedId}`);
@@ -4882,7 +4900,7 @@ const AppLayout = () => {
requestAnimationFrame(() => {
targetElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
}, [orderedSelectedDocuments, selectionOrder, documentsViewMode]);
}, [orderedSelectedDocuments, selectionOrder, documentsViewMode, detailPanelOpen]);
const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => {
const chain = [];
@@ -5267,6 +5285,11 @@ const AppLayout = () => {
[sidebarProps, openTagsModal, openCorrespondentsModal, collapseSidebar],
);
const handleDetailPanelClose = useCallback(() => {
setDetailPanelOpen(false);
clearDocumentSelection();
}, [clearDocumentSelection]);
const detailPanelProps = {
selectedDocuments: orderedSelectedDocuments,
tags,
@@ -5286,12 +5309,13 @@ const AppLayout = () => {
onUpdateTitle: handleDocumentTitleUpdate,
ensureAssetUrl,
getDocumentAsset,
ensurePreviewData,
correspondents,
onCorrespondentAdd: handleCorrespondentAdd,
onCorrespondentRemove: handleCorrespondentRemove,
resolveApiPath,
onFolderNavigate: selectFolder,
onClose: clearDocumentSelection,
onClose: handleDetailPanelClose,
resolveFolderPath,
};
@@ -5369,6 +5393,8 @@ const AppLayout = () => {
openTagsModal,
openCorrespondentsModal,
openSettingsModal,
detailPanelOpen,
setDetailPanelOpen,
}),
[
token,
@@ -5410,6 +5436,8 @@ const AppLayout = () => {
openTagsModal,
openCorrespondentsModal,
openSettingsModal,
detailPanelOpen,
setDetailPanelOpen,
],
);
@@ -5575,6 +5603,7 @@ const DocumentsRoute = () => {
sidebarProps,
documentsTableProps,
detailPanelProps,
detailPanelOpen,
workspaceMode,
skeuoWorkspaceProps,
openTagsModal,
@@ -5653,6 +5682,7 @@ const DocumentsRoute = () => {
onNavigateParent: parentBreadcrumb ? handleNavigateParent : null,
renderSidebarToggle,
detailProps: detailPanelProps,
detailOpen: detailPanelOpen,
});
}, [
documentsTableProps,
@@ -5660,6 +5690,7 @@ const DocumentsRoute = () => {
handleNavigateParent,
renderSidebarToggle,
detailPanelProps,
detailPanelOpen,
]);
const previewSurface = useMemo(() => {
+7 -13
View File
@@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager';
import { formatFileSize } from '../utils/format';
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
import { openOcrTextInNewTab } from '../utils/ocr';
const PreviewWorkspace = ({
document,
@@ -106,21 +106,15 @@ export const createPreviewWorkspaceHeaderActions = ({
const hasOcr = Boolean(ocrAsset);
const handleOcrClick = async () => {
if (!ocrAsset) {
return;
}
try {
const ensured = await ensureAssetUrl(document.id, ocrAsset, { force: false });
const entry = ensured || ocrAsset;
const url = entry?.url
|| resolveDocumentAssetUrl(document, 'ocr-text', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
if (!url) {
const success = await openOcrTextInNewTab({
document,
getDocumentAsset,
ensureAssetUrl,
});
if (!success) {
throw new Error('OCR text URL unavailable.');
}
window.open(url, '_blank', 'noopener,noreferrer');
} catch (error) {
notifyApiError(error, 'Unable to open OCR text.');
}
+4 -48
View File
@@ -2120,54 +2120,6 @@ button.danger:hover:not([disabled]) {
}
.detail-ocr__content {
max-height: 60vh;
overflow: auto;
padding: 0.75rem;
background: var(--surface-soft);
border-radius: 6px;
box-shadow: inset 0 1px 2px var(--shadow-faint);
}
.detail-ocr__content pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.4;
}
.detail-ocr__frame {
width: 100%;
min-height: 360px;
height: 60vh;
background: var(--surface-subtle);
border-radius: 4px;
box-shadow: inset 0 1px 2px var(--shadow-faint);
}
.modal__body {
width: 100%;
}
.modal--ocr {
width: min(720px, 100%);
max-height: 80vh;
gap: 0.75rem;
}
.modal--ocr .modal__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.modal--ocr .modal__body {
padding: 0;
}
.preview-pane {
margin-top: 0.4rem;
border-radius: 0;
@@ -2743,6 +2695,10 @@ form.inline {
padding: 0;
}
.modal__body {
width: 100%;
}
.modal h3 {
margin: 0;
font-size: 1.15rem;