diff --git a/frontend/src/app/DocumentsLayout.tsx b/frontend/src/app/DocumentsLayout.tsx deleted file mode 100644 index 740efe0..0000000 --- a/frontend/src/app/DocumentsLayout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React, { ReactNode } from 'react'; -import Sidebar from '../sidebar/Sidebar'; -import { useSidebarContext } from '../sidebar/SidebarContext'; -import { usePanelManager } from './PanelManagerContext'; - -interface DocumentsLayoutProps { - sidebarProps?: Record; - children?: ReactNode; -} - -const DocumentsLayout: React.FC = ({ sidebarProps = {}, children }) => { - const { collapsed } = useSidebarContext(); - const { sidebarSuppressed } = usePanelManager(); - const sidebarHidden = collapsed || sidebarSuppressed; - return ( -
- {!sidebarHidden ? : null} - {children} -
- ); -}; - -export default DocumentsLayout; diff --git a/frontend/src/app/DocumentsRoute.tsx b/frontend/src/app/DocumentsRoute.tsx index 605a723..ab0c096 100644 --- a/frontend/src/app/DocumentsRoute.tsx +++ b/frontend/src/app/DocumentsRoute.tsx @@ -2,17 +2,14 @@ import React, { useCallback, useEffect, useMemo } from 'react'; import type { ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAppShell } from '../appShellContext'; -import DocumentsLayout from './DocumentsLayout'; import { useWorkspaceSurface } from './useWorkspaceSurface'; -import PanelHeader from '../ui/PanelHeader'; -import BreadcrumbTrail from '../ui/BreadcrumbTrail'; +import { DocumentsHeaderBreadcrumb } from '../documents/panel/DocumentsPanelHeader'; import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext'; import { PanelManagerProvider, usePanelManager } from './PanelManagerContext'; +import Sidebar from '../sidebar/Sidebar'; type Identifier = string | number; -type Breadcrumb = { id?: Identifier; name?: string; label?: string; title?: string }; - type EnsureAssetUrl = ( docId: Identifier, asset: unknown, @@ -25,7 +22,8 @@ type ResolveApiPath = (path: string) => string; type NotifyApiError = (error: unknown, fallbackMessage?: string) => void; interface DocumentsTableProps { - breadcrumbs?: Breadcrumb[] | null; + breadcrumbs?: DocumentsHeaderBreadcrumb[] | null; + onBreadcrumbNavigate?: (crumb: DocumentsHeaderBreadcrumb) => void; [key: string]: unknown; } @@ -88,7 +86,7 @@ const DocumentsRouteContent: React.FC = () => { const sidebarHidden = sidebarCollapsed || sidebarSuppressed; - const handleHeaderBreadcrumbClick = useCallback((crumb: Breadcrumb) => { + const handleHeaderBreadcrumbClick = useCallback((crumb: DocumentsHeaderBreadcrumb) => { if (!crumb || !crumb.id) { return; } @@ -96,10 +94,16 @@ const DocumentsRouteContent: React.FC = () => { navigate(target); }, [navigate]); + const documentsTablePropsWithNav = useMemo(() => ( + documentsTableProps + ? { ...documentsTableProps, onBreadcrumbNavigate: handleHeaderBreadcrumbClick } + : null + ), [documentsTableProps, handleHeaderBreadcrumbClick]); + const { surface } = useWorkspaceSurface({ sidebarHidden, onExpandSidebar: expandSidebar, - documentsTableProps, + documentsTableProps: documentsTablePropsWithNav, detailPanelProps, detailPanelOpen, previewWorkspaceDocument, @@ -122,75 +126,24 @@ const DocumentsRouteContent: React.FC = () => { if (!surface) { return ( - -
-
+
+ {!sidebarHidden ? : null} +
+
- +
); } - const variant = surface.variant || 'documents'; - const surfaceDetail = (surface as { detail?: ReactNode }).detail || null; - const hasDetail = Boolean(surfaceDetail); - - const mainContentClass = `main-content main-content--${variant}${ - hasDetail ? ' main-content--has-detail' : '' - }`; - const bodyClass = `main-content__body main-content__body--${variant}${ - hasDetail ? ' main-content__body--has-detail' : '' - }`; - - const header = surface.header || null; - - let headerTitle: React.ReactNode = null; - if (header) { - const breadcrumbEntries = Array.isArray(header.breadcrumbs) ? header.breadcrumbs.filter(Boolean) : []; - const lastIndex = breadcrumbEntries.length - 1; - const trailEntries = breadcrumbEntries.length - ? breadcrumbEntries.map((crumb, index) => ({ - id: crumb.id ?? index, - label: crumb.name ?? crumb.label ?? crumb.title ?? '', - onClick: index < lastIndex ? () => handleHeaderBreadcrumbClick(crumb) : null, - })) - : [{ id: 'current-location', label: header.title }]; - - headerTitle = ( -

- - {header.subtitle ? ( - {header.subtitle} - ) : null} -

- ); - } - return ( - -
- {header ? ( - <> -
- -
- {(header.floatingActions)} - - ) : null} -
{surface.content}
+
+ {!sidebarHidden ? : null} +
+
{surface.content}
{surfaceDetail}
- +
); }; diff --git a/frontend/src/app/PanelManagerContext.tsx b/frontend/src/app/PanelManagerContext.tsx index 7706ca5..714a640 100644 --- a/frontend/src/app/PanelManagerContext.tsx +++ b/frontend/src/app/PanelManagerContext.tsx @@ -98,12 +98,13 @@ const persistWidth = (panel: PanelKey, value: number): void => { window.localStorage.setItem(STORAGE_KEYS[panel], String(Math.round(value))); }; -const applyPanelWidthToRoot = (panel: PanelKey, width: number): void => { +const applyPanelWidthToRoot = (panel: PanelKey, width: number, active: boolean): void => { if (!Number.isFinite(width)) { return; } const varName = panel === 'sidebar' ? '--sidebar-width' : '--detail-panel-width'; - document.documentElement.style.setProperty(varName, `${width}px`); + const resolvedValue = panel === 'detail' && !active ? '0px' : `${width}px`; + document.documentElement.style.setProperty(varName, resolvedValue); }; interface PanelManagerProviderProps { @@ -132,13 +133,13 @@ export const PanelManagerProvider: React.FC = ({ chil useEffect(() => { panelWidthsRef.current.sidebar = sidebarWidth; - applyPanelWidthToRoot('sidebar', sidebarWidth); + applyPanelWidthToRoot('sidebar', sidebarWidth, true); }, [sidebarWidth]); useEffect(() => { panelWidthsRef.current.detail = detailWidth; - applyPanelWidthToRoot('detail', detailWidth); - }, [detailWidth]); + applyPanelWidthToRoot('detail', detailWidth, detailPanelOpen); + }, [detailWidth, detailPanelOpen]); const handlePanelLayoutChange = useCallback(( panel: PanelKey, @@ -187,7 +188,6 @@ export const PanelManagerProvider: React.FC = ({ chil setDetailWidthState((prev) => (prev === clamped ? prev : clamped)); } panelWidthsRef.current[panel] = clamped; - applyPanelWidthToRoot(panel, clamped); if (commit) { preferredPanelWidthsRef.current[panel] = clamped; persistWidth(panel, clamped); diff --git a/frontend/src/app/UploadQueueOverlay.tsx b/frontend/src/app/UploadQueueOverlay.tsx index 5522621..98052e1 100644 --- a/frontend/src/app/UploadQueueOverlay.tsx +++ b/frontend/src/app/UploadQueueOverlay.tsx @@ -9,7 +9,6 @@ import { WarningIcon, BottombarCollapseIcon, BottombarExpandIcon, - ClearAllIcon, } from '../ui/icons'; import PanelHeader from '../ui/PanelHeader'; @@ -86,11 +85,21 @@ const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProp const hasActiveUploads = queue.some((item) => item.status === 'uploading' || item.status === 'pending'); - const handleClearQueue = () => { - if (!onClearQueue || hasActiveUploads) { + const handleDismissOverlay = () => { + if (!queue.length) { + setDismissed(true); return; } - onClearQueue(); + + if (hasActiveUploads) { + const confirmed = window.confirm('Uploads are still running. Clear the queue and hide the overlay?'); + if (!confirmed) { + return; + } + } + + onClearQueue?.(); + setDismissed(true); }; if (!queue.length || dismissed) { @@ -100,11 +109,10 @@ const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProp return (
+ Uploads - {summary} + {summary} )} actions={( @@ -117,20 +125,11 @@ const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProp > {collapsed ? : } - @@ -138,82 +137,69 @@ const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProp )} /> {!collapsed ? ( -
    - {[...queue] - .slice() - .reverse() - .map((item) => { - const meta = STATUS_META[item.status] || STATUS_META.pending; - const fileLabel = item.name; - const duplicateLabel = item.status === 'duplicate' ? item.document?.title || null : null; - const documentId = item.document?.id || item.conflictDocumentId || null; - const hasLink = Boolean(documentId); - const handleNavigate = () => { - if (!documentId) { - return; - } - navigate(`/documents/${documentId}`); - }; - return ( -
  • - - {meta.icon} - -
    - {item.status === 'success' && hasLink ? ( - - ) : ( -
    - {fileLabel} -
    - )} -
    - {item.status === 'duplicate' && duplicateLabel ? ( - - Duplicate of{' '} +
    +
      + {[...queue] + .slice() + .reverse() + .map((item) => { + const meta = STATUS_META[item.status] || STATUS_META.pending; + const fileLabel = item.name; + const documentTitle = item.document?.title || null; + const duplicateLabel = item.status === 'duplicate' ? documentTitle : null; + const documentId = item.document?.id || item.conflictDocumentId || null; + const hasLink = Boolean(documentId); + const handleNavigate = () => { + if (!documentId) { + return; + } + navigate(`/documents/${documentId}`); + }; + return ( +
    • + + {meta.icon} + +
      + {item.status === 'success' && hasLink ? ( - - ) : item.status === 'error' && item.error ? ( - - {item.error} - - ) : ( - <> - {meta.label} - {documentId ? ( - - ( + ) : ( +
      + {fileLabel} +
      + )} +
      + {item.status === 'duplicate' && duplicateLabel ? ( + + Duplicate of{' '} - ) - ) : null} - - )} -
      -
      -
    • - ); - })} -
    + ) : item.status === 'error' && item.error ? ( + + {item.error} + + ) : ( + {meta.label} + )} +
    +
    +
  • + ); + })} +
+
) : null}
); diff --git a/frontend/src/documents/panel/DocumentsPanel.tsx b/frontend/src/documents/panel/DocumentsPanel.tsx index 19094a5..ada5563 100644 --- a/frontend/src/documents/panel/DocumentsPanel.tsx +++ b/frontend/src/documents/panel/DocumentsPanel.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; import DocumentsGrid from '../DocumentsGrid'; import DocumentsList from '../DocumentsList'; import DesktopWorkspace from '../../desktop/DesktopWorkspace'; @@ -6,6 +7,10 @@ import { isTagTransferEvent } from '../tagTransfer'; import PreviewZoomOverlay from '../../detail/PreviewZoomOverlay'; import { isPointerModifierEvent, isPrimaryPointerEvent } from '../useEntryPointer'; import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext'; +import DocumentsPanelHeader, { + DocumentsPanelHeaderConfig, + DocumentsHeaderBreadcrumb, +} from './DocumentsPanelHeader'; const DEFAULT_GRID_ICON_SIZE = 144; @@ -15,6 +20,8 @@ const EntryType = { }; interface DocumentsPanelProps { + headerConfig?: DocumentsPanelHeaderConfig; + onBreadcrumbNavigate?: (crumb: DocumentsHeaderBreadcrumb) => void; [key: string]: any; } @@ -23,6 +30,8 @@ const defaultGetDocumentAsset = (_doc?: unknown, _type?: string) => null; export type PreviewEntryLike = { url?: string | null; contentType?: string | null }; const DocumentsPanel: React.FC = ({ + headerConfig, + onBreadcrumbNavigate, currentFolderName: _currentFolderName, breadcrumbs, subfolders, @@ -56,7 +65,7 @@ const DocumentsPanel: React.FC = ({ previewEntries, ensureDownloadUrl, deskWorkspaceProps = null, -}) => { +}): ReactNode => { const { selectedEntries, focusedRowKey, @@ -123,20 +132,8 @@ const DocumentsPanel: React.FC = ({ () => new Set(activeCorrespondentIds || []), [activeCorrespondentIds], ); - const scrollRef = useRef(null); + const scrollRef = useRef(null); const suppressDocumentClickRef = useRef(false); - 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 isDeskView = viewMode === 'desk'; @@ -577,44 +574,34 @@ const DocumentsPanel: React.FC = ({ const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading; const renderBody = () => { if (isDeskView) { - return ( -
- {deskWorkspaceProps ? ( - - ) : ( -
- Desk view is unavailable. -
- )} + return deskWorkspaceProps ? ( + + ) : ( +
+ Desk view is unavailable.
); } if (showDefaultEmptyState) { return ( -
-
- Drop files anywhere or onto a folder to upload documents. -
+
+ Drop files anywhere or onto a folder to upload documents.
); } if (showGridSearchEmptyState) { return ( -
-
- No documents match the current filters. -
+
+ No documents match the current filters.
); } if (showListSearchEmptyState) { return ( -
-
No documents match the current filters.
-
+
No documents match the current filters.
); } @@ -622,102 +609,113 @@ const DocumentsPanel: React.FC = ({ return null; } + if (isGridView) { + return ( + + ); + } + return ( -
-
{ - if (event.target === scrollRef.current) { - handlePanelFocus(); - } - }} - onKeyDown={(event) => { - if (event.target !== scrollRef.current) { - return; - } - handlePanelKeyDown(event); - }} - onClick={(event) => { - if (event.target === event.currentTarget) { - clearSelection(); - } - }} - aria-activedescendant={isGridView ? undefined : activeDescendantId} - > - {isGridView ? ( - - ) : ( - - )} -
-
+ ); }; const panelVariant = isDeskView ? 'desk' : isGridView ? 'grid' : 'list'; + const shouldHandlePanelInteractions = !isDeskView && showTableRows; + + const handleSectionFocus = useCallback((event: React.FocusEvent) => { + if (!shouldHandlePanelInteractions || event.target !== event.currentTarget) { + return; + } + handlePanelFocus(); + }, [shouldHandlePanelInteractions, handlePanelFocus]); + + const handleSectionKeyDown = useCallback((event: React.KeyboardEvent) => { + if (!shouldHandlePanelInteractions || event.target !== event.currentTarget) { + return; + } + handlePanelKeyDown(event); + }, [shouldHandlePanelInteractions, handlePanelKeyDown]); + + const handleSectionClick = useCallback((event: React.MouseEvent) => { + if (!shouldHandlePanelInteractions || event.target !== event.currentTarget) { + return; + } + clearSelection(); + }, [shouldHandlePanelInteractions, clearSelection]); return ( <> + {headerConfig ? ( + + ) : null}
{renderBody()}
diff --git a/frontend/src/documents/panel/DocumentsPanelHeader.tsx b/frontend/src/documents/panel/DocumentsPanelHeader.tsx new file mode 100644 index 0000000..b58c73e --- /dev/null +++ b/frontend/src/documents/panel/DocumentsPanelHeader.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import type { ReactNode } from 'react'; +import PanelHeader from '../../ui/PanelHeader'; +import BreadcrumbTrail from '../../ui/BreadcrumbTrail'; + +type Identifier = string | number; + +export interface DocumentsHeaderBreadcrumb { + id?: Identifier; + name?: string; + label?: string; + title?: string; +} + +export interface DocumentsPanelHeaderConfig { + title?: ReactNode; + subtitle?: ReactNode; + leading?: ReactNode; + actions?: ReactNode; + breadcrumbs?: DocumentsHeaderBreadcrumb[] | null; + floatingActions?: ReactNode; +} + +interface DocumentsPanelHeaderProps { + header?: DocumentsPanelHeaderConfig | null; + onBreadcrumbClick?: (crumb: DocumentsHeaderBreadcrumb) => void; +} + +const DocumentsPanelHeader: React.FC = ({ + header, + onBreadcrumbClick, +}) => { + if (!header) { + return null; + } + + const breadcrumbEntries = Array.isArray(header.breadcrumbs) + ? header.breadcrumbs.filter(Boolean) + : []; + const lastIndex = breadcrumbEntries.length - 1; + const trailEntries = breadcrumbEntries.length + ? breadcrumbEntries.map((crumb, index) => ({ + id: crumb.id ?? index, + label: crumb.name ?? crumb.label ?? crumb.title ?? '', + onClick: index < lastIndex && onBreadcrumbClick + ? () => onBreadcrumbClick(crumb) + : null, + })) + : [{ id: 'current-location', label: header.title }]; + + const headerTitle = ( +

+ + {header.subtitle ? ( + {header.subtitle} + ) : null} +

+ ); + + return ( + <> + + {header.floatingActions} + + ); +}; + +export default DocumentsPanelHeader; diff --git a/frontend/src/documents/panel/DocumentsToolbar.tsx b/frontend/src/documents/panel/DocumentsToolbar.tsx index a7d35f7..bced972 100644 --- a/frontend/src/documents/panel/DocumentsToolbar.tsx +++ b/frontend/src/documents/panel/DocumentsToolbar.tsx @@ -111,7 +111,7 @@ export const createDocumentsTableHeaderActions = ({