frontend: refactor preview

This commit is contained in:
2025-10-19 21:23:35 +02:00
parent ab06205744
commit 3591415d46
4 changed files with 244 additions and 250 deletions
+12
View File
@@ -0,0 +1,12 @@
import React from 'react';
export const AppShellContext = React.createContext(null);
export const useAppShell = () => {
const context = React.useContext(AppShellContext);
if (!context) {
throw new Error('AppShellContext not found. Ensure routes are nested under AppLayout.');
}
return context;
};
+68 -250
View File
@@ -1,10 +1,10 @@
import React, { import React, {
useCallback, useCallback,
useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
useContext,
useReducer, useReducer,
} from 'react'; } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
@@ -28,11 +28,11 @@ import DetailPanel from './detail/DetailPanel';
import TagsPanel from './tags/TagsPanel'; import TagsPanel from './tags/TagsPanel';
import CorrespondentsPanel from './correspondents/CorrespondentsPanel'; import CorrespondentsPanel from './correspondents/CorrespondentsPanel';
import { CORRESPONDENT_ROLES } from './constants/correspondents'; import { CORRESPONDENT_ROLES } from './constants/correspondents';
import { DownloadIcon } from './ui/icons';
import TagManager from './tag_manager'; import TagManager from './tag_manager';
import { formatFileSize } from './utils/format';
import Sidebar from './sidebar/Sidebar'; import Sidebar from './sidebar/Sidebar';
import DocumentsTable from './documents/DocumentsTable'; import DocumentsTable from './documents/DocumentsTable';
import { AppShellContext, useAppShell } from './appShellContext';
import DocumentViewerRoute from './routes/DocumentViewerRoute';
const runtimeApiBase = const runtimeApiBase =
typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL
@@ -226,91 +226,6 @@ const LoginView = ({ onSubmit, status }) => (
const PreviewWorkspace = ({
document,
previewEntry,
onClose,
onRegenerateThumbnails,
}) => {
if (!document) {
return null;
}
const title = document.title || document.original_name || 'Document';
const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
const downloadHref = document.current_version?.download_path
? resolveApiPath(document.current_version.download_path)
: null;
const sizeBytes = Number(document.current_version?.size_bytes) || 0;
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
const metadata =
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
return (
<section className="preview-workspace">
<header className="preview-workspace__header">
<div className="preview-workspace__meta">
<button
type="button"
className="secondary"
onClick={() => onClose(document.folder_id ?? 'root')}
>
Back
</button>
<div>
<h2>{title}</h2>
<span className="meta">
{document.content_type || mime}
{sizeLabel ? ` · ${sizeLabel}` : ''}
</span>
</div>
</div>
<div className="preview-workspace__actions">
<a
className="button-link with-icon"
href={downloadHref || '#'}
target="_blank"
rel="noopener noreferrer"
aria-disabled={!downloadHref}
onClick={(event) => {
if (!downloadHref) {
event.preventDefault();
}
}}
>
<DownloadIcon className="icon-inline" />
<span>Download</span>
</a>
<button
type="button"
className="secondary"
onClick={() => onRegenerateThumbnails(document.id)}
>
Re-run analysis
</button>
</div>
</header>
<div className="preview-workspace__body">
{!previewEntry?.url ? (
<div className="preview-workspace__message">Loading preview</div>
) : (
<iframe
src={previewEntry.url}
title={`Preview of ${title}`}
className="preview-workspace__object"
/>
)}
</div>
{metadata && (
<section className="preview-workspace__metadata">
<h3>Metadata</h3>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</section>
)}
</section>
);
};
const DocumentsLayout = ({ sidebarProps, children }) => ( const DocumentsLayout = ({ sidebarProps, children }) => (
<main className="documents-main"> <main className="documents-main">
<Sidebar {...sidebarProps} /> <Sidebar {...sidebarProps} />
@@ -318,19 +233,16 @@ const DocumentsLayout = ({ sidebarProps, children }) => (
</main> </main>
); );
const AppShellContext = React.createContext(null);
const AppLayout = () => { const AppLayout = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const appState = useAppState(); const appState = useAppState();
const appDispatch = useAppDispatch(); const appDispatch = useAppDispatch();
const folderMatch = matchPath('/documents/folder/:folderId', location.pathname); const folderMatch = matchPath('/documents/folder/:folderId', location.pathname);
const nestedDocMatch = matchPath('/documents/folder/:folderId/documents/:documentId', location.pathname); const docMatch = matchPath('/documents/:documentId', location.pathname);
const docMatch = nestedDocMatch || matchPath('/documents/:documentId', location.pathname); const routeFolderId = folderMatch?.params?.folderId || null;
const routeFolderId =
folderMatch?.params?.folderId || nestedDocMatch?.params?.folderId || null;
const routeDocumentId = docMatch?.params?.documentId || null; const routeDocumentId = docMatch?.params?.documentId || null;
const previewDocumentId = routeDocumentId;
const { status: appStatus, token } = appState; const { status: appStatus, token } = appState;
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
const setStatusMessage = useCallback((message, variant = 'info') => { const setStatusMessage = useCallback((message, variant = 'info') => {
@@ -475,8 +387,6 @@ const AppLayout = () => {
folderName: DEFAULT_FOLDER_NAME, folderName: DEFAULT_FOLDER_NAME,
}); });
const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null); const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null);
const [previewDocumentId, setPreviewDocumentId] = useState(null);
const [previewDocumentLoading, setPreviewDocumentLoading] = useState(false);
const [searchLoading, setSearchLoading] = useState(false); const [searchLoading, setSearchLoading] = useState(false);
const shellRef = useRef(null); const shellRef = useRef(null);
const assetManagerRef = useRef(null); const assetManagerRef = useRef(null);
@@ -608,8 +518,6 @@ const AppLayout = () => {
setActiveTagFilters([]); setActiveTagFilters([]);
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME }); setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
setActivePreviewId(null); setActivePreviewId(null);
setPreviewDocumentId(null);
setPreviewDocumentLoading(false);
assetManager.reset(); assetManager.reset();
setPreviewEntries(() => new Map()); setPreviewEntries(() => new Map());
previewInflightRef.current = new Map(); previewInflightRef.current = new Map();
@@ -754,9 +662,6 @@ const AppLayout = () => {
useEffect(() => { useEffect(() => {
if (!selectedDocumentIds.length) { if (!selectedDocumentIds.length) {
if (activePreviewId !== null) {
setActivePreviewId(null);
}
return; return;
} }
if (!selectedDocumentIds.includes(activePreviewId)) { if (!selectedDocumentIds.includes(activePreviewId)) {
@@ -3084,63 +2989,75 @@ const AppLayout = () => {
[token, refreshCurrentFolder, notifyApiError, setStatusMessage], [token, refreshCurrentFolder, notifyApiError, setStatusMessage],
); );
const openDocumentPreview = useCallback( const ensurePreviewData = useCallback(
async (documentId, { replace = false, skipNavigate = false } = {}) => { async (documentId) => {
if (!documentId) return; if (!documentId) return null;
setPreviewDocumentId(documentId);
setPreviewDocumentLoading(true); const findInCache = () => {
try {
const pool = searchResults ?? documents; const pool = searchResults ?? documents;
const doc = pool.find((item) => item.id === documentId); return pool.find((item) => item.id === documentId) || null;
};
let doc = findInCache();
if (!doc) {
const { data } = await api.get(`/documents/${documentId}`);
const hydratedDetail = assetManager.hydrateDetail(data);
const fetched = hydratedDetail?.document || data.document || data;
doc = fetched ? assetManager.hydrateDocument(fetched) : null;
if (!doc) { if (!doc) {
throw new Error('Document metadata unavailable.'); throw new Error('Document metadata unavailable.');
} }
const currentVersion = doc.current_version || null; setDocuments((prev) => {
const previewAsset = getAssetFromVersion(currentVersion, 'preview'); if (prev.some((item) => item.id === doc.id)) {
const thumbnailAsset = getAssetFromVersion(currentVersion, 'thumbnail'); return prev;
const refreshAssetIfNeeded = async (asset) => {
if (!asset?.id) {
return;
} }
const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; return [doc, ...prev];
const shouldForce = Boolean(asset.url && expiresAt && expiresAt <= Date.now()); });
if (!asset.url || shouldForce) {
try {
await ensureAssetUrl(documentId, asset, { force: shouldForce || !asset.url });
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
};
await refreshAssetIfNeeded(previewAsset);
refreshAssetIfNeeded(thumbnailAsset);
await ensurePreviewUrl(documentId, { force: false });
setActivePreviewId(documentId);
if (!skipNavigate && navigate) {
navigate(`/documents/${documentId}`, { replace });
}
} catch (error) {
notifyApiError(error, 'Failed to open document preview.');
setPreviewDocumentId(null);
} finally {
setPreviewDocumentLoading(false);
} }
const targetFolder = doc?.folder_id || selectedFolder || 'root';
if (targetFolder && targetFolder !== selectedFolder) {
await loadFolder(targetFolder, { showLoading: false, preserveSearch: isFilterActive });
doc = findInCache() || doc;
}
await ensurePreviewUrl(documentId, { force: false });
setActivePreviewId(documentId);
return doc;
}, },
[ [
documents,
searchResults, searchResults,
documents,
api,
assetManager,
setDocuments,
selectedFolder,
loadFolder,
isFilterActive,
ensurePreviewUrl, ensurePreviewUrl,
ensureAssetUrl, setActivePreviewId,
navigate,
notifyApiError,
], ],
); );
const openDocumentPreview = useCallback(
(documentId, { replace = false } = {}) => {
if (!documentId) return;
navigate(`/documents/${documentId}`, { replace });
},
[navigate],
);
const closeDocumentPreview = useCallback(
(folderId = null) => {
const targetId = folderId || selectedFolder || 'root';
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
navigate(path, { replace: false });
},
[navigate, selectedFolder],
);
const handleDocumentListFocus = useCallback(() => { const handleDocumentListFocus = useCallback(() => {
if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) { if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
return; return;
@@ -3269,24 +3186,6 @@ const AppLayout = () => {
], ],
); );
const closeDocumentPreview = useCallback(
(folderId = null) => {
setPreviewDocumentId(null);
setPreviewDocumentLoading(false);
const targetId = folderId || selectedFolder || 'root';
if (!navigate) {
loadFolder(targetId, { showLoading: false, preserveSearch: isFilterActive });
return;
}
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
navigate(path, { replace: false });
},
[navigate, selectedFolder, loadFolder, isFilterActive],
);
useEffect(() => { useEffect(() => {
if (!previewDocumentId) return; if (!previewDocumentId) return;
const handleKeyDown = (event) => { const handleKeyDown = (event) => {
@@ -3298,63 +3197,6 @@ const AppLayout = () => {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [previewDocumentId, closeDocumentPreview]); }, [previewDocumentId, closeDocumentPreview]);
useEffect(() => {
if (!routeDocumentId) {
if (previewDocumentId) {
closeDocumentPreview();
}
return;
}
let cancelled = false;
const hydratePreview = async () => {
try {
const pool = searchResults ?? documents;
let doc = pool.find((item) => item.id === routeDocumentId) || null;
if (!doc) {
const { data } = await api.get(`/documents/${routeDocumentId}`);
const hydratedDetail = assetManager.hydrateDetail(data);
const fetched = hydratedDetail?.document || data.document || data;
doc = fetched ? assetManager.hydrateDocument(fetched) : null;
}
const targetFolder = doc?.folder_id || routeFolderId || 'root';
if (targetFolder && targetFolder !== selectedFolder) {
await loadFolder(targetFolder, { showLoading: false, preserveSearch: isFilterActive });
}
if (!cancelled) {
await openDocumentPreview(routeDocumentId, { replace: true, skipNavigate: true });
}
} catch (error) {
if (!cancelled) {
notifyApiError(error, 'Failed to open document preview.');
}
}
};
hydratePreview();
return () => {
cancelled = true;
};
}, [
routeDocumentId,
routeFolderId,
api,
assetManager,
selectedFolder,
loadFolder,
openDocumentPreview,
previewDocumentId,
closeDocumentPreview,
documents,
searchResults,
notifyApiError,
isFilterActive,
]);
const handleDocumentTitleUpdate = useCallback( const handleDocumentTitleUpdate = useCallback(
async (documentId, nextTitle) => { async (documentId, nextTitle) => {
const trimmed = nextTitle.trim(); const trimmed = nextTitle.trim();
@@ -4635,6 +4477,9 @@ const AppLayout = () => {
showSkeuoWorkspace, showSkeuoWorkspace,
exitSkeuoWorkspace, exitSkeuoWorkspace,
skeuoWorkspaceProps, skeuoWorkspaceProps,
ensurePreviewData,
notifyApiError,
resolveApiPath,
}), }),
[ [
token, token,
@@ -4670,6 +4515,9 @@ const AppLayout = () => {
showSkeuoWorkspace, showSkeuoWorkspace,
exitSkeuoWorkspace, exitSkeuoWorkspace,
skeuoWorkspaceProps, skeuoWorkspaceProps,
ensurePreviewData,
notifyApiError,
resolveApiPath,
], ],
); );
@@ -4826,41 +4674,15 @@ const AppLayout = () => {
); );
}; };
const useAppShell = () => {
const context = useContext(AppShellContext);
if (!context) {
throw new Error('AppShellContext not found. Ensure routes are nested under AppLayout.');
}
return context;
};
const DocumentsRoute = () => { const DocumentsRoute = () => {
const { const {
sidebarProps, sidebarProps,
previewActive,
previewWorkspaceDocument,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
documentsTableProps, documentsTableProps,
detailPanelProps, detailPanelProps,
workspaceMode, workspaceMode,
skeuoWorkspaceProps, skeuoWorkspaceProps,
} = useAppShell(); } = useAppShell();
if (previewActive && previewWorkspaceDocument) {
return (
<main className="preview-main">
<PreviewWorkspace
document={previewWorkspaceDocument}
previewEntry={previewWorkspaceEntry}
onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
</main>
);
}
if (workspaceMode === 'skeuo') { if (workspaceMode === 'skeuo') {
return ( return (
<main className="skeuo-main"> <main className="skeuo-main">
@@ -4998,11 +4820,7 @@ const AppRouter = () => (
<Route path="/" element={<Navigate to="/documents" replace />} /> <Route path="/" element={<Navigate to="/documents" replace />} />
<Route path="/documents" element={<DocumentsRoute />} /> <Route path="/documents" element={<DocumentsRoute />} />
<Route path="/documents/folder/:folderId" element={<DocumentsRoute />} /> <Route path="/documents/folder/:folderId" element={<DocumentsRoute />} />
<Route <Route path="/documents/:documentId" element={<DocumentViewerRoute />} />
path="/documents/folder/:folderId/documents/:documentId"
element={<DocumentsRoute />}
/>
<Route path="/documents/:documentId" element={<DocumentsRoute />} />
<Route path="/tags" element={<TagsRoute />} /> <Route path="/tags" element={<TagsRoute />} />
<Route path="/correspondents" element={<CorrespondentsRoute />} /> <Route path="/correspondents" element={<CorrespondentsRoute />} />
<Route path="*" element={<Navigate to="/documents" replace />} /> <Route path="*" element={<Navigate to="/documents" replace />} />
+92
View File
@@ -0,0 +1,92 @@
import React from 'react';
import { DownloadIcon } from '../ui/icons';
import { formatFileSize } from '../utils/format';
const PreviewWorkspace = ({
document,
previewEntry,
resolveApiPath,
onClose,
onRegenerateThumbnails,
}) => {
if (!document) {
return null;
}
const title = document.title || document.original_name || 'Document';
const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
const downloadHref = document.current_version?.download_path
? resolveApiPath(document.current_version.download_path)
: null;
const sizeBytes = Number(document.current_version?.size_bytes) || 0;
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
const metadata =
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
return (
<section className="preview-workspace">
<header className="preview-workspace__header">
<div className="preview-workspace__meta">
<button
type="button"
className="secondary"
onClick={() => onClose(document.folder_id ?? 'root')}
>
Back
</button>
<div>
<h2>{title}</h2>
<span className="meta">
{document.content_type || mime}
{sizeLabel ? ` · ${sizeLabel}` : ''}
</span>
</div>
</div>
<div className="preview-workspace__actions">
<a
className="button-link with-icon"
href={downloadHref || '#'}
target="_blank"
rel="noopener noreferrer"
aria-disabled={!downloadHref}
onClick={(event) => {
if (!downloadHref) {
event.preventDefault();
}
}}
>
<DownloadIcon className="icon-inline" />
<span>Download</span>
</a>
<button
type="button"
className="secondary"
onClick={() => onRegenerateThumbnails(document.id)}
>
Re-run analysis
</button>
</div>
</header>
<div className="preview-workspace__body">
{!previewEntry?.url ? (
<div className="preview-workspace__message">Loading preview</div>
) : (
<iframe
src={previewEntry.url}
title={`Preview of ${title}`}
className="preview-workspace__object"
/>
)}
</div>
{metadata && (
<section className="preview-workspace__metadata">
<h3>Metadata</h3>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</section>
)}
</section>
);
};
export default PreviewWorkspace;
@@ -0,0 +1,72 @@
import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAppShell } from '../appShellContext';
import PreviewWorkspace from '../preview/PreviewWorkspace';
const DocumentViewerRoute = () => {
const {
previewWorkspaceDocument,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
ensurePreviewData,
notifyApiError,
resolveApiPath,
} = useAppShell();
const { documentId } = useParams();
const navigate = useNavigate();
useEffect(() => {
if (!documentId) {
navigate('/documents', { replace: true });
return;
}
let cancelled = false;
const hydrate = async () => {
try {
await ensurePreviewData(documentId);
} catch (error) {
if (cancelled) {
return;
}
notifyApiError(error, 'Failed to open document preview.');
navigate('/documents', { replace: true });
}
};
hydrate();
return () => {
cancelled = true;
};
}, [documentId, ensurePreviewData, notifyApiError, navigate]);
const isReady =
documentId && previewWorkspaceDocument && previewWorkspaceDocument.id === documentId;
if (!isReady) {
return (
<main className="preview-main">
<div className="preview-workspace__message">Loading preview</div>
</main>
);
}
return (
<main className="preview-main">
<PreviewWorkspace
document={previewWorkspaceDocument}
previewEntry={previewWorkspaceEntry}
resolveApiPath={resolveApiPath}
onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
</main>
);
};
export default DocumentViewerRoute;