frontend tweaks, preview workspace

This commit is contained in:
2025-10-28 00:43:54 +01:00
parent 709d32050e
commit 1179f4fcd4
6 changed files with 266 additions and 209 deletions
+244 -118
View File
@@ -26,6 +26,7 @@ import './styles.css';
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from './asset_manager'; import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from './asset_manager';
import useApiError from './hooks/useApiError'; import useApiError from './hooks/useApiError';
import SkeuomorphicWorkspace from './skeuomorphic_ws'; import SkeuomorphicWorkspace from './skeuomorphic_ws';
import PreviewWorkspace from './preview/PreviewWorkspace';
import DetailPanel from './detail/DetailPanel'; 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';
@@ -41,10 +42,13 @@ import {
RefreshIcon, RefreshIcon,
ArrowUpIcon, ArrowUpIcon,
MinusVerticalIcon, MinusVerticalIcon,
DownloadIcon,
TextScanIcon,
AnalyzeIcon,
CloseIcon,
} from './ui/icons'; } from './ui/icons';
import DocumentsTable from './documents/DocumentsTable'; import DocumentsTable from './documents/DocumentsTable';
import { AppShellContext, useAppShell } from './appShellContext'; 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
@@ -3624,6 +3628,26 @@ const AppLayout = () => {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [previewDocumentId, closeDocumentPreview]); }, [previewDocumentId, closeDocumentPreview]);
useEffect(() => {
if (!previewDocumentId) {
return;
}
let cancelled = false;
ensurePreviewData(previewDocumentId).catch((error) => {
if (cancelled) {
return;
}
notifyApiError(error, 'Failed to open document preview.');
closeDocumentPreview();
});
return () => {
cancelled = true;
};
}, [previewDocumentId, ensurePreviewData, notifyApiError, closeDocumentPreview]);
const handleDocumentTitleUpdate = useCallback( const handleDocumentTitleUpdate = useCallback(
async (documentId, nextTitle) => { async (documentId, nextTitle) => {
const trimmed = nextTitle.trim(); const trimmed = nextTitle.trim();
@@ -5188,6 +5212,8 @@ const AppLayout = () => {
exitSkeuoWorkspace, exitSkeuoWorkspace,
skeuoWorkspaceProps, skeuoWorkspaceProps,
ensurePreviewData, ensurePreviewData,
ensureAssetUrl,
getDocumentAsset,
notifyApiError, notifyApiError,
resolveApiPath, resolveApiPath,
openTagsModal, openTagsModal,
@@ -5226,6 +5252,8 @@ const AppLayout = () => {
exitSkeuoWorkspace, exitSkeuoWorkspace,
skeuoWorkspaceProps, skeuoWorkspaceProps,
ensurePreviewData, ensurePreviewData,
ensureAssetUrl,
getDocumentAsset,
notifyApiError, notifyApiError,
resolveApiPath, resolveApiPath,
openTagsModal, openTagsModal,
@@ -5387,6 +5415,14 @@ const DocumentsRoute = () => {
openTagsModal, openTagsModal,
openCorrespondentsModal, openCorrespondentsModal,
exitSkeuoWorkspace, exitSkeuoWorkspace,
previewWorkspaceDocument,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
resolveApiPath,
ensureAssetUrl,
getDocumentAsset,
notifyApiError,
} = useAppShell(); } = useAppShell();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -5416,12 +5452,24 @@ const DocumentsRoute = () => {
breadcrumbs, breadcrumbs,
} = documentsTableProps; } = documentsTableProps;
const isWorkspace = workspaceMode === 'skeuo';
const isPreviewWorkspace = Boolean(previewWorkspaceDocument);
const showPreviewWorkspace = !isWorkspace && isPreviewWorkspace;
const isGridView = viewMode === 'grid'; const isGridView = viewMode === 'grid';
const showingSearchResults = Array.isArray(searchResults); const showingSearchResults = Array.isArray(searchResults);
const headerTitle = showingSearchResults ? 'Search results' : currentFolderName; let headerTitle;
const headerSubtitle = showingSearchResults let headerSubtitle = null;
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` if (showPreviewWorkspace) {
: null; const previewTitle = previewWorkspaceDocument?.title
|| previewWorkspaceDocument?.original_name
|| 'Document preview';
headerTitle = previewTitle;
} else if (showingSearchResults) {
headerTitle = 'Search results';
headerSubtitle = `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`;
} else {
headerTitle = currentFolderName;
}
const parentBreadcrumb = breadcrumbs && breadcrumbs.length > 1 ? breadcrumbs[breadcrumbs.length - 2] : null; const parentBreadcrumb = breadcrumbs && breadcrumbs.length > 1 ? breadcrumbs[breadcrumbs.length - 2] : null;
const handleNavigateParent = parentBreadcrumb const handleNavigateParent = parentBreadcrumb
? () => { ? () => {
@@ -5432,77 +5480,85 @@ const DocumentsRoute = () => {
} }
: null; : null;
const detailHasContent = detailPanelProps.selectedDocuments?.length > 0; const detailHasContent = !isWorkspace && !showPreviewWorkspace
&& detailPanelProps.selectedDocuments?.length > 0;
const toggleSidebar = sidebarCollapsed ? expandSidebar : collapseSidebar; const toggleSidebar = sidebarCollapsed ? expandSidebar : collapseSidebar;
const ToggleIcon = sidebarCollapsed ? ChevronsRightIcon : ChevronsLeftIcon; const ToggleIcon = sidebarCollapsed ? ChevronsRightIcon : ChevronsLeftIcon;
const toggleLabel = sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'; const toggleLabel = sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar';
const mainContentClass = (() => {
if (isWorkspace) {
return 'main-content main-content--workspace';
}
if (showPreviewWorkspace) {
return 'main-content main-content--preview';
}
return `main-content main-content--documents${
detailHasContent ? ' main-content--has-detail' : ''
}`;
})();
if (workspaceMode === 'skeuo') { const bodyClass = (() => {
return ( if (isWorkspace) {
<DocumentsLayout return 'main-content__body main-content__body--workspace';
sidebarProps={sidebarPropsWithActions} }
sidebarCollapsed={sidebarCollapsed} if (showPreviewWorkspace) {
> return 'main-content__body main-content__body--preview';
<div className="main-content"> }
<div className="panel-header main-content__header"> return `main-content__body main-content__body--documents${
<div className="panel-actions main-content__actions"> detailHasContent ? ' main-content__body--has-detail' : ''
{sidebarCollapsed ? ( }`;
<button })();
type="button"
className="icon-button ghost" const previewDownloadHref = useMemo(() => {
onClick={toggleSidebar} if (!previewWorkspaceDocument?.current_version?.download_path) {
aria-label={toggleLabel} return null;
title={toggleLabel} }
> return resolveApiPath(previewWorkspaceDocument.current_version.download_path);
<ToggleIcon /> }, [previewWorkspaceDocument, resolveApiPath]);
</button>
) : null} const previewHasOcr = useMemo(
{parentBreadcrumb ? ( () => Boolean(
<button previewWorkspaceDocument && getDocumentAsset(previewWorkspaceDocument, 'ocr-text'),
type="button" ),
className="icon-button ghost" [previewWorkspaceDocument, getDocumentAsset],
onClick={handleNavigateParent} );
aria-label="Go to parent folder"
title="Go to parent folder" const handlePreviewOcr = useCallback(async () => {
> if (!previewWorkspaceDocument) {
<ArrowUpIcon /> return;
</button> }
) : null} const asset = getDocumentAsset(previewWorkspaceDocument, 'ocr-text');
<h2 className="main-content__title"> if (!asset) {
{headerTitle} return;
{headerSubtitle ? ( }
<span className="main-content__subtitle">{headerSubtitle}</span> try {
) : null} const ensured = await ensureAssetUrl(previewWorkspaceDocument.id, asset, { force: false });
</h2> const entry = ensured || asset;
<div className="spacer" /> const url = entry?.url
<button || resolveDocumentAssetUrl(previewWorkspaceDocument, 'ocr-text', {
type="button" ensureAssetUrl,
className="icon-button ghost" getAsset: getDocumentAsset,
onClick={skeuoWorkspaceProps.onRefresh} });
aria-label="Refresh" if (!url) {
title="Refresh" throw new Error('OCR text URL unavailable.');
> }
<RefreshIcon /> window.open(url, '_blank', 'noopener,noreferrer');
</button> } catch (error) {
<button type="button" className="secondary" onClick={exitSkeuoWorkspace}> notifyApiError(error, 'Unable to open OCR text.');
Back to List }
</button> }, [
</div> previewWorkspaceDocument,
</div> ensureAssetUrl,
<div className="main-content__body"> getDocumentAsset,
<SkeuomorphicWorkspace {...skeuoWorkspaceProps} /> notifyApiError,
</div> ]);
</div>
</DocumentsLayout>
);
}
return ( return (
<DocumentsLayout <DocumentsLayout
sidebarProps={sidebarPropsWithActions} sidebarProps={sidebarPropsWithActions}
sidebarCollapsed={sidebarCollapsed} sidebarCollapsed={sidebarCollapsed}
> >
<div className={`main-content main-content--documents${detailHasContent ? ' main-content--has-detail' : ''}`}> <div className={mainContentClass}>
<div className="panel-header main-content__header"> <div className="panel-header main-content__header">
<div className="panel-actions main-content__actions"> <div className="panel-actions main-content__actions">
{sidebarCollapsed ? ( {sidebarCollapsed ? (
@@ -5516,7 +5572,7 @@ const DocumentsRoute = () => {
<ToggleIcon /> <ToggleIcon />
</button> </button>
) : null} ) : null}
{parentBreadcrumb ? ( {parentBreadcrumb && !showPreviewWorkspace ? (
<button <button
type="button" type="button"
className="icon-button ghost" className="icon-button ghost"
@@ -5534,59 +5590,129 @@ const DocumentsRoute = () => {
) : null} ) : null}
</h2> </h2>
<div className="spacer" /> <div className="spacer" />
<div className="view-toggle" role="group" aria-label="Change view"> {isWorkspace ? (
<button <>
type="button" <button
className={`view-toggle__button${isGridView ? '' : ' active'}`} type="button"
onClick={() => onViewModeChange?.('list')} className="icon-button ghost"
aria-pressed={!isGridView} onClick={skeuoWorkspaceProps.onRefresh}
title="List view" aria-label="Refresh"
> title="Refresh"
<ViewListIcon className="view-toggle__icon" size={18} /> >
</button> <RefreshIcon />
<button </button>
type="button" <button type="button" className="secondary" onClick={exitSkeuoWorkspace}>
className={`view-toggle__button${isGridView ? ' active' : ''}`} Back to List
onClick={() => onViewModeChange?.('grid')} </button>
aria-pressed={isGridView} </>
title="Icons view" ) : showPreviewWorkspace ? (
> <>
<ViewGridIcon className="view-toggle__icon" size={18} /> {previewDownloadHref ? (
</button> <a
</div> className="icon-button"
<span className="main-content__actions-divider" aria-hidden="true"> href={previewDownloadHref}
<MinusVerticalIcon /> target="_blank"
</span> rel="noopener noreferrer"
<button aria-label="Download document"
type="button" title="Download document"
className="icon-button ghost" >
onClick={onRequestCreateFolder} <DownloadIcon />
disabled={creatingFolder} </a>
aria-label={creatingFolder ? 'Creating folder…' : 'Create folder'} ) : null}
title={creatingFolder ? 'Creating folder…' : 'Create folder'} {previewHasOcr ? (
> <button
<FolderPlusIcon /> type="button"
</button> className="icon-button ghost"
<button onClick={handlePreviewOcr}
type="button" aria-label="View OCR text"
className="icon-button ghost" title="View OCR text"
onClick={onRefresh} >
aria-label="Refresh" <TextScanIcon />
title="Refresh" </button>
> ) : null}
<RefreshIcon /> <button
</button> type="button"
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}> className="icon-button ghost"
Desk View onClick={() => handleThumbnailRegeneration(previewWorkspaceDocument.id)}
</button> aria-label="Re-run analysis"
title="Re-run analysis"
>
<AnalyzeIcon />
</button>
<button
type="button"
className="icon-button ghost"
onClick={() => closeDocumentPreview()}
aria-label="Close preview"
title="Close preview"
>
<CloseIcon />
</button>
</>
) : (
<>
<div className="view-toggle" role="group" aria-label="Change view">
<button
type="button"
className={`view-toggle__button${isGridView ? '' : ' active'}`}
onClick={() => onViewModeChange?.('list')}
aria-pressed={!isGridView}
title="List view"
>
<ViewListIcon className="view-toggle__icon" size={18} />
</button>
<button
type="button"
className={`view-toggle__button${isGridView ? ' active' : ''}`}
onClick={() => onViewModeChange?.('grid')}
aria-pressed={isGridView}
title="Icons view"
>
<ViewGridIcon className="view-toggle__icon" size={18} />
</button>
</div>
<span className="main-content__actions-divider" aria-hidden="true">
<MinusVerticalIcon />
</span>
<button
type="button"
className="icon-button ghost"
onClick={onRequestCreateFolder}
disabled={creatingFolder}
aria-label={creatingFolder ? 'Creating folder…' : 'Create folder'}
title={creatingFolder ? 'Creating folder…' : 'Create folder'}
>
<FolderPlusIcon />
</button>
<button
type="button"
className="icon-button ghost"
onClick={onRefresh}
aria-label="Refresh"
title="Refresh"
>
<RefreshIcon />
</button>
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
Desk View
</button>
</>
)}
</div> </div>
</div> </div>
<div <div className={bodyClass}>
className={`main-content__body main-content__body--documents${detailHasContent ? ' main-content__body--has-detail' : ''}`} {isWorkspace ? (
> <SkeuomorphicWorkspace {...skeuoWorkspaceProps} />
<DocumentsTable {...documentsTableProps} showHeader={false} /> ) : showPreviewWorkspace ? (
<PreviewWorkspace
document={previewWorkspaceDocument}
previewEntry={previewWorkspaceEntry}
/>
) : (
<DocumentsTable {...documentsTableProps} showHeader={false} />
)}
</div> </div>
{detailHasContent ? ( {!isWorkspace && !showPreviewWorkspace && detailHasContent ? (
<DetailPanel {...detailPanelProps} /> <DetailPanel {...detailPanelProps} />
) : null} ) : null}
</div> </div>
@@ -5739,7 +5865,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 path="/documents/:documentId" element={<DocumentViewerRoute />} /> <Route path="/documents/:documentId" element={<DocumentsRoute />} />
<Route path="*" element={<Navigate to="/documents" replace />} /> <Route path="*" element={<Navigate to="/documents" replace />} />
</Route> </Route>
</Routes> </Routes>
+4 -52
View File
@@ -1,13 +1,9 @@
import React from 'react'; import React from 'react';
import { DownloadIcon } from '../ui/icons';
import { formatFileSize } from '../utils/format'; import { formatFileSize } from '../utils/format';
const PreviewWorkspace = ({ const PreviewWorkspace = ({
document, document,
previewEntry, previewEntry,
resolveApiPath,
onClose,
onRegenerateThumbnails,
}) => { }) => {
if (!document) { if (!document) {
return null; return null;
@@ -15,9 +11,6 @@ const PreviewWorkspace = ({
const title = document.title || document.original_name || 'Document'; const title = document.title || document.original_name || 'Document';
const mime = previewEntry?.contentType || document.content_type || 'application/pdf'; 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 sizeBytes = Number(document.current_version?.size_bytes) || 0;
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null; const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
const metadata = const metadata =
@@ -25,48 +18,6 @@ const PreviewWorkspace = ({
return ( return (
<section className="preview-workspace"> <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"> <div className="preview-workspace__body">
{!previewEntry?.url ? ( {!previewEntry?.url ? (
<div className="preview-workspace__message">Loading preview</div> <div className="preview-workspace__message">Loading preview</div>
@@ -78,15 +29,16 @@ const PreviewWorkspace = ({
/> />
)} )}
</div> </div>
{metadata && ( {metadata ? (
<section className="preview-workspace__metadata"> <section className="preview-workspace__metadata">
<h3>Metadata</h3> <h3>Metadata</h3>
{sizeLabel ? <p><strong>Size:</strong> {sizeLabel}</p> : null}
<p><strong>Type:</strong> {mime}</p>
<pre>{JSON.stringify(metadata, null, 2)}</pre> <pre>{JSON.stringify(metadata, null, 2)}</pre>
</section> </section>
)} ) : null}
</section> </section>
); );
}; };
export default PreviewWorkspace; export default PreviewWorkspace;
+8 -27
View File
@@ -1,18 +1,13 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { useAppShell } from '../appShellContext'; import { useAppShell } from '../appShellContext';
import PreviewWorkspace from '../preview/PreviewWorkspace';
const DocumentViewerRoute = () => { const DocumentViewerRoute = () => {
const { const {
previewWorkspaceDocument, previewWorkspaceDocument,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
ensurePreviewData, ensurePreviewData,
notifyApiError, notifyApiError,
resolveApiPath,
} = useAppShell(); } = useAppShell();
const { documentId } = useParams(); const { documentId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -44,29 +39,15 @@ const DocumentViewerRoute = () => {
}; };
}, [documentId, ensurePreviewData, notifyApiError, navigate]); }, [documentId, ensurePreviewData, notifyApiError, navigate]);
const isReady = if (!documentId) {
documentId && previewWorkspaceDocument && previewWorkspaceDocument.id === documentId; return <Navigate to="/documents" replace />;
if (!isReady) {
return (
<main className="preview-main">
<div className="preview-workspace__message">Loading preview</div>
</main>
);
} }
return ( if (!previewWorkspaceDocument || previewWorkspaceDocument.id !== documentId) {
<main className="preview-main"> return <div className="preview-workspace__message">Loading preview</div>;
<PreviewWorkspace }
document={previewWorkspaceDocument}
previewEntry={previewWorkspaceEntry} return <Navigate to="/documents" replace />;
resolveApiPath={resolveApiPath}
onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
</main>
);
}; };
export default DocumentViewerRoute; export default DocumentViewerRoute;
-7
View File
@@ -291,12 +291,6 @@ const Sidebar = ({
); );
const rootNode = folderNodes.get('root'); const rootNode = folderNodes.get('root');
const hintText = appStatus === 'bootstrapping' && loading
? 'Loading your library…'
: previewActive
? 'Viewing document preview. Press ← Back to return to the library.'
: 'Drag files here to upload.';
return ( return (
<aside className="sidebar"> <aside className="sidebar">
<div className="panel-header sidebar__header"> <div className="panel-header sidebar__header">
@@ -371,7 +365,6 @@ const Sidebar = ({
</div> </div>
</div> </div>
<div className="panel-body sidebar__body"> <div className="panel-body sidebar__body">
<span className="sidebar__hint">{hintText}</span>
{status && ( {status && (
<div className="sidebar__status"> <div className="sidebar__status">
<div className={`status-banner ${status.variant}`}>{status.message}</div> <div className={`status-banner ${status.variant}`}>{status.message}</div>
-5
View File
@@ -1188,11 +1188,6 @@ button.danger:hover:not([disabled]) {
background: rgba(209, 67, 67, 0.12); background: rgba(209, 67, 67, 0.12);
} }
.sidebar__hint {
color: var(--muted);
font-size: 0.9rem;
}
.sidebar__search { .sidebar__search {
display: flex; display: flex;
align-items: center; align-items: center;
+10
View File
@@ -20,6 +20,7 @@ import {
IconMinusVertical, IconMinusVertical,
IconLogout, IconLogout,
IconChevronDown, IconChevronDown,
IconX,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import FolderSvg from '../assets/folder.svg'; import FolderSvg from '../assets/folder.svg';
@@ -185,6 +186,15 @@ export const MinusVerticalIcon = ({ className, size = '1em', stroke = 1.6, ...re
/> />
); );
export const CloseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconX
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconAnalyze <IconAnalyze
className={composeClassName('icon', className)} className={composeClassName('icon', className)}