frontend: refactor preview
This commit is contained in:
@@ -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
@@ -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 />} />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
Reference in New Issue
Block a user