diff --git a/frontend/package-lock.json b/frontend/package-lock.json index aeb6da3..5e35cb5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "papercrate-frontend", "version": "0.1.0", "dependencies": { + "@fontsource/inter": "^5.2.8", "@tabler/icons-react": "3.11.0", "axios": "1.7.7", "react": "18.3.1", @@ -1852,6 +1853,15 @@ "node": ">=10.0.0" } }, + "node_modules/@fontsource/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5de499f..a3f0da4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "lint": "echo \"No linting configured\"" }, "dependencies": { + "@fontsource/inter": "^5.2.8", "@tabler/icons-react": "3.11.0", "axios": "1.7.7", "react": "18.3.1", @@ -19,6 +20,7 @@ "@babel/core": "7.26.0", "@babel/preset-env": "7.26.0", "@babel/preset-react": "7.26.3", + "@svgr/webpack": "8.1.0", "babel-loader": "9.2.1", "css-loader": "7.1.2", "dotenv": "16.4.5", @@ -26,7 +28,6 @@ "style-loader": "4.0.0", "webpack": "5.95.0", "webpack-cli": "5.1.4", - "webpack-dev-server": "5.1.0", - "@svgr/webpack": "8.1.0" + "webpack-dev-server": "5.1.0" } } diff --git a/frontend/src/correspondents/CorrespondentsPanel.jsx b/frontend/src/correspondents/CorrespondentsPanel.jsx index 25f156a..b1ad5c8 100644 --- a/frontend/src/correspondents/CorrespondentsPanel.jsx +++ b/frontend/src/correspondents/CorrespondentsPanel.jsx @@ -118,11 +118,11 @@ function CorrespondentsPanel({ }, []); return ( -
-
-
+
+
+

Correspondents

-
{correspondents.length} total
+
{correspondents.length} total
@@ -147,7 +147,7 @@ function CorrespondentsPanel({
-
+
{correspondents.length === 0 ? (
No correspondents created yet.
) : ( diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index c6c5589..d5bcda6 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -1,11 +1,20 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { DownloadIcon, EditIcon, ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + DownloadIcon, + EditIcon, + ArrowLeftIcon, + ArrowRightIcon, + ChevronsRightIcon, + AnalyzeIcon, + WindowMaximizeIcon, + TextScanIcon, +} from '../ui/icons'; import { getTagColorStyle } from '../utils/colors'; import { formatFileSize } from '../utils/format'; import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; import { useAssetNavigator } from '../hooks/useAssetNavigator'; import { CORRESPONDENT_ROLES } from '../constants/correspondents'; +import PreviewZoomOverlay from './PreviewZoomOverlay'; const MAX_PREVIEW_STACK_ITEMS = 15; @@ -208,6 +217,7 @@ const PreviewStack = ({ emptyMessage = 'Preview unavailable', onItemActivate, onOpenPreview, + onZoomPreview, activeItemId = null, }) => { if (!items.length) { @@ -240,7 +250,11 @@ const PreviewStack = ({ zIndex: preparedItems.length - index, transform, }} - aria-hidden={hasMultiple && !onItemActivate && !onOpenPreview ? 'true' : undefined} + aria-hidden={ + hasMultiple && !onItemActivate && !onOpenPreview && !onZoomPreview + ? 'true' + : undefined + } > { event.stopPropagation(); - if (isFront && onOpenPreview) { - onOpenPreview(entry.id); + if (isFront) { + if (onZoomPreview) { + onZoomPreview(entry); + } else if (onOpenPreview) { + onOpenPreview(entry.id); + } else if (onItemActivate) { + onItemActivate(entry.id); + } } else if (onItemActivate) { onItemActivate(entry.id); } }} onKeyDown={(event) => { - if (!onItemActivate && !onOpenPreview) return; + if (!onItemActivate && !onOpenPreview && !onZoomPreview) return; if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); - if (isFront && onOpenPreview) { - onOpenPreview(entry.id); + if (isFront) { + if (onZoomPreview) { + onZoomPreview(entry); + } else if (onOpenPreview) { + onOpenPreview(entry.id); + } else { + onItemActivate?.(entry.id); + } } else { onItemActivate?.(entry.id); } @@ -297,9 +323,24 @@ const DetailPanel = ({ onCorrespondentAdd, onCorrespondentRemove, resolveApiPath, + onClose = () => {}, }) => { const selectedCount = selectedDocuments.length; const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null; + const singleDocId = singleDoc?.id || null; + const selectionKey = useMemo( + () => selectedDocuments.map((doc) => doc?.id ?? '').join('|'), + [selectedDocuments], + ); + + const singleDownloadHref = useMemo(() => { + if (!singleDoc) return null; + const downloadPath = singleDoc.current_version?.download_path; + if (!downloadPath || !resolveApiPath) { + return null; + } + return resolveApiPath(downloadPath); + }, [singleDoc, resolveApiPath]); const [titleEditDocId, setTitleEditDocId] = useState(null); const [titleDraft, setTitleDraft] = useState(''); @@ -309,6 +350,7 @@ const DetailPanel = ({ const [ocrUrl, setOcrUrl] = useState(null); const [ocrLoading, setOcrLoading] = useState(false); const [ocrError, setOcrError] = useState(null); + const [zoomedPreview, setZoomedPreview] = useState(null); useEffect(() => { if (!singleDoc) { @@ -338,6 +380,10 @@ const DetailPanel = ({ setOcrUrl(null); }, [singleDoc?.id]); + useEffect(() => { + setZoomedPreview(null); + }, [selectionKey]); + const startTitleEdit = useCallback(() => { if (!singleDoc) return; setTitleEditDocId(singleDoc.id); @@ -500,6 +546,7 @@ const DetailPanel = ({ }, [selectedDocuments]); const stackTopDocument = stackDocuments[0] || null; + const stackTopDocId = stackTopDocument?.id || null; const stackPreviewNavigator = useAssetNavigator({ document: stackTopDocument, assetType: 'preview', @@ -699,15 +746,178 @@ const DetailPanel = ({ [bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments], ); + const openZoomPreview = useCallback((config) => { + if (!config) return; + setZoomedPreview({ + mode: config.mode, + docId: config.docId ?? null, + }); + }, []); + + const closeZoomPreview = useCallback(() => { + setZoomedPreview(null); + }, []); + + const handleSingleZoom = useCallback( + (entry) => { + if (!singleHasPreview) return; + const targetId = entry?.id ?? singleDocId; + if (!targetId) return; + openZoomPreview({ mode: 'single', docId: targetId }); + }, + [openZoomPreview, singleHasPreview, singleDocId], + ); + + const handleStackZoom = useCallback( + (entry) => { + if (!stackTopDocId || entry?.id !== stackTopDocId) return; + if (!topHasPreview) return; + openZoomPreview({ mode: 'stack', docId: stackTopDocId }); + }, + [openZoomPreview, stackTopDocId, topHasPreview], + ); + + const zoomDisplay = useMemo(() => { + if (!zoomedPreview) { + return null; + } + + if ( + zoomedPreview.mode === 'single' && + singleDocId && + singleDoc && + singleHasPreview && + zoomedPreview.docId === singleDocId + ) { + return { + url: singlePreviewNavigator.currentUrl, + alt: singleDoc.title || singleDoc.original_name || 'Document preview', + canGoPrev: + singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoPrev), + canGoNext: + singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoNext), + goPrev: singlePreviewNavigator.goPrev, + goNext: singlePreviewNavigator.goNext, + }; + } + + if ( + zoomedPreview.mode === 'stack' && + stackTopDocId && + stackTopDocument && + zoomedPreview.docId === stackTopDocId && + topHasPreview + ) { + return { + url: stackPreviewNavigator.currentUrl, + alt: stackTopDocument.title || stackTopDocument.original_name || 'Document preview', + canGoPrev: + topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoPrev), + canGoNext: + topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoNext), + goPrev: stackPreviewNavigator.goPrev, + goNext: stackPreviewNavigator.goNext, + }; + } + + return null; + }, [ + zoomedPreview, + singleDoc, + singleDocId, + singleHasPreview, + singlePreviewNavigator.currentUrl, + singlePreviewNavigator.canGoPrev, + singlePreviewNavigator.canGoNext, + singlePreviewNavigator.goPrev, + singlePreviewNavigator.goNext, + singleEffectiveCardinality, + stackTopDocument, + stackTopDocId, + topHasPreview, + stackPreviewNavigator.currentUrl, + stackPreviewNavigator.canGoPrev, + stackPreviewNavigator.canGoNext, + stackPreviewNavigator.goPrev, + stackPreviewNavigator.goNext, + topEffectiveCardinality, + ]); + + useEffect(() => { + if (zoomedPreview && !zoomDisplay) { + setZoomedPreview(null); + } + }, [zoomedPreview, zoomDisplay]); + + useEffect(() => { + if (typeof ensureAssetUrl !== 'function') { + return; + } + + const warmNavigator = (navigator) => { + const { + documentId, + asset, + ordinal, + canGoPrev, + canGoNext, + cardinality, + } = navigator; + if (!documentId || !asset || !Number.isFinite(ordinal)) { + return; + } + + const requests = []; + if (canGoPrev) { + const prevOrdinal = Math.max(1, ordinal - 1); + if (!cardinality || prevOrdinal <= cardinality) { + requests.push( + ensureAssetUrl(documentId, asset, { + start: prevOrdinal, + limit: 1, + objectOrdinal: prevOrdinal, + }), + ); + } + } + if (canGoNext) { + const nextOrdinal = ordinal + 1; + if (!cardinality || nextOrdinal <= cardinality) { + requests.push( + ensureAssetUrl(documentId, asset, { + start: nextOrdinal, + limit: 1, + objectOrdinal: nextOrdinal, + }), + ); + } + } + + requests.forEach((promise) => promise?.catch?.(() => {})); + }; + + warmNavigator(singlePreviewNavigator); + warmNavigator(stackPreviewNavigator); + }, [ + ensureAssetUrl, + singlePreviewNavigator.documentId, + singlePreviewNavigator.asset, + singlePreviewNavigator.ordinal, + singlePreviewNavigator.canGoPrev, + singlePreviewNavigator.canGoNext, + stackPreviewNavigator.documentId, + stackPreviewNavigator.asset, + stackPreviewNavigator.ordinal, + stackPreviewNavigator.canGoPrev, + stackPreviewNavigator.canGoNext, + ]); + const renderSingle = () => { if (!singleDoc) { return

Select a document to view metadata, tags and actions.

; } const displayName = singleDoc.title || singleDoc.original_name; - const downloadHref = singleDoc.current_version?.download_path - ? resolveApiPath?.(singleDoc.current_version.download_path) - : null; const isEditingTitle = titleEditDocId === singleDoc.id; const sizeBytes = Number(singleDoc.current_version?.size_bytes) || 0; const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : '—'; @@ -743,6 +953,7 @@ const DetailPanel = ({ emptyMessage="Preview loading…" onItemActivate={handlePreviewActivate} onOpenPreview={onOpenPreview} + onZoomPreview={handleSingleZoom} activeItemId={activePreviewId} /> {hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? ( @@ -857,44 +1068,6 @@ const DetailPanel = ({ {singleDoc.original_name}
-
- { - if (!downloadHref) { - event.preventDefault(); - } - }} - > - - Download - - - -
- {hasOcrAsset ? ( -
- -
- ) : null} ({ @@ -931,7 +1104,7 @@ const DetailPanel = ({ {metadata && (
Metadata
-
{JSON.stringify(metadata, null, 2)}
+
{JSON.stringify(metadata, null, 2)}
)} {hasOcrAsset && ocrOpen @@ -1002,6 +1175,7 @@ const DetailPanel = ({ emptyMessage="No previews available." onItemActivate={handlePreviewActivate} onOpenPreview={onOpenPreview} + onZoomPreview={handleStackZoom} activeItemId={activePreviewId} /> {topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? ( @@ -1074,23 +1248,110 @@ const DetailPanel = ({ showCount className="bulk-correspondents" /> - ); }; + const isBulkSelection = selectedCount > 1; + const showOcrAction = Boolean(singleDoc && hasOcrAsset); + return ( - + + ); }; diff --git a/frontend/src/detail/PreviewZoomOverlay.jsx b/frontend/src/detail/PreviewZoomOverlay.jsx new file mode 100644 index 0000000..d07b23a --- /dev/null +++ b/frontend/src/detail/PreviewZoomOverlay.jsx @@ -0,0 +1,262 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; + +const noop = () => {}; + +const clamp = (value, min, max) => { + if (value < min) return min; + if (value > max) return max; + return value; +}; + +const ensureDocumentRoot = () => { + if (typeof document === 'undefined') { + return null; + } + return document.body; +}; + +const PreviewZoomOverlay = ({ + open = false, + display = null, + onClose = noop, +}) => { + const portalTarget = ensureDocumentRoot(); + const [isNativeScale, setIsNativeScale] = useState(false); + const [naturalSize, setNaturalSize] = useState({ width: null, height: null }); + const scrollRef = useRef(null); + const imageRef = useRef(null); + const focusRef = useRef(null); + const previouslyFocusedRef = useRef(null); + + useEffect(() => { + setIsNativeScale(false); + setNaturalSize({ width: null, height: null }); + focusRef.current = null; + const scrollEl = scrollRef.current; + if (scrollEl) { + scrollEl.scrollLeft = 0; + scrollEl.scrollTop = 0; + } + }, [open]); + + useEffect(() => { + if (!open || !isNativeScale) { + return; + } + + const scrollEl = scrollRef.current; + const imageEl = imageRef.current; + if (!scrollEl || !imageEl) { + return; + } + + const imageWidth = imageEl.naturalWidth || imageEl.clientWidth; + const imageHeight = imageEl.naturalHeight || imageEl.clientHeight; + if (!(imageWidth > 0 && imageHeight > 0)) { + return; + } + + const target = focusRef.current || { xRatio: 0.5, yRatio: 0.5 }; + const maxScrollLeft = Math.max(0, imageWidth - scrollEl.clientWidth); + const maxScrollTop = Math.max(0, imageHeight - scrollEl.clientHeight); + + const desiredLeft = target.xRatio * imageWidth - scrollEl.clientWidth / 2; + const desiredTop = target.yRatio * imageHeight - scrollEl.clientHeight / 2; + + scrollEl.scrollLeft = clamp(desiredLeft, 0, maxScrollLeft); + scrollEl.scrollTop = clamp(desiredTop, 0, maxScrollTop); + }, [open, isNativeScale, naturalSize.width, naturalSize.height]); + + useEffect(() => { + if (!open) { + if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') { + previouslyFocusedRef.current.focus(); + } + previouslyFocusedRef.current = null; + return undefined; + } + + if (typeof document !== 'undefined') { + const active = document.activeElement; + if (active && typeof active.focus === 'function') { + previouslyFocusedRef.current = active; + } else { + previouslyFocusedRef.current = null; + } + } + + const scrollEl = scrollRef.current; + if (!scrollEl) { + return undefined; + } + + const frame = requestAnimationFrame(() => { + scrollEl.focus(); + }); + + return () => { + cancelAnimationFrame(frame); + if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') { + previouslyFocusedRef.current.focus(); + previouslyFocusedRef.current = null; + } + }; + }, [open]); + + const handleKeyDown = (event) => { + event.stopPropagation(); + + if (!open) { + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + return; + } + + if (event.key === 'ArrowLeft') { + if (display?.canGoPrev && display?.goPrev) { + event.preventDefault(); + display.goPrev(); + } + return; + } + + if (event.key === 'ArrowRight') { + if (display?.canGoNext && display?.goNext) { + event.preventDefault(); + display.goNext(); + } + } + }; + + if (!open || !display?.url || !portalTarget) { + return null; + } + + const navVisible = Boolean(display?.canGoPrev || display?.canGoNext); + const stageClassName = [ + 'preview-zoom__stage', + ] + .filter(Boolean) + .join(' '); + + const containerClassName = [ + 'preview-zoom__scroll', + isNativeScale ? 'preview-zoom__scroll--native' : '', + ] + .filter(Boolean) + .join(' '); + + const imageStyle = isNativeScale + ? { + cursor: 'zoom-out', + width: naturalSize.width ? `${naturalSize.width}px` : 'auto', + height: naturalSize.height ? `${naturalSize.height}px` : 'auto', + maxWidth: 'none', + maxHeight: 'none', + } + : { + cursor: 'zoom-in', + maxWidth: '95vw', + maxHeight: '95vh', + }; + + return createPortal( + ( +
+
event.stopPropagation()} + onKeyDown={handleKeyDown} + > +
+ {display.alt { + setNaturalSize({ + width: event.currentTarget.naturalWidth || null, + height: event.currentTarget.naturalHeight || null, + }); + }} + onClick={(event) => { + event.stopPropagation(); + if (!isNativeScale) { + const img = imageRef.current; + if (img) { + const rect = img.getBoundingClientRect(); + const xRatio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5; + const yRatio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5; + focusRef.current = { + xRatio: clamp(xRatio, 0, 1), + yRatio: clamp(yRatio, 0, 1), + }; + } else { + focusRef.current = null; + } + } else { + focusRef.current = null; + } + setIsNativeScale((current) => !current); + }} + style={imageStyle} + /> +
+ {navVisible ? ( +
+ + +
+ ) : null} +
+
+ ), + portalTarget, + ); +}; + +export default PreviewZoomOverlay; diff --git a/frontend/src/documents/DocumentsTable.jsx b/frontend/src/documents/DocumentsTable.jsx index c2cc35b..780e22a 100644 --- a/frontend/src/documents/DocumentsTable.jsx +++ b/frontend/src/documents/DocumentsTable.jsx @@ -1,7 +1,7 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; import { getTagColorStyle } from '../utils/colors'; -import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon } from '../ui/icons'; +import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon, TrashIcon } from '../ui/icons'; const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; const DEFAULT_GRID_ICON_SIZE = 96; @@ -13,13 +13,62 @@ const getPageCount = (doc) => ? doc.current_version.metadata.page_count : null; +// Detects when an element becomes visible within a scroll container. +const useLazyVisibility = (rootRef, resetKey) => { + const targetRef = useRef(null); + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + setIsVisible(false); + }, [resetKey]); + + const rootNode = rootRef?.current || null; + + useEffect(() => { + if (isVisible) { + return undefined; + } + const element = targetRef.current; + if (!element) { + return undefined; + } + if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') { + setIsVisible(true); + return undefined; + } + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setIsVisible(true); + observer.disconnect(); + } + }); + }, + { + root: rootNode, + rootMargin: '200px 0px', + threshold: 0.01, + }, + ); + + observer.observe(element); + return () => observer.disconnect(); + }, [isVisible, rootNode, resetKey]); + + return { ref: targetRef, isVisible }; +}; + const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt, maxSize = LIST_ICON_SIZE, + scrollRootRef = null, }) => { + const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, document?.id); const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1)); const thumbnailAsset = useMemo( () => getAssetFromVersion(document?.current_version, 'thumbnail'), @@ -45,14 +94,15 @@ const DocumentThumbnailImage = ({ () => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }), [dimensions.height, dimensions.width], ); - const url = useMemo( - () => - resolveDocumentAssetUrl(document, 'thumbnail', { - ensureAssetUrl, - getAsset: getDocumentAsset, - }), - [document, ensureAssetUrl, getDocumentAsset], - ); + const url = useMemo(() => { + if (!isVisible) { + return null; + } + return resolveDocumentAssetUrl(document, 'thumbnail', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }); + }, [document, ensureAssetUrl, getDocumentAsset, isVisible]); const pageCount = getPageCount(document); const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1; @@ -62,13 +112,15 @@ const DocumentThumbnailImage = ({ } return ( -
+
{url ? ( {alt event.preventDefault()} /> @@ -125,6 +177,7 @@ const DocumentsTable = ({ viewMode = 'list', onViewModeChange, onClearSelection, + showHeader = true, }) => { const showingSearchResults = searchResults !== null; const rows = showingSearchResults ? searchResults : documents; @@ -142,6 +195,18 @@ const DocumentsTable = ({ [draggingDocumentIds], ); const scrollRef = useRef(null); + const [, forceVisibilityTick] = useState(0); + const lastScrollNodeRef = useRef(null); + const assignScrollRef = useCallback((node) => { + if (lastScrollNodeRef.current === node) { + return; + } + lastScrollNodeRef.current = node; + scrollRef.current = node; + if (node) { + forceVisibilityTick((value) => value + 1); + } + }, []); const isGridView = viewMode === 'grid'; const gridIconSize = DEFAULT_GRID_ICON_SIZE; const handleSetViewMode = useCallback( @@ -156,6 +221,11 @@ const DocumentsTable = ({ }, [onViewModeChange], ); + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + }, [viewMode]); const isTagDragEvent = useCallback((event) => { const types = Array.from(event.dataTransfer?.types || []); return TAG_MIME_TYPES.some((type) => types.includes(type)); @@ -263,16 +333,6 @@ const DocumentsTable = ({ [isTagDragEvent, onDocumentTagDrop], ); - const handleGridBackgroundClick = useCallback( - (event) => { - if (event.target !== event.currentTarget) { - return; - } - onClearSelection?.(); - }, - [onClearSelection], - ); - const showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0; const showListSearchEmptyState = showingSearchResults && rows.length === 0 && !isGridView && !isSearchLoading; @@ -282,73 +342,53 @@ const DocumentsTable = ({ return (
-
-
- - {showingSearchResults && ( -
Search results
- )} -
-
-
+ {showHeader ? ( +
+
+

{currentFolderName}

+ {showingSearchResults && ( +
Search results
+ )} +
+
+
+ + +
- +
- - -
-
+ ) : null} {showDefaultEmptyState && (
Drop files anywhere or onto a folder to upload documents. @@ -359,9 +399,9 @@ const DocumentsTable = ({ No documents match the current filters.
)} -
+
{ if (event.target === scrollRef.current) { @@ -376,13 +416,22 @@ const DocumentsTable = ({ onDocumentListKeyDown(event); } }} + onClick={(event) => { + if (event.target === event.currentTarget) { + onClearSelection?.(); + } + }} aria-activedescendant={isGridView ? undefined : activeDescendantId} > {showDefaultEmptyState ? null : isGridView ? (
{ + if (event.target === event.currentTarget) { + onClearSelection?.(); + } + }} style={{ '--documents-grid-icon-size': `${gridIconSize}px` }} > {!showingSearchResults && @@ -477,6 +526,7 @@ const DocumentsTable = ({ getDocumentAsset={getDocumentAsset} alt={`Thumbnail for ${doc.title || doc.original_name}`} maxSize={gridIconSize} + scrollRootRef={scrollRef} />
{folder.name} - {folder.id !== 'root' && ( +
+ + Folder + — + +
+ {folder.id !== 'root' && onFolderRename && ( )} +
- Folder - — - - - ); })} @@ -690,36 +743,13 @@ const DocumentsTable = ({ ensureAssetUrl={ensureAssetUrl} getDocumentAsset={getDocumentAsset} alt={`Thumbnail for ${doc.title || doc.original_name}`} + scrollRootRef={scrollRef} />
{doc.title || doc.original_name} -
{(doc.tags || []).length > 0 && (
@@ -788,31 +818,59 @@ const DocumentsTable = ({
+ {onDocumentRename && ( + + )} {downloadHref ? ( event.stopPropagation()} onAuxClick={(event) => event.stopPropagation()} onContextMenu={(event) => event.stopPropagation()} > - Download ) : ( No download )}
diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 8f878da..ad0e5d3 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -1,3 +1,5 @@ +import '@fontsource/inter/400.css'; + import React, { useCallback, useContext, @@ -30,6 +32,16 @@ import CorrespondentsPanel from './correspondents/CorrespondentsPanel'; import { CORRESPONDENT_ROLES } from './constants/correspondents'; import TagManager from './tag_manager'; import Sidebar from './sidebar/Sidebar'; +import { + ChevronsRightIcon, + ChevronsLeftIcon, + ViewListIcon, + ViewGridIcon, + FolderPlusIcon, + RefreshIcon, + ArrowUpIcon, + MinusVerticalIcon, +} from './ui/icons'; import DocumentsTable from './documents/DocumentsTable'; import { AppShellContext, useAppShell } from './appShellContext'; import DocumentViewerRoute from './routes/DocumentViewerRoute'; @@ -309,9 +321,9 @@ const LoginView = ({ -const DocumentsLayout = ({ sidebarProps, children }) => ( -
- +const DocumentsLayout = ({ sidebarProps, children, sidebarCollapsed }) => ( +
+ {!sidebarCollapsed ? : null} {children}
); @@ -335,6 +347,9 @@ const AppLayout = () => { ({ message, variant }) => setStatusMessage(message, variant), [setStatusMessage], ); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const collapseSidebar = useCallback(() => setSidebarCollapsed(true), []); + const expandSidebar = useCallback(() => setSidebarCollapsed(false), []); const reportApiError = useApiError({ onReport: handleApiReport, }); @@ -345,6 +360,8 @@ const AppLayout = () => { ); const [loading, setLoading] = useState(false); const [isCreateFolderModalOpen, setCreateFolderModalOpen] = useState(false); + const [isTagsModalOpen, setTagsModalOpen] = useState(false); + const [isCorrespondentsModalOpen, setCorrespondentsModalOpen] = useState(false); const [newFolderName, setNewFolderName] = useState(''); const [createFolderError, setCreateFolderError] = useState(''); const [creatingFolder, setCreatingFolder] = useState(false); @@ -366,15 +383,11 @@ const AppLayout = () => { const stored = window.localStorage.getItem('papercrate_view_mode'); return stored === 'grid' ? 'grid' : 'list'; }); - const initialRowSelection = routeDocumentId - ? [resolveDocumentRowKey(routeDocumentId)] - : []; + const initialRowSelection = []; const [selectedRowKeys, setSelectedRowKeys] = useState(initialRowSelection); const [selectionOrder, setSelectionOrder] = useState(initialRowSelection); - const [focusedDocumentId, setFocusedDocumentId] = useState(routeDocumentId); - const [focusedRowKey, setFocusedRowKey] = useState(() => - routeDocumentId ? resolveDocumentRowKey(routeDocumentId) : null, - ); + const [focusedDocumentId, setFocusedDocumentId] = useState(null); + const [focusedRowKey, setFocusedRowKey] = useState(null); const tokenRef = useRef(token); const refreshPromiseRef = useRef(null); const breadcrumbFetchRef = useRef(new Set()); @@ -423,13 +436,9 @@ const AppLayout = () => { const documentsRouteMatch = useMatch('/documents'); const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId'); const documentsDetailRouteMatch = useMatch('/documents/:documentId'); - const tagsRouteMatch = useMatch('/tags'); - const correspondentsRouteMatch = useMatch('/correspondents'); const isDocumentsRoute = Boolean( documentsRouteMatch || documentsFolderRouteMatch || documentsDetailRouteMatch, ); - const isTagsRoute = Boolean(tagsRouteMatch); - const isCorrespondentsRoute = Boolean(correspondentsRouteMatch); const toggleTagFilter = useCallback((tagId) => { if (!tagId) return; setActiveTagFilters((previous) => @@ -455,6 +464,10 @@ const AppLayout = () => { setSearchLoading(false); }, []); + const handleSearchChange = useCallback((value) => { + setSearchQuery(value); + }, []); + const handleSearchSubmit = useCallback(() => { if (!navigate) return; const targetFolder = selectedFolder && selectedFolder !== 'root' ? selectedFolder : 'root'; @@ -814,14 +827,7 @@ const AppLayout = () => { if (selectionInitializedRef.current) { nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key)); } else { - const filtered = previousDocKeys.filter((key) => availableDocKeySet.has(key)); - if (filtered.length) { - nextDocKeys = filtered; - } else if (availableDocKeys.length) { - nextDocKeys = [availableDocKeys[0]]; - } else { - nextDocKeys = []; - } + nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key)); } mergedSelection = [...previousFolderKeys, ...nextDocKeys]; @@ -3682,6 +3688,24 @@ const AppLayout = () => { setCreateFolderError(''); }, [creatingFolder]); + const openTagsModal = useCallback(() => { + setCorrespondentsModalOpen(false); + setTagsModalOpen(true); + }, []); + + const closeTagsModal = useCallback(() => { + setTagsModalOpen(false); + }, []); + + const openCorrespondentsModal = useCallback(() => { + setTagsModalOpen(false); + setCorrespondentsModalOpen(true); + }, []); + + const closeCorrespondentsModal = useCallback(() => { + setCorrespondentsModalOpen(false); + }, []); + const handleCreateFolderSubmit = useCallback( async (event) => { event.preventDefault(); @@ -3742,6 +3766,31 @@ const AppLayout = () => { }; }, [isCreateFolderModalOpen, closeCreateFolderModal]); + useEffect(() => { + setTagsModalOpen(false); + setCorrespondentsModalOpen(false); + }, [location.pathname]); + + useEffect(() => { + if (!isTagsModalOpen && !isCorrespondentsModalOpen) { + return; + } + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + if (isTagsModalOpen) { + closeTagsModal(); + } else if (isCorrespondentsModalOpen) { + closeCorrespondentsModal(); + } + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isTagsModalOpen, isCorrespondentsModalOpen, closeTagsModal, closeCorrespondentsModal]); + useEffect(() => { if (!token) return undefined; @@ -4442,6 +4491,17 @@ const AppLayout = () => { correspondents, activeCorrespondentIds: activeCorrespondentFilters, onToggleCorrespondentFilter: toggleCorrespondentFilter, + appStatus, + loading, + previewActive, + searchQuery, + onSearchChange: handleSearchChange, + onSearchSubmit: handleSearchSubmit, + onSearchClear: clearFilters, + isFilterActive, + onLogout: handleLogout, + status, + onCollapse: collapseSidebar, }; const resolveThumbnailUrlForDoc = useCallback( @@ -4538,6 +4598,7 @@ const AppLayout = () => { onCorrespondentAdd: handleCorrespondentAdd, onCorrespondentRemove: handleCorrespondentRemove, resolveApiPath, + onClose: clearDocumentSelection, }; const skeuoWorkspaceProps = useMemo( @@ -4585,7 +4646,6 @@ const AppLayout = () => { tags, refreshTags, handleTagUpdate, - handleTagCreate, handleTagDelete, handleDocumentTagAttach, correspondents, @@ -4610,6 +4670,8 @@ const AppLayout = () => { ensurePreviewData, notifyApiError, resolveApiPath, + openTagsModal, + openCorrespondentsModal, }), [ token, @@ -4622,7 +4684,6 @@ const AppLayout = () => { tags, refreshTags, handleTagUpdate, - handleTagCreate, handleTagDelete, handleDocumentTagAttach, correspondents, @@ -4647,15 +4708,22 @@ const AppLayout = () => { ensurePreviewData, notifyApiError, resolveApiPath, + openTagsModal, + openCorrespondentsModal, ], ); if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { + const shouldRememberLastLocation = appStatus !== 'logged-out'; return ( ); } @@ -4667,81 +4735,6 @@ const AppLayout = () => { active={dropOverlayState.active} folderName={dropOverlayState.folderName} /> -
-
-
-

Papercrate

- - {appStatus === 'bootstrapping' && loading - ? 'Loading your library…' - : previewActive - ? 'Viewing document preview. Press ← Back to return to the library.' - : 'Drag files here to upload.'} - -
-
- setSearchQuery(event.target.value)} - placeholder="Search documents" - aria-label="Search documents" - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - handleSearchSubmit(); - } - }} - /> - {isFilterActive && ( - - )} -
-
-
- - - -
- {status && ( -
- -
- )} -
- -
-
-
-
{isCreateFolderModalOpen && (
{
)} + {isTagsModalOpen && ( +
+
event.stopPropagation()} + > +
+

Manage Tags

+ +
+
+ +
+
+
+ )} + {isCorrespondentsModalOpen && ( +
+
event.stopPropagation()} + > +
+

Manage Correspondents

+ +
+
+ +
+
+
+ )}
); @@ -4803,20 +4864,212 @@ const DocumentsRoute = () => { detailPanelProps, workspaceMode, skeuoWorkspaceProps, + openTagsModal, + openCorrespondentsModal, + exitSkeuoWorkspace, } = useAppShell(); + const navigate = useNavigate(); + + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const collapseSidebar = useCallback(() => setSidebarCollapsed(true), []); + const expandSidebar = useCallback(() => setSidebarCollapsed(false), []); + + const sidebarPropsWithActions = useMemo( + () => ({ + ...sidebarProps, + onManageTags: openTagsModal, + onManageCorrespondents: openCorrespondentsModal, + onCollapse: collapseSidebar, + }), + [sidebarProps, openTagsModal, openCorrespondentsModal, collapseSidebar], + ); + + const { + currentFolderName, + viewMode, + onViewModeChange, + onRequestCreateFolder, + creatingFolder, + onRefresh, + onShowSkeuoWorkspace, + searchResults, + breadcrumbs, + } = documentsTableProps; + + 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; + const parentBreadcrumb = breadcrumbs && breadcrumbs.length > 1 ? breadcrumbs[breadcrumbs.length - 2] : null; + const handleNavigateParent = parentBreadcrumb + ? () => { + const target = parentBreadcrumb.id === 'root' + ? '/documents' + : `/documents/folder/${parentBreadcrumb.id}`; + navigate(target); + } + : null; + + const detailHasContent = detailPanelProps.selectedDocuments?.length > 0; + const toggleSidebar = sidebarCollapsed ? expandSidebar : collapseSidebar; + const ToggleIcon = sidebarCollapsed ? ChevronsRightIcon : ChevronsLeftIcon; + const toggleLabel = sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'; if (workspaceMode === 'skeuo') { return ( - - + +
+
+
+ {sidebarCollapsed ? ( + + ) : null} + {parentBreadcrumb ? ( + + ) : null} +

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

+
+ + +
+
+
+ +
+
); } return ( - - - + +
+
+
+ {sidebarCollapsed ? ( + + ) : null} + {parentBreadcrumb ? ( + + ) : null} +

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

+
+
+ + +
+ + + + +
+
+
+ +
+ {detailHasContent ? ( + + ) : null} +
); }; @@ -4951,51 +5204,6 @@ const LoginRoute = () => { ); }; -function TagsRoute() { - const { - tags, - refreshTags, - handleTagUpdate, - handleTagDelete, - setStatusMessage, - } = useAppShell(); - return ( -
- -
- ); -} - -function CorrespondentsRoute() { - const { - correspondents, - refreshCorrespondents, - handleCorrespondentCreate, - handleCorrespondentUpdate, - handleCorrespondentDelete, - setStatusMessage, - } = useAppShell(); - - return ( -
- -
- ); -} - const AppRouter = () => ( } /> @@ -5004,8 +5212,6 @@ const AppRouter = () => ( } /> } /> } /> - } /> - } /> } /> diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx index 690b191..11851b0 100644 --- a/frontend/src/sidebar/Sidebar.jsx +++ b/frontend/src/sidebar/Sidebar.jsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo } from 'react'; -import { ChevronIcon, TrashIcon, EditIcon, FolderIcon } from '../ui/icons'; +import { ChevronIcon, TrashIcon, EditIcon, FolderIcon, ChevronsLeftIcon } from '../ui/icons'; import { getTagColorStyle } from '../utils/colors'; @@ -136,6 +136,19 @@ const Sidebar = ({ correspondents = [], activeCorrespondentIds = [], onToggleCorrespondentFilter, + onManageTags, + onManageCorrespondents, + searchQuery = '', + onSearchChange, + onSearchSubmit, + onSearchClear, + isFilterActive, + appStatus, + loading, + previewActive, + onLogout, + status, + onCollapse, }) => { const sortedCorrespondents = useMemo( () => @@ -150,6 +163,27 @@ const Sidebar = ({ ); const handleToggleTag = onToggleTagFilter || (() => {}); const activeTagSet = new Set(activeTagIds); + const handleManageTags = onManageTags || (() => {}); + const handleManageCorrespondents = onManageCorrespondents || (() => {}); + const handleSearchInputChange = useCallback( + (event) => { + onSearchChange?.(event.target.value); + }, + [onSearchChange], + ); + const handleSearchFormSubmit = useCallback( + (event) => { + event.preventDefault(); + onSearchSubmit?.(); + }, + [onSearchSubmit], + ); + const handleSearchClear = useCallback(() => { + onSearchClear?.(); + }, [onSearchClear]); + const handleLogoutClick = useCallback(() => { + onLogout?.(); + }, [onLogout]); const renderNodes = useCallback( (ids, depth) => @@ -193,30 +227,80 @@ 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 ( -