frontend: preview

This commit is contained in:
2025-10-10 18:59:19 +02:00
parent dfadc8ba23
commit 5fd4a41e64
2 changed files with 384 additions and 70 deletions
+280 -68
View File
@@ -291,6 +291,7 @@ const DocumentsTable = ({
onFolderDragLeave,
onFolderDelete,
onDocumentSelect,
onDocumentOpen,
selectedDocumentId,
onDocumentDragStart,
onDocumentDragEnd,
@@ -371,6 +372,12 @@ const DocumentsTable = ({
key={doc.id}
className={`document${selectedDocumentId === doc.id ? ' selected' : ''}`}
onClick={() => onDocumentSelect(doc.id)}
onDoubleClick={(event) => {
event.stopPropagation();
if (onDocumentOpen) {
onDocumentOpen(doc.id);
}
}}
draggable
onDragStart={(event) => onDocumentDragStart(event, doc.id)}
onDragEnd={onDocumentDragEnd}
@@ -437,6 +444,8 @@ const DetailPanel = ({
onTagRemove,
onRegenerateThumbnails,
loading,
previewEntry,
onOpenPreview,
}) => (
<aside className="detail-panel column">
<div className="column-header">
@@ -470,6 +479,14 @@ const DetailPanel = ({
<IconDownload className="icon-inline" />
<span>Download</span>
</button>
<button
type="button"
className="secondary"
onClick={() => onOpenPreview(document.id)}
disabled={loading}
>
Open preview
</button>
<button
type="button"
className="secondary"
@@ -480,19 +497,16 @@ const DetailPanel = ({
</button>
</div>
<div>
<dt>Thumbnails</dt>
<div className="thumbnail-preview">
{detail.assets?.length ? (
detail.assets.map((asset) => (
<img
key={asset.id}
src={asset.url}
alt={`${asset.asset_type} for ${detail.document.original_name}`}
className="thumbnail-image"
/>
))
<dt>Preview</dt>
<div className="preview-pane">
{previewEntry?.url ? (
<iframe
src={previewEntry.url}
title={`Preview of ${detail.document.original_name}`}
className="preview-object"
/>
) : (
<span className="meta">No thumbnails yet.</span>
<span className="meta">Preview loading</span>
)}
</div>
</div>
@@ -544,6 +558,75 @@ const DetailPanel = ({
</aside>
);
const PreviewWorkspace = ({
document,
detail,
previewEntry,
loading,
onClose,
onDownload,
onRegenerateThumbnails,
}) => {
if (!document) {
return null;
}
const title = detail?.document?.original_name || document.original_name;
const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
return (
<section className="preview-workspace">
<header className="preview-workspace__header">
<div className="preview-workspace__meta">
<button type="button" className="secondary" onClick={onClose}>
Back
</button>
<div>
<h2>{title}</h2>
<span className="meta">
{document.content_type || 'Document'}
{detail?.current_version?.size_bytes
? ` · ${(detail.current_version.size_bytes / 1024 / 1024).toFixed(2)} MB`
: ''}
</span>
</div>
</div>
<div className="preview-workspace__actions">
<button type="button" className="with-icon" onClick={() => onDownload(document.id)}>
<IconDownload className="icon-inline" />
<span>Download</span>
</button>
<button
type="button"
className="secondary"
onClick={() => onRegenerateThumbnails(document.id)}
disabled={loading}
>
Regenerate thumbnails
</button>
</div>
</header>
<div className="preview-workspace__body">
{loading || !previewEntry?.url ? (
<div className="preview-workspace__message">Loading preview</div>
) : (
<iframe
src={previewEntry.url}
title={`Preview of ${title}`}
className="preview-workspace__object"
/>
)}
</div>
{detail?.document?.metadata && Object.keys(detail.document.metadata).length > 0 && (
<section className="preview-workspace__metadata">
<h3>Metadata</h3>
<pre>{JSON.stringify(detail.document.metadata, null, 2)}</pre>
</section>
)}
</section>
);
};
const Sidebar = ({
folderNodes,
onToggle,
@@ -887,6 +970,10 @@ function App() {
);
const folderPathCacheRef = useRef(new Map());
const previewCacheRef = useRef(new Map());
const [previewCacheTick, setPreviewCacheTick] = useState(0);
const [previewDocumentId, setPreviewDocumentId] = useState(null);
const [previewDocumentLoading, setPreviewDocumentLoading] = useState(false);
const ensureFolderPathOnServer = useCallback(
async (baseFolderId, segments) => {
@@ -914,6 +1001,38 @@ function App() {
[],
);
const ensurePreviewUrl = useCallback(
async (documentId, { force = false } = {}) => {
if (!documentId) return null;
const cached = previewCacheRef.current.get(documentId);
const now = Date.now();
if (!force && cached && (!cached.expiresAt || cached.expiresAt > now)) {
return cached;
}
try {
const { data } = await api.get(`/documents/${documentId}/download`);
const ttl = data.expires_in ? Math.max(data.expires_in - 60, 30) * 1000 : 5 * 60 * 1000;
const entry = {
url: data.url,
contentType: data.content_type || null,
filename: data.filename,
expiresAt: now + ttl,
};
previewCacheRef.current.set(documentId, entry);
setPreviewCacheTick((prev) => prev + 1);
return entry;
} catch (error) {
console.error(error);
setStatusMessage('Unable to fetch document preview.', 'error');
throw error;
}
},
[setStatusMessage],
);
const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
if (
!dataTransfer ||
@@ -987,7 +1106,15 @@ function App() {
try {
folderPathCacheRef.current.clear();
const extracted = await extractFilesFromDataTransfer(dataTransfer);
let extracted;
try {
extracted = await extractFilesFromDataTransfer(dataTransfer);
} catch (error) {
console.error(error);
setStatusMessage(error.message || 'Failed to process dropped files.', 'error');
return;
}
if (!extracted.length) {
setStatusMessage('No files to upload.', 'info');
return;
@@ -1101,6 +1228,40 @@ const handleDownload = useCallback(
[token, ensureDocumentDetail, setStatusMessage],
);
const openDocumentPreview = useCallback(
async (documentId) => {
if (!documentId) return;
setPreviewDocumentId(documentId);
setPreviewDocumentLoading(true);
try {
await ensureDocumentDetail(documentId, { force: false });
await ensurePreviewUrl(documentId, { force: false });
} catch (error) {
console.error(error);
setPreviewDocumentId(null);
} finally {
setPreviewDocumentLoading(false);
}
},
[ensureDocumentDetail, ensurePreviewUrl],
);
const closeDocumentPreview = useCallback(() => {
setPreviewDocumentId(null);
setPreviewDocumentLoading(false);
}, []);
useEffect(() => {
if (!previewDocumentId) return;
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
closeDocumentPreview();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [previewDocumentId, closeDocumentPreview]);
const handleTagRemove = useCallback(
async (documentId, tagId) => {
try {
@@ -1526,6 +1687,38 @@ const handleDownload = useCallback(
? documentDetails.get(selectedDocument.id)
: null;
useEffect(() => {
if (!selectedDocument) return;
ensurePreviewUrl(selectedDocument.id).catch(() => {});
}, [selectedDocument, ensurePreviewUrl]);
const selectedPreviewEntry = useMemo(() => {
if (!selectedDocument) {
return null;
}
return previewCacheRef.current.get(selectedDocument.id) || null;
}, [selectedDocument, previewCacheTick]);
const previewWorkspaceEntry = useMemo(() => {
if (!previewDocumentId) {
return null;
}
return previewCacheRef.current.get(previewDocumentId) || null;
}, [previewDocumentId, previewCacheTick]);
const previewWorkspaceDetail = previewDocumentId
? documentDetails.get(previewDocumentId)
: null;
const previewWorkspaceDocument = useMemo(() => {
if (!previewDocumentId) return null;
if (previewWorkspaceDetail?.document) return previewWorkspaceDetail.document;
const pool = searchResults ?? documents;
return pool.find((doc) => doc.id === previewDocumentId) || null;
}, [previewDocumentId, previewWorkspaceDetail, searchResults, documents]);
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
return (
<div className="app-shell">
{token ? (
@@ -1538,7 +1731,9 @@ const handleDownload = useCallback(
<div className="app-bar__meta">
<h1>Paperless-NEO</h1>
<span className="app-bar__hint">
Drag files anywhere to upload into "{currentFolderName}".
{previewActive
? 'Viewing document preview. Press ← Back to return to the library.'
: `Drag files anywhere to upload into "${currentFolderName}".`}
</span>
</div>
<div className="app-bar__actions">
@@ -1560,60 +1755,77 @@ const handleDownload = useCallback(
<StatusBanner status={status} />
</div>
)}
<main className="app-main">
<Sidebar
folderNodes={folderNodes}
onToggle={folderClickHandlers.onToggle}
onSelect={folderClickHandlers.onSelect}
onDrop={folderClickHandlers.onDrop}
onDragOver={folderClickHandlers.onDragOver}
onDragLeave={folderClickHandlers.onDragLeave}
onCreateFolder={handleFolderCreate}
onDeleteFolder={handleFolderDelete}
disableActions={loading}
selectedFolder={selectedFolder}
/>
<DocumentsTable
currentFolderName={currentFolderName}
loading={loading}
onRefresh={refreshCurrentFolder}
subfolders={currentSubfolders}
documents={documents}
searchResults={searchResults}
isFilterActive={isFilterActive}
onFolderSelect={selectFolder}
onFolderDrop={folderClickHandlers.onDrop}
onFolderDragOver={folderClickHandlers.onDragOver}
onFolderDragLeave={folderClickHandlers.onDragLeave}
onFolderDelete={handleFolderDelete}
onDocumentSelect={(docId) => {
setSelectedDocumentId(docId);
ensureDocumentDetail(docId);
}}
selectedDocumentId={selectedDocumentId}
onDocumentDragStart={(event, documentId) => {
setDraggedDocumentId(documentId);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('application/x-paperless-doc', documentId);
event.currentTarget.classList.add('dragging');
}}
onDocumentDragEnd={(event) => {
setDraggedDocumentId(null);
event.currentTarget.classList.remove('dragging');
}}
onDownload={handleDownload}
filterBar={filterBar}
/>
<DetailPanel
document={selectedDocument}
detail={selectedDetail}
tags={tags}
onDownload={handleDownload}
onTagAdd={handleTagAdd}
onTagRemove={handleTagRemove}
onRegenerateThumbnails={handleThumbnailRegeneration}
loading={loading}
/>
<main className={previewActive ? 'preview-main' : 'app-main'}>
{previewActive && previewWorkspaceDocument ? (
<PreviewWorkspace
document={previewWorkspaceDocument}
detail={previewWorkspaceDetail}
previewEntry={previewWorkspaceEntry}
loading={previewDocumentLoading}
onClose={closeDocumentPreview}
onDownload={handleDownload}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
) : (
<>
<Sidebar
folderNodes={folderNodes}
onToggle={folderClickHandlers.onToggle}
onSelect={folderClickHandlers.onSelect}
onDrop={folderClickHandlers.onDrop}
onDragOver={folderClickHandlers.onDragOver}
onDragLeave={folderClickHandlers.onDragLeave}
onCreateFolder={handleFolderCreate}
onDeleteFolder={handleFolderDelete}
disableActions={loading}
selectedFolder={selectedFolder}
/>
<DocumentsTable
currentFolderName={currentFolderName}
loading={loading}
onRefresh={refreshCurrentFolder}
subfolders={currentSubfolders}
documents={documents}
searchResults={searchResults}
isFilterActive={isFilterActive}
onFolderSelect={selectFolder}
onFolderDrop={folderClickHandlers.onDrop}
onFolderDragOver={folderClickHandlers.onDragOver}
onFolderDragLeave={folderClickHandlers.onDragLeave}
onFolderDelete={handleFolderDelete}
onDocumentSelect={(docId) => {
setSelectedDocumentId(docId);
ensureDocumentDetail(docId);
}}
onDocumentOpen={openDocumentPreview}
selectedDocumentId={selectedDocumentId}
onDocumentDragStart={(event, documentId) => {
setDraggedDocumentId(documentId);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('application/x-paperless-doc', documentId);
event.currentTarget.classList.add('dragging');
}}
onDocumentDragEnd={(event) => {
setDraggedDocumentId(null);
event.currentTarget.classList.remove('dragging');
}}
onDownload={handleDownload}
filterBar={filterBar}
/>
<DetailPanel
document={selectedDocument}
detail={selectedDetail}
tags={tags}
onDownload={handleDownload}
onTagAdd={handleTagAdd}
onTagRemove={handleTagRemove}
onRegenerateThumbnails={handleThumbnailRegeneration}
loading={loading}
previewEntry={selectedPreviewEntry}
onOpenPreview={openDocumentPreview}
/>
</>
)}
</main>
</>
) : (