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, onFolderDragLeave,
onFolderDelete, onFolderDelete,
onDocumentSelect, onDocumentSelect,
onDocumentOpen,
selectedDocumentId, selectedDocumentId,
onDocumentDragStart, onDocumentDragStart,
onDocumentDragEnd, onDocumentDragEnd,
@@ -371,6 +372,12 @@ const DocumentsTable = ({
key={doc.id} key={doc.id}
className={`document${selectedDocumentId === doc.id ? ' selected' : ''}`} className={`document${selectedDocumentId === doc.id ? ' selected' : ''}`}
onClick={() => onDocumentSelect(doc.id)} onClick={() => onDocumentSelect(doc.id)}
onDoubleClick={(event) => {
event.stopPropagation();
if (onDocumentOpen) {
onDocumentOpen(doc.id);
}
}}
draggable draggable
onDragStart={(event) => onDocumentDragStart(event, doc.id)} onDragStart={(event) => onDocumentDragStart(event, doc.id)}
onDragEnd={onDocumentDragEnd} onDragEnd={onDocumentDragEnd}
@@ -437,6 +444,8 @@ const DetailPanel = ({
onTagRemove, onTagRemove,
onRegenerateThumbnails, onRegenerateThumbnails,
loading, loading,
previewEntry,
onOpenPreview,
}) => ( }) => (
<aside className="detail-panel column"> <aside className="detail-panel column">
<div className="column-header"> <div className="column-header">
@@ -470,6 +479,14 @@ const DetailPanel = ({
<IconDownload className="icon-inline" /> <IconDownload className="icon-inline" />
<span>Download</span> <span>Download</span>
</button> </button>
<button
type="button"
className="secondary"
onClick={() => onOpenPreview(document.id)}
disabled={loading}
>
Open preview
</button>
<button <button
type="button" type="button"
className="secondary" className="secondary"
@@ -480,19 +497,16 @@ const DetailPanel = ({
</button> </button>
</div> </div>
<div> <div>
<dt>Thumbnails</dt> <dt>Preview</dt>
<div className="thumbnail-preview"> <div className="preview-pane">
{detail.assets?.length ? ( {previewEntry?.url ? (
detail.assets.map((asset) => ( <iframe
<img src={previewEntry.url}
key={asset.id} title={`Preview of ${detail.document.original_name}`}
src={asset.url} className="preview-object"
alt={`${asset.asset_type} for ${detail.document.original_name}`} />
className="thumbnail-image"
/>
))
) : ( ) : (
<span className="meta">No thumbnails yet.</span> <span className="meta">Preview loading</span>
)} )}
</div> </div>
</div> </div>
@@ -544,6 +558,75 @@ const DetailPanel = ({
</aside> </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 = ({ const Sidebar = ({
folderNodes, folderNodes,
onToggle, onToggle,
@@ -887,6 +970,10 @@ function App() {
); );
const folderPathCacheRef = useRef(new Map()); 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( const ensureFolderPathOnServer = useCallback(
async (baseFolderId, segments) => { 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) => { const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
if ( if (
!dataTransfer || !dataTransfer ||
@@ -987,7 +1106,15 @@ function App() {
try { try {
folderPathCacheRef.current.clear(); 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) { if (!extracted.length) {
setStatusMessage('No files to upload.', 'info'); setStatusMessage('No files to upload.', 'info');
return; return;
@@ -1101,6 +1228,40 @@ const handleDownload = useCallback(
[token, ensureDocumentDetail, setStatusMessage], [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( const handleTagRemove = useCallback(
async (documentId, tagId) => { async (documentId, tagId) => {
try { try {
@@ -1526,6 +1687,38 @@ const handleDownload = useCallback(
? documentDetails.get(selectedDocument.id) ? documentDetails.get(selectedDocument.id)
: null; : 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 ( return (
<div className="app-shell"> <div className="app-shell">
{token ? ( {token ? (
@@ -1538,7 +1731,9 @@ const handleDownload = useCallback(
<div className="app-bar__meta"> <div className="app-bar__meta">
<h1>Paperless-NEO</h1> <h1>Paperless-NEO</h1>
<span className="app-bar__hint"> <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> </span>
</div> </div>
<div className="app-bar__actions"> <div className="app-bar__actions">
@@ -1560,60 +1755,77 @@ const handleDownload = useCallback(
<StatusBanner status={status} /> <StatusBanner status={status} />
</div> </div>
)} )}
<main className="app-main"> <main className={previewActive ? 'preview-main' : 'app-main'}>
<Sidebar {previewActive && previewWorkspaceDocument ? (
folderNodes={folderNodes} <PreviewWorkspace
onToggle={folderClickHandlers.onToggle} document={previewWorkspaceDocument}
onSelect={folderClickHandlers.onSelect} detail={previewWorkspaceDetail}
onDrop={folderClickHandlers.onDrop} previewEntry={previewWorkspaceEntry}
onDragOver={folderClickHandlers.onDragOver} loading={previewDocumentLoading}
onDragLeave={folderClickHandlers.onDragLeave} onClose={closeDocumentPreview}
onCreateFolder={handleFolderCreate} onDownload={handleDownload}
onDeleteFolder={handleFolderDelete} onRegenerateThumbnails={handleThumbnailRegeneration}
disableActions={loading} />
selectedFolder={selectedFolder} ) : (
/> <>
<DocumentsTable <Sidebar
currentFolderName={currentFolderName} folderNodes={folderNodes}
loading={loading} onToggle={folderClickHandlers.onToggle}
onRefresh={refreshCurrentFolder} onSelect={folderClickHandlers.onSelect}
subfolders={currentSubfolders} onDrop={folderClickHandlers.onDrop}
documents={documents} onDragOver={folderClickHandlers.onDragOver}
searchResults={searchResults} onDragLeave={folderClickHandlers.onDragLeave}
isFilterActive={isFilterActive} onCreateFolder={handleFolderCreate}
onFolderSelect={selectFolder} onDeleteFolder={handleFolderDelete}
onFolderDrop={folderClickHandlers.onDrop} disableActions={loading}
onFolderDragOver={folderClickHandlers.onDragOver} selectedFolder={selectedFolder}
onFolderDragLeave={folderClickHandlers.onDragLeave} />
onFolderDelete={handleFolderDelete} <DocumentsTable
onDocumentSelect={(docId) => { currentFolderName={currentFolderName}
setSelectedDocumentId(docId); loading={loading}
ensureDocumentDetail(docId); onRefresh={refreshCurrentFolder}
}} subfolders={currentSubfolders}
selectedDocumentId={selectedDocumentId} documents={documents}
onDocumentDragStart={(event, documentId) => { searchResults={searchResults}
setDraggedDocumentId(documentId); isFilterActive={isFilterActive}
event.dataTransfer.effectAllowed = 'move'; onFolderSelect={selectFolder}
event.dataTransfer.setData('application/x-paperless-doc', documentId); onFolderDrop={folderClickHandlers.onDrop}
event.currentTarget.classList.add('dragging'); onFolderDragOver={folderClickHandlers.onDragOver}
}} onFolderDragLeave={folderClickHandlers.onDragLeave}
onDocumentDragEnd={(event) => { onFolderDelete={handleFolderDelete}
setDraggedDocumentId(null); onDocumentSelect={(docId) => {
event.currentTarget.classList.remove('dragging'); setSelectedDocumentId(docId);
}} ensureDocumentDetail(docId);
onDownload={handleDownload} }}
filterBar={filterBar} onDocumentOpen={openDocumentPreview}
/> selectedDocumentId={selectedDocumentId}
<DetailPanel onDocumentDragStart={(event, documentId) => {
document={selectedDocument} setDraggedDocumentId(documentId);
detail={selectedDetail} event.dataTransfer.effectAllowed = 'move';
tags={tags} event.dataTransfer.setData('application/x-paperless-doc', documentId);
onDownload={handleDownload} event.currentTarget.classList.add('dragging');
onTagAdd={handleTagAdd} }}
onTagRemove={handleTagRemove} onDocumentDragEnd={(event) => {
onRegenerateThumbnails={handleThumbnailRegeneration} setDraggedDocumentId(null);
loading={loading} 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> </main>
</> </>
) : ( ) : (
+104 -2
View File
@@ -173,6 +173,89 @@ button.icon-button.ghost:hover:not([disabled]) {
overflow: hidden; overflow: hidden;
} }
.preview-main {
flex: 1;
display: flex;
flex-direction: column;
padding: 0.75rem 1.5rem 1.25rem;
min-height: 0;
overflow: hidden;
}
.preview-workspace {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
}
.preview-workspace__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.preview-workspace__meta {
display: flex;
align-items: center;
gap: 0.75rem;
}
.preview-workspace__meta h2 {
margin: 0;
font-size: 1.1rem;
}
.preview-workspace__meta .meta {
display: block;
font-size: 0.8rem;
color: var(--muted);
}
.preview-workspace__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.preview-workspace__body {
flex: 1;
background: var(--surface-subtle);
border: 1px solid var(--border);
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.preview-workspace__object {
width: 100%;
height: 100%;
border: none;
}
.preview-workspace__message {
color: var(--muted);
font-size: 0.95rem;
}
.preview-workspace__metadata {
border: 1px solid var(--border);
border-radius: 2px;
padding: 0.75rem;
background: var(--surface);
max-height: 180px;
overflow: auto;
}
.preview-workspace__metadata h3 {
margin: 0 0 0.5rem;
font-size: 0.9rem;
}
.column { .column {
background: transparent; background: transparent;
border: none; border: none;
@@ -381,8 +464,8 @@ button.icon-button.ghost:hover:not([disabled]) {
width: 36px; width: 36px;
height: 48px; height: 48px;
object-fit: cover; object-fit: cover;
border-radius: 0; border-radius: 3px;
box-shadow: none; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
} }
.thumb-placeholder { .thumb-placeholder {
@@ -526,6 +609,25 @@ button.icon-button.ghost:hover:not([disabled]) {
margin: 0.6rem 0; margin: 0.6rem 0;
} }
.preview-pane {
margin-top: 0.4rem;
border: 1px solid var(--border);
border-radius: 2px;
background: var(--surface-subtle);
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.preview-object {
width: 100%;
height: 320px;
border: none;
background: var(--surface);
}
.thumbnail-preview { .thumbnail-preview {
display: flex; display: flex;
flex-direction: column; flex-direction: column;