From 32ef3ced59061fb32db718379a6784620f4d4087 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 12 Nov 2025 02:15:04 +0100 Subject: [PATCH] panels --- frontend/src/app/DocumentsLayout.jsx | 7 +- frontend/src/app/DocumentsRoute.jsx | 106 +++++++- frontend/src/app/useWorkspaceSurface.js | 6 +- frontend/src/preview/DocumentViewerPanel.jsx | 255 ++++++++++++++++++- frontend/src/preview/useViewerLayoutMode.js | 13 +- frontend/src/sidebar/Sidebar.jsx | 224 +++++++++++++++- frontend/src/sidebar/SidebarContext.js | 20 +- frontend/src/styles/base/theme.css | 1 + frontend/src/styles/detail/detail-panels.css | 42 +++ frontend/src/styles/documents/listing.css | 2 +- frontend/src/styles/layout/structure.css | 6 +- frontend/src/styles/sidebar/sidebar.css | 49 +++- 12 files changed, 700 insertions(+), 31 deletions(-) diff --git a/frontend/src/app/DocumentsLayout.jsx b/frontend/src/app/DocumentsLayout.jsx index 202c16c..0655d26 100644 --- a/frontend/src/app/DocumentsLayout.jsx +++ b/frontend/src/app/DocumentsLayout.jsx @@ -3,10 +3,11 @@ import Sidebar from '../sidebar/Sidebar'; import { useSidebarContext } from '../sidebar/SidebarContext'; const DocumentsLayout = ({ sidebarProps, children }) => { - const { collapsed } = useSidebarContext(); + const { collapsed, sidebarSuppressed } = useSidebarContext(); + const sidebarHidden = collapsed || sidebarSuppressed; return ( -
- {!collapsed ? : null} +
+ {!sidebarHidden ? : null} {children}
); diff --git a/frontend/src/app/DocumentsRoute.jsx b/frontend/src/app/DocumentsRoute.jsx index 6fcc108..2769e64 100644 --- a/frontend/src/app/DocumentsRoute.jsx +++ b/frontend/src/app/DocumentsRoute.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAppShell } from '../appShellContext'; import DocumentsLayout from './DocumentsLayout'; @@ -28,9 +28,105 @@ const DocumentsRouteContent = () => { notifyApiError, } = useAppShell(); const navigate = useNavigate(); - const { collapsed: sidebarCollapsed, setCollapsed } = useSidebarContext(); + const { collapsed: sidebarCollapsed, sidebarSuppressed, setCollapsed, setSidebarSuppressed } = useSidebarContext(); - const expandSidebar = useCallback(() => setCollapsed(false), [setCollapsed]); + const getDetailPanelWidth = useCallback(() => { + if (typeof window === 'undefined') { + return null; + } + const panelEl = document.querySelector('.detail-panel'); + if (panelEl) { + const rect = panelEl.getBoundingClientRect(); + if (Number.isFinite(rect?.width)) { + return rect.width; + } + } + const rootStyles = window.getComputedStyle(document.documentElement); + const varValue = rootStyles.getPropertyValue('--detail-panel-width'); + const parsed = parseFloat(varValue); + return Number.isFinite(parsed) ? parsed : null; + }, []); + + const getSidebarWidth = useCallback(() => { + if (typeof window === 'undefined') { + return null; + } + const sidebarEl = document.querySelector('.sidebar'); + if (sidebarEl) { + const rect = sidebarEl.getBoundingClientRect(); + if (Number.isFinite(rect?.width)) { + return rect.width; + } + } + const rootStyles = window.getComputedStyle(document.documentElement); + const varValue = rootStyles.getPropertyValue('--sidebar-width'); + const parsed = parseFloat(varValue); + return Number.isFinite(parsed) ? parsed : null; + }, []); + + const shouldCloseDetailPanelForSidebar = useCallback(() => { + if (typeof window === 'undefined') { + return false; + } + if (!detailPanelOpen && !previewDocumentId) { + return false; + } + const detailWidth = getDetailPanelWidth(); + const sidebarWidth = getSidebarWidth(); + if (!Number.isFinite(detailWidth) || !Number.isFinite(sidebarWidth)) { + return false; + } + return detailWidth + sidebarWidth > window.innerWidth * (2 / 3); + }, [detailPanelOpen, previewDocumentId, getDetailPanelWidth, getSidebarWidth]); + + const closeAnyDetailPanel = useCallback(() => { + if (previewDocumentId && typeof closeDocumentPreview === 'function') { + closeDocumentPreview(); + return true; + } + if (detailPanelOpen && typeof detailPanelProps?.onClose === 'function') { + detailPanelProps.onClose(); + return true; + } + return false; + }, [previewDocumentId, closeDocumentPreview, detailPanelOpen, detailPanelProps]); + + const sidebarWidthRef = useRef(null); + + useEffect(() => { + sidebarWidthRef.current = getSidebarWidth(); + }, [getSidebarWidth]); + + const expandSidebar = useCallback(() => { + if (shouldCloseDetailPanelForSidebar()) { + closeAnyDetailPanel(); + } + setSidebarSuppressed(false); + setCollapsed(false); + }, [setCollapsed, setSidebarSuppressed, shouldCloseDetailPanelForSidebar, closeAnyDetailPanel]); + + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + const handleSidebarResize = (event) => { + const nextWidth = Number.isFinite(event?.detail?.width) + ? event.detail.width + : getSidebarWidth(); + const prevWidth = sidebarWidthRef.current; + if (Number.isFinite(nextWidth)) { + sidebarWidthRef.current = nextWidth; + } + if (Number.isFinite(nextWidth) && Number.isFinite(prevWidth) && nextWidth <= prevWidth) { + return; + } + if (shouldCloseDetailPanelForSidebar()) { + closeAnyDetailPanel(); + } + }; + window.addEventListener('sidebar-width-change', handleSidebarResize); + return () => window.removeEventListener('sidebar-width-change', handleSidebarResize); + }, [shouldCloseDetailPanelForSidebar, closeAnyDetailPanel, getSidebarWidth]); const sidebarPropsWithActions = useMemo( () => ({ @@ -67,8 +163,10 @@ const DocumentsRouteContent = () => { navigate(target); }, [navigate]); + const sidebarHidden = sidebarCollapsed || sidebarSuppressed; + const { surface } = useWorkspaceSurface({ - sidebarCollapsed, + sidebarHidden, onExpandSidebar: expandSidebar, documentsTableProps, detailPanelProps, diff --git a/frontend/src/app/useWorkspaceSurface.js b/frontend/src/app/useWorkspaceSurface.js index bb5d513..5ff89dd 100644 --- a/frontend/src/app/useWorkspaceSurface.js +++ b/frontend/src/app/useWorkspaceSurface.js @@ -5,7 +5,7 @@ import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel'; import createDesktopSurface from '../desktop/createDesktopSurface'; export const useWorkspaceSurface = ({ - sidebarCollapsed, + sidebarHidden, onExpandSidebar, documentsTableProps, detailPanelProps, @@ -25,7 +25,7 @@ export const useWorkspaceSurface = ({ onNavigateParent, }) => { const renderSidebarToggle = useCallback(() => { - if (!sidebarCollapsed) { + if (!sidebarHidden) { return null; } return ( @@ -39,7 +39,7 @@ export const useWorkspaceSurface = ({ ); - }, [sidebarCollapsed, onExpandSidebar]); + }, [sidebarHidden, onExpandSidebar]); const documentsSurface = useMemo(() => { if (!documentsTableProps) { diff --git a/frontend/src/preview/DocumentViewerPanel.jsx b/frontend/src/preview/DocumentViewerPanel.jsx index 2d92071..6b2bfce 100644 --- a/frontend/src/preview/DocumentViewerPanel.jsx +++ b/frontend/src/preview/DocumentViewerPanel.jsx @@ -26,6 +26,77 @@ import BreadcrumbTrail from '../ui/BreadcrumbTrail'; import { useAssetNavigator } from '../hooks/useAssetNavigator'; import DocumentViewerLayout from './DocumentViewerLayout'; import useViewerLayoutMode from './useViewerLayoutMode'; +import { useSidebarContext } from '../sidebar/SidebarContext'; + +const DETAIL_PANEL_WIDTH_STORAGE_KEY = 'detailPanelWidth'; +const MIN_DETAIL_PANEL_WIDTH = 320; +const MAX_DETAIL_PANEL_WIDTH = 960; + +const isBrowser = typeof window !== 'undefined'; +const isDocumentAvailable = typeof document !== 'undefined'; + +const getDetailPanelBounds = () => { + if (!isBrowser) { + return { + min: MIN_DETAIL_PANEL_WIDTH, + max: MAX_DETAIL_PANEL_WIDTH, + }; + } + const viewportWidth = Math.max(window.innerWidth, 1); + const minFractionWidth = viewportWidth / 5; + const maxFractionWidth = viewportWidth * 0.75; + const rawMin = Math.max(MIN_DETAIL_PANEL_WIDTH, minFractionWidth); + const rawMax = Math.min(MAX_DETAIL_PANEL_WIDTH, maxFractionWidth); + if (rawMin >= rawMax) { + const fallback = Math.min(Math.max(rawMin, viewportWidth * 0.5), MAX_DETAIL_PANEL_WIDTH); + return { min: fallback, max: fallback }; + } + return { + min: rawMin, + max: rawMax, + }; +}; + +const clampDetailPanelWidth = (value) => { + if (!Number.isFinite(value) || value <= 0) { + return null; + } + const { min, max } = getDetailPanelBounds(); + return Math.min(Math.max(value, min), max); +}; + +const loadStoredDetailPanelWidth = () => { + if (!isBrowser) { + return null; + } + try { + const raw = window.localStorage?.getItem(DETAIL_PANEL_WIDTH_STORAGE_KEY); + if (!raw) { + return null; + } + const parsed = parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : null; + } catch (error) { + console.warn('Failed to read detail panel width', error); + return null; + } +}; + +const applyDetailPanelWidth = (width) => { + if (!isDocumentAvailable || width == null) { + return; + } + document.documentElement.style.setProperty('--detail-panel-width', `${width}px`); +}; + +const getSidebarWidthFromRoot = () => { + if (!isBrowser || !isDocumentAvailable) { + return null; + } + const computed = window.getComputedStyle(document.documentElement); + const parsed = parseFloat(computed.getPropertyValue('--sidebar-width')); + return Number.isFinite(parsed) ? parsed : null; +}; export const createDocumentViewerHeaderActions = ({ document, @@ -99,6 +170,7 @@ const DocumentViewerPanel = ({ }) => { const navigate = useNavigate(); const isSidebarVariant = variant === 'sidebar'; + const { setSidebarSuppressed } = useSidebarContext(); const sortedCorrespondents = useMemo( () => sortCorrespondents(document?.correspondents || []), [document], @@ -239,6 +311,174 @@ const DocumentViewerPanel = ({ const panelRef = useRef(null); const isStackedLayout = useViewerLayoutMode(panelRef, document?.id); + const pendingWidthRef = useRef(null); + const [isResizingPanel, setIsResizingPanel] = useState(false); + const [detailPanelWidth, setDetailPanelWidth] = useState(() => { + if (!isBrowser) { + return null; + } + const stored = loadStoredDetailPanelWidth(); + return stored != null ? clampDetailPanelWidth(stored) : null; + }); + + useEffect(() => { + if (detailPanelWidth != null) { + const clamped = clampDetailPanelWidth(detailPanelWidth); + if (clamped != null) { + applyDetailPanelWidth(clamped); + } + } + }, [detailPanelWidth]); + + useEffect(() => { + if (!isSidebarVariant) { + setSidebarSuppressed(false); + return undefined; + } + const updateSuppression = () => { + if (!isBrowser) { + setSidebarSuppressed(false); + return; + } + const panelWidth = pendingWidthRef.current + ?? detailPanelWidth + ?? panelRef.current?.getBoundingClientRect().width; + const sidebarWidth = getSidebarWidthFromRoot(); + if (!Number.isFinite(panelWidth)) { + setSidebarSuppressed(false); + return; + } + const minMainContentWidth = (window.innerWidth * 2) / 5; + const occupiedWidth = panelWidth + (Number.isFinite(sidebarWidth) ? sidebarWidth : 0); + const availableWidth = window.innerWidth - occupiedWidth; + setSidebarSuppressed(availableWidth < minMainContentWidth); + }; + + updateSuppression(); + const handleWindowResize = () => updateSuppression(); + window.addEventListener('resize', handleWindowResize); + return () => { + window.removeEventListener('resize', handleWindowResize); + }; + }, [isSidebarVariant, detailPanelWidth, setSidebarSuppressed]); + + useEffect(() => { + if (!isSidebarVariant) { + setSidebarSuppressed(false); + } + }, [isSidebarVariant, setSidebarSuppressed]); + + useEffect(() => { + if (!isBrowser) { + return undefined; + } + const handleResize = () => { + setDetailPanelWidth((prev) => { + if (prev == null) { + return prev; + } + const clamped = clampDetailPanelWidth(prev); + if (clamped != null && clamped !== prev) { + applyDetailPanelWidth(clamped); + try { + window.localStorage?.setItem(DETAIL_PANEL_WIDTH_STORAGE_KEY, String(Math.round(clamped))); + } catch (error) { + console.warn('Failed to persist detail panel width', error); + } + return clamped; + } + return prev; + }); + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + const handleResizePointerDown = useCallback((event) => { + if (!isSidebarVariant || !panelRef.current || !isBrowser) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const pointerId = event.pointerId; + const target = event.currentTarget; + target.setPointerCapture?.(pointerId); + setIsResizingPanel(true); + + const rect = panelRef.current.getBoundingClientRect(); + const startWidth = rect.width; + const startX = event.clientX; + + const updateWidth = (nextWidth) => { + const clamped = clampDetailPanelWidth(nextWidth); + if (clamped != null) { + pendingWidthRef.current = clamped; + applyDetailPanelWidth(clamped); + if (isSidebarVariant && isBrowser) { + setSidebarSuppressed(clamped > window.innerWidth / 2); + } + } + }; + + const handlePointerMove = (moveEvent) => { + if (moveEvent.pointerId !== pointerId) { + return; + } + const delta = startX - moveEvent.clientX; + updateWidth(startWidth + delta); + }; + + const handlePointerUp = (upEvent) => { + if (upEvent.pointerId !== pointerId) { + return; + } + target.releasePointerCapture?.(pointerId); + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + setIsResizingPanel(false); + if (pendingWidthRef.current != null) { + const finalizedWidth = pendingWidthRef.current; + pendingWidthRef.current = null; + setDetailPanelWidth(finalizedWidth); + try { + window.localStorage?.setItem(DETAIL_PANEL_WIDTH_STORAGE_KEY, String(Math.round(finalizedWidth))); + } catch (error) { + console.warn('Failed to persist detail panel width', error); + } + } + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + }, [isSidebarVariant, setSidebarSuppressed]); + + const handleResizeKeyDown = useCallback((event) => { + if (!isSidebarVariant || !isBrowser) { + return; + } + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return; + } + const baseWidth = pendingWidthRef.current + ?? detailPanelWidth + ?? panelRef.current?.getBoundingClientRect().width; + if (!Number.isFinite(baseWidth)) { + return; + } + event.preventDefault(); + const step = event.shiftKey ? 40 : 20; + const delta = event.key === 'ArrowLeft' ? step : -step; + const nextWidth = clampDetailPanelWidth(baseWidth + delta); + if (nextWidth == null) { + return; + } + setDetailPanelWidth(nextWidth); + try { + window.localStorage?.setItem(DETAIL_PANEL_WIDTH_STORAGE_KEY, String(Math.round(nextWidth))); + } catch (error) { + console.warn('Failed to persist detail panel width', error); + } + }, [detailPanelWidth, isSidebarVariant]); const viewerClassName = isStackedLayout ? 'document-viewer document-viewer--stacked' @@ -403,6 +643,18 @@ const DocumentViewerPanel = ({ ) : null; + const resizeHandle = isSidebarVariant ? ( + + ) : null; + const loadingSection = (
@@ -460,7 +712,8 @@ const DocumentViewerPanel = ({ if (isSidebarVariant) { return ( <> -