frontend: preview
This commit is contained in:
+280
-68
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
+104
-2
@@ -173,6 +173,89 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
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 {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -381,8 +464,8 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
width: 36px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.thumb-placeholder {
|
||||
@@ -526,6 +609,25 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user