diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 518fed7..4bd889a 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -26,6 +26,7 @@ import './styles.css'; import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from './asset_manager'; import useApiError from './hooks/useApiError'; import SkeuomorphicWorkspace from './skeuomorphic_ws'; +import PreviewWorkspace from './preview/PreviewWorkspace'; import DetailPanel from './detail/DetailPanel'; import TagsPanel from './tags/TagsPanel'; import CorrespondentsPanel from './correspondents/CorrespondentsPanel'; @@ -41,10 +42,13 @@ import { RefreshIcon, ArrowUpIcon, MinusVerticalIcon, + DownloadIcon, + TextScanIcon, + AnalyzeIcon, + CloseIcon, } from './ui/icons'; import DocumentsTable from './documents/DocumentsTable'; import { AppShellContext, useAppShell } from './appShellContext'; -import DocumentViewerRoute from './routes/DocumentViewerRoute'; const runtimeApiBase = typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL @@ -3624,6 +3628,26 @@ const AppLayout = () => { return () => window.removeEventListener('keydown', handleKeyDown); }, [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( async (documentId, nextTitle) => { const trimmed = nextTitle.trim(); @@ -5188,6 +5212,8 @@ const AppLayout = () => { exitSkeuoWorkspace, skeuoWorkspaceProps, ensurePreviewData, + ensureAssetUrl, + getDocumentAsset, notifyApiError, resolveApiPath, openTagsModal, @@ -5226,6 +5252,8 @@ const AppLayout = () => { exitSkeuoWorkspace, skeuoWorkspaceProps, ensurePreviewData, + ensureAssetUrl, + getDocumentAsset, notifyApiError, resolveApiPath, openTagsModal, @@ -5387,6 +5415,14 @@ const DocumentsRoute = () => { openTagsModal, openCorrespondentsModal, exitSkeuoWorkspace, + previewWorkspaceDocument, + previewWorkspaceEntry, + closeDocumentPreview, + handleThumbnailRegeneration, + resolveApiPath, + ensureAssetUrl, + getDocumentAsset, + notifyApiError, } = useAppShell(); const navigate = useNavigate(); @@ -5416,12 +5452,24 @@ const DocumentsRoute = () => { breadcrumbs, } = documentsTableProps; + const isWorkspace = workspaceMode === 'skeuo'; + const isPreviewWorkspace = Boolean(previewWorkspaceDocument); + const showPreviewWorkspace = !isWorkspace && isPreviewWorkspace; const isGridView = viewMode === 'grid'; const showingSearchResults = Array.isArray(searchResults); - const headerTitle = showingSearchResults ? 'Search results' : currentFolderName; - const headerSubtitle = showingSearchResults - ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` - : null; + let headerTitle; + let headerSubtitle = null; + if (showPreviewWorkspace) { + 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 handleNavigateParent = parentBreadcrumb ? () => { @@ -5432,77 +5480,85 @@ const DocumentsRoute = () => { } : null; - const detailHasContent = detailPanelProps.selectedDocuments?.length > 0; + const detailHasContent = !isWorkspace && !showPreviewWorkspace + && detailPanelProps.selectedDocuments?.length > 0; const toggleSidebar = sidebarCollapsed ? expandSidebar : collapseSidebar; const ToggleIcon = sidebarCollapsed ? ChevronsRightIcon : ChevronsLeftIcon; 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') { - return ( - -
-
-
- {sidebarCollapsed ? ( - - ) : null} - {parentBreadcrumb ? ( - - ) : null} -

- {headerTitle} - {headerSubtitle ? ( - {headerSubtitle} - ) : null} -

-
- - -
-
-
- -
-
- - ); - } + const bodyClass = (() => { + if (isWorkspace) { + return 'main-content__body main-content__body--workspace'; + } + if (showPreviewWorkspace) { + return 'main-content__body main-content__body--preview'; + } + return `main-content__body main-content__body--documents${ + detailHasContent ? ' main-content__body--has-detail' : '' + }`; + })(); + + const previewDownloadHref = useMemo(() => { + if (!previewWorkspaceDocument?.current_version?.download_path) { + return null; + } + return resolveApiPath(previewWorkspaceDocument.current_version.download_path); + }, [previewWorkspaceDocument, resolveApiPath]); + + const previewHasOcr = useMemo( + () => Boolean( + previewWorkspaceDocument && getDocumentAsset(previewWorkspaceDocument, 'ocr-text'), + ), + [previewWorkspaceDocument, getDocumentAsset], + ); + + const handlePreviewOcr = useCallback(async () => { + if (!previewWorkspaceDocument) { + return; + } + const asset = getDocumentAsset(previewWorkspaceDocument, 'ocr-text'); + if (!asset) { + return; + } + try { + const ensured = await ensureAssetUrl(previewWorkspaceDocument.id, asset, { force: false }); + const entry = ensured || asset; + const url = entry?.url + || resolveDocumentAssetUrl(previewWorkspaceDocument, 'ocr-text', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }); + if (!url) { + throw new Error('OCR text URL unavailable.'); + } + window.open(url, '_blank', 'noopener,noreferrer'); + } catch (error) { + notifyApiError(error, 'Unable to open OCR text.'); + } + }, [ + previewWorkspaceDocument, + ensureAssetUrl, + getDocumentAsset, + notifyApiError, + ]); return ( -
+
{sidebarCollapsed ? ( @@ -5516,7 +5572,7 @@ const DocumentsRoute = () => { ) : null} - {parentBreadcrumb ? ( + {parentBreadcrumb && !showPreviewWorkspace ? ( - -
- - - - + {isWorkspace ? ( + <> + + + + ) : showPreviewWorkspace ? ( + <> + {previewDownloadHref ? ( + + + + ) : null} + {previewHasOcr ? ( + + ) : null} + + + + ) : ( + <> +
+ + +
+ + + + + + )}
-
- +
+ {isWorkspace ? ( + + ) : showPreviewWorkspace ? ( + + ) : ( + + )}
- {detailHasContent ? ( + {!isWorkspace && !showPreviewWorkspace && detailHasContent ? ( ) : null}
@@ -5739,7 +5865,7 @@ const AppRouter = () => ( } /> } /> } /> - } /> + } /> } /> diff --git a/frontend/src/preview/PreviewWorkspace.jsx b/frontend/src/preview/PreviewWorkspace.jsx index 19b16fc..e7cbf8f 100644 --- a/frontend/src/preview/PreviewWorkspace.jsx +++ b/frontend/src/preview/PreviewWorkspace.jsx @@ -1,13 +1,9 @@ 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; @@ -15,9 +11,6 @@ const PreviewWorkspace = ({ 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 = @@ -25,48 +18,6 @@ const PreviewWorkspace = ({ return (
-
-
- -
-

{title}

- - {document.content_type || mime} - {sizeLabel ? ` · ${sizeLabel}` : ''} - -
-
-
- { - if (!downloadHref) { - event.preventDefault(); - } - }} - > - - Download - - -
-
{!previewEntry?.url ? (
Loading preview…
@@ -78,15 +29,16 @@ const PreviewWorkspace = ({ /> )}
- {metadata && ( + {metadata ? (

Metadata

+ {sizeLabel ?

Size: {sizeLabel}

: null} +

Type: {mime}

{JSON.stringify(metadata, null, 2)}
- )} + ) : null}
); }; export default PreviewWorkspace; - diff --git a/frontend/src/routes/DocumentViewerRoute.jsx b/frontend/src/routes/DocumentViewerRoute.jsx index b475a7a..131dc56 100644 --- a/frontend/src/routes/DocumentViewerRoute.jsx +++ b/frontend/src/routes/DocumentViewerRoute.jsx @@ -1,18 +1,13 @@ 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 PreviewWorkspace from '../preview/PreviewWorkspace'; const DocumentViewerRoute = () => { const { previewWorkspaceDocument, - previewWorkspaceEntry, - closeDocumentPreview, - handleThumbnailRegeneration, ensurePreviewData, notifyApiError, - resolveApiPath, } = useAppShell(); const { documentId } = useParams(); const navigate = useNavigate(); @@ -44,29 +39,15 @@ const DocumentViewerRoute = () => { }; }, [documentId, ensurePreviewData, notifyApiError, navigate]); - const isReady = - documentId && previewWorkspaceDocument && previewWorkspaceDocument.id === documentId; - - if (!isReady) { - return ( -
-
Loading preview…
-
- ); + if (!documentId) { + return ; } - return ( -
- -
- ); + if (!previewWorkspaceDocument || previewWorkspaceDocument.id !== documentId) { + return
Loading preview…
; + } + + return ; }; export default DocumentViewerRoute; - diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx index 240ca78..8da1924 100644 --- a/frontend/src/sidebar/Sidebar.jsx +++ b/frontend/src/sidebar/Sidebar.jsx @@ -291,12 +291,6 @@ const Sidebar = ({ ); 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 (
- {hintText} {status && (
{status.message}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 0a0f6d2..8ea7d63 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1188,11 +1188,6 @@ button.danger:hover:not([disabled]) { background: rgba(209, 67, 67, 0.12); } -.sidebar__hint { - color: var(--muted); - font-size: 0.9rem; -} - .sidebar__search { display: flex; align-items: center; diff --git a/frontend/src/ui/icons.js b/frontend/src/ui/icons.js index 0ad4606..d5b123b 100644 --- a/frontend/src/ui/icons.js +++ b/frontend/src/ui/icons.js @@ -20,6 +20,7 @@ import { IconMinusVertical, IconLogout, IconChevronDown, + IconX, } from '@tabler/icons-react'; 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 }) => ( + +); + export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (