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/appState.js b/frontend/src/app/appState.js index 01c5bf3..8a63e46 100644 --- a/frontend/src/app/appState.js +++ b/frontend/src/app/appState.js @@ -184,7 +184,7 @@ const AppStateProvider = ({ children }) => { } try { - const { data } = await api.get('/auth/tenants'); + const { data } = await api.get('/tenants'); if (!abort) { dispatch({ type: 'SET_TENANTS', diff --git a/frontend/src/app/useDocumentPreview.js b/frontend/src/app/useDocumentPreview.js index 4489ec3..36f0671 100644 --- a/frontend/src/app/useDocumentPreview.js +++ b/frontend/src/app/useDocumentPreview.js @@ -4,7 +4,6 @@ const useDocumentPreview = ({ routeDocumentId, documents, searchResults, - setDocuments, selectedFolder, assetManager, api, @@ -17,6 +16,7 @@ const useDocumentPreview = ({ setActivePreviewId, }) => { const [previewEntries, setPreviewEntries] = useState(() => new Map()); + const [previewDocuments, setPreviewDocuments] = useState(() => new Map()); const previewInflightRef = useRef(new Map()); const previewReturnPathRef = useRef(null); @@ -46,6 +46,35 @@ const useDocumentPreview = ({ }); }, []); + const cachePreviewDocument = useCallback((doc) => { + if (!doc?.id) { + return; + } + setPreviewDocuments((prev) => { + const existing = prev.get(doc.id); + if (existing === doc) { + return prev; + } + const next = new Map(prev); + next.set(doc.id, doc); + return next; + }); + }, []); + + const removeCachedPreviewDocument = useCallback((documentId) => { + if (!documentId) { + return; + } + setPreviewDocuments((prev) => { + if (!prev.has(documentId)) { + return prev; + } + const next = new Map(prev); + next.delete(documentId); + return next; + }); + }, []); + const ensurePreviewUrl = useCallback( async (documentId, { force = false } = {}) => { if (!documentId) return null; @@ -116,12 +145,16 @@ const useDocumentPreview = ({ throw new Error('Document metadata unavailable.'); } - setDocuments((prev) => { - if (prev.some((item) => item.id === doc.id)) { - return prev; - } - return [doc, ...prev]; - }); + const existsInDocuments = documents.some((item) => item.id === doc.id); + const existsInSearch = Array.isArray(searchResults) + ? searchResults.some((item) => item.id === doc.id) + : false; + + if (existsInDocuments || existsInSearch) { + removeCachedPreviewDocument(doc.id); + } else { + cachePreviewDocument(doc); + } } if (!previewReturnPathRef.current) { @@ -138,10 +171,11 @@ const useDocumentPreview = ({ searchResults, documents, assetManager, - setDocuments, ensurePreviewUrl, setActivePreviewId, api, + cachePreviewDocument, + removeCachedPreviewDocument, ], ); @@ -207,8 +241,33 @@ const useDocumentPreview = ({ }; }, [routeDocumentId, ensurePreviewData, notifyApiError, closeDocumentPreview]); + useEffect(() => { + setPreviewDocuments((prev) => { + if (!prev.size) { + return prev; + } + const next = new Map(prev); + let changed = false; + const prune = (list) => { + if (!Array.isArray(list)) { + return; + } + list.forEach((doc) => { + if (doc?.id && next.has(doc.id)) { + next.delete(doc.id); + changed = true; + } + }); + }; + prune(documents); + prune(searchResults); + return changed ? next : prev; + }); + }, [documents, searchResults]); + return { previewEntries, + previewDocuments, ensurePreviewUrl, ensurePreviewData, openDocumentPreview, 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/detail/useDetailWorkspace.js b/frontend/src/detail/useDetailWorkspace.js index d802857..2c0478e 100644 --- a/frontend/src/detail/useDetailWorkspace.js +++ b/frontend/src/detail/useDetailWorkspace.js @@ -10,6 +10,7 @@ import { const useDetailWorkspace = ({ documents, searchResults, + previewDocuments, selectionOrder, selectedDocumentIds, documentLookup, @@ -195,8 +196,10 @@ const useDetailWorkspace = ({ return null; } const pool = searchResults ?? documents; - return pool.find((doc) => doc.id === previewDocumentId) || null; - }, [previewDocumentId, searchResults, documents]); + return pool.find((doc) => doc.id === previewDocumentId) + || previewDocuments?.get?.(previewDocumentId) + || null; + }, [previewDocumentId, searchResults, documents, previewDocuments]); const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument); diff --git a/frontend/src/documents/DocumentInfoPanel.jsx b/frontend/src/documents/DocumentInfoPanel.jsx index f1dc4f1..064125a 100644 --- a/frontend/src/documents/DocumentInfoPanel.jsx +++ b/frontend/src/documents/DocumentInfoPanel.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import DocumentSummarySection from './DocumentSummarySection'; import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata'; @@ -16,6 +16,13 @@ const DocumentInfoPanel = ({ resetKey = null, classNamePrefix = 'document-info', hideTabNavWhenSingle = true, + summaryPlacement = 'inline', + summaryTabLabel = 'Summary', + summaryTabId = 'summary', + leadingTabs = [], + trailingTabs = [], + tabsPlacement = 'top', + summaryLayout = 'default', }) => { const base = classNamePrefix; @@ -92,29 +99,90 @@ const DocumentInfoPanel = ({ }; }, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey]); + const renderSummarySection = useCallback(() => ( + + ), [document, summaryLayout, summaryProps, metadataItems]); + + const renderDetailsSection = useCallback(() => ( +
+ {metadataItems.length ? ( +
+ {metadataItems.map(({ label, value }) => ( +
+
{label}
+
{value || '—'}
+
+ ))} +
+ ) : ( +

No details available.

+ )} +
+ ), [base, metadataItems]); + + const summaryInline = summaryPlacement !== 'tabs'; + + const summaryTab = useMemo(() => { + if (summaryPlacement !== 'tabs') { + return null; + } + return { + id: summaryTabId, + label: summaryTabLabel, + render: () => ( +
+ {renderSummarySection()} +
+ ), + }; + }, [summaryPlacement, summaryTabId, summaryTabLabel, base, renderSummarySection]); + + const normalizedLeadingTabs = useMemo( + () => (Array.isArray(leadingTabs) + ? leadingTabs.filter((tab) => tab && tab.id && tab.label) + : []), + [leadingTabs], + ); + + const normalizedTrailingTabs = useMemo( + () => (Array.isArray(trailingTabs) + ? trailingTabs.filter((tab) => tab && tab.id && tab.label) + : []), + [trailingTabs], + ); + + const summaryNode = summaryInline + ? ( + <> + {renderSummarySection()} + {renderDetailsSection()} + + ) + : null; + const visibleTabs = useMemo(() => { const tabsList = []; - tabsList.push({ - id: 'details', - label: detailsTabLabel, - render: () => ( -
- {metadataItems.length ? ( -
- {metadataItems.map(({ label, value }) => ( -
-
{label}
-
{value || '—'}
-
- ))} -
- ) : ( -

No details available.

- )} -
- ), - }); + if (normalizedLeadingTabs.length) { + tabsList.push(...normalizedLeadingTabs); + } + + if (summaryTab) { + tabsList.push(summaryTab); + } + + if (summaryPlacement !== 'tabs') { + tabsList.push({ + id: 'details', + label: detailsTabLabel, + render: () => renderDetailsSection(), + }); + } if (showContentTab && contentConfig) { tabsList.push({ @@ -183,30 +251,38 @@ const DocumentInfoPanel = ({ } if (metadataPayload) { - tabsList.push({ - id: 'metadata', - label: metadataTabLabel, - render: () => ( -
-
-              {JSON.stringify(metadataPayload, null, 2)}
-            
-
- ), - }); + tabsList.push({ + id: 'metadata', + label: metadataTabLabel, + render: () => ( +
+
+            {JSON.stringify(metadataPayload, null, 2)}
+          
+
+ ), + }); + } + + if (normalizedTrailingTabs.length) { + tabsList.push(...normalizedTrailingTabs); } return tabsList; }, [ base, detailsTabLabel, - metadataItems, contentConfig, contentEnabled, contentState, metadataPayload, metadataTabLabel, showContentTab, + summaryTab, + normalizedLeadingTabs, + normalizedTrailingTabs, + summaryPlacement, + renderDetailsSection, ]); const fallbackTabId = useMemo(() => { @@ -242,7 +318,7 @@ const DocumentInfoPanel = ({ if (!isControlled) { setUncontrolledTab(fallbackTabId); } - }, [fallbackTabId, resetKey, isControlled]); + }, [fallbackTabId, isControlled]); useEffect(() => { if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) { @@ -270,12 +346,40 @@ const DocumentInfoPanel = ({ const singleTab = visibleTabs.length === 1 ? visibleTabs[0] : null; const shouldHideNav = hideTabNavWhenSingle && singleTab; + const tabNav = ( +
+ {visibleTabs.map((tab) => ( + + ))} +
+ ); + + const tabPanels = ( +
+ {visibleTabs.map((tab) => ( + tab.id === activeTabId ? ( +
+ {renderTabContent(tab, { document })} +
+ ) : null + ))} +
+ ); + + const tabsWrapperClass = `${base}__tabs-wrapper${tabsPlacement === 'bottom' ? ` ${base}__tabs-wrapper--bottom` : ''}`; + return ( <> - + {summaryNode} {shouldHideNav ? (
@@ -283,30 +387,11 @@ const DocumentInfoPanel = ({
) : ( -
-
- {visibleTabs.map((tab) => ( - - ))} -
-
- {visibleTabs.map((tab) => ( - tab.id === activeTabId ? ( -
- {renderTabContent(tab, { document })} -
- ) : null - ))} -
+
+ {tabsPlacement !== 'bottom' ? tabNav : null} + {tabsPlacement === 'bottom' ? tabPanels : null} + {tabsPlacement === 'bottom' ? tabNav : null} + {tabsPlacement !== 'bottom' ? tabPanels : null}
)} diff --git a/frontend/src/documents/DocumentSummarySection.jsx b/frontend/src/documents/DocumentSummarySection.jsx index 5793167..1d1319b 100644 --- a/frontend/src/documents/DocumentSummarySection.jsx +++ b/frontend/src/documents/DocumentSummarySection.jsx @@ -348,8 +348,11 @@ const DocumentSummarySection = ({ onCorrespondentAdd, onCorrespondentRemove, onUpdateTitle, - onUpdateIssued + onUpdateIssued, + layout = 'default', + detailItems = [], }) => { + const isCompactLayout = layout === 'compact'; const summary = useMemo(() => { if (!document) { return { @@ -507,6 +510,204 @@ const DocumentSummarySection = ({ return null; } + const TitleSection = () => ( + editableTitle && isTitleEditing ? ( +
+ { + setTitleDraft(event.target.value); + if (titleError) { + setTitleError(null); + } + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + cancelTitleEdit(); + } + }} + aria-label="Document title" + autoFocus + disabled={titleSaving} + /> + + +
+ ) : ( + <> +

{summary.title}

+ {editableTitle ? ( + + ) : null} + + ) + ); + + const issuedDisplay = editableIssued && isIssuedEditing ? ( +
+ { + setIssuedDraft(event.target.value); + if (issuedError) { + setIssuedError(null); + } + }} + aria-label="Issued on" + disabled={issuedSaving} + /> + + +
+ ) : ( + <> + {issuedDateLabel || 'Not set'} + {editableIssued ? ( + + ) : null} + + ); + + const metaItems = [ + { + key: 'issued', + label: 'Issued', + valueContent: issuedDisplay, + error: issuedError, + }, + ...metaRows.map((row) => ({ + key: row.key, + label: row.label, + fallbackValue: row.value, + })), + ]; + + const detailRows = Array.isArray(detailItems) + ? detailItems.map((item, index) => ({ + key: `detail-${item?.label || index}`, + label: item?.label || '—', + fallbackValue: item?.value, + })) + : []; + + const compactRows = [...metaItems, ...detailRows]; + + const renderTags = () => ( +
+ onTagRemove(document.id, tag.id) + : undefined + } + onAdd={ + onTagAdd + ? ({ value, option }) => onTagAdd(document, value, { option }) + : undefined + } + datalistOptions={tagOptions} + className="document-summary__tags" + /> +
+ ); + + const renderCorrespondents = () => ( +
+ + onCorrespondentRemove({ + documentId: document.id, + correspondentId: entry.id, + }) + : undefined + } + onAdd={ + onCorrespondentAdd + ? ({ name, option }) => + onCorrespondentAdd({ + document, + name, + option, + }) + : undefined + } + showCount + datalistOptions={correspondentOptions} + className="document-summary__correspondents" + /> +
+ ); + + if (isCompactLayout) { + return ( +
+
+ +
+ {titleError ?
{titleError}
: null} + {renderTags()} + {renderCorrespondents()} + {compactRows.length ? ( +
+
+ {compactRows.map((item) => ( +
+
{item.label}
+
+ {item.valueContent != null && item.valueContent !== '' + ? item.valueContent + : item.fallbackValue || '—'} +
+ {item.error ?
{item.error}
: null} +
+ ))} +
+
+ ) : null} +
+ ); + } + return (
@@ -552,62 +753,21 @@ const DocumentSummarySection = ({ className="icon-button" onClick={startTitleEdit} aria-label="Edit title" - title="Edit title" - > - - - ) : null} - - )} -
+ title="Edit title" + > + + + ) : null} + + )}
+
{titleError ?
{titleError}
: null}
Issued: - {editableIssued && isIssuedEditing ? ( -
- { - setIssuedDraft(event.target.value); - if (issuedError) { - setIssuedError(null); - } - }} - aria-label="Issued on" - disabled={issuedSaving} - /> - - -
- ) : ( - <> - {issuedDateLabel || 'Not set'} - {editableIssued ? ( - - ) : null} - - )} + {issuedDisplay}
{issuedError ?
{issuedError}
: null} @@ -619,45 +779,9 @@ const DocumentSummarySection = ({ ))}
- onTagRemove(document.id, tag.id) - : undefined - } - onAdd={ - onTagAdd - ? ({ value, option }) => onTagAdd(document, value, { option }) - : undefined - } - datalistOptions={tagOptions} - /> + {renderTags()} - - onCorrespondentRemove({ - documentId: document.id, - correspondentId: entry.id, - }) - : undefined - } - onAdd={ - onCorrespondentAdd - ? ({ name, option }) => - onCorrespondentAdd({ - document, - name, - option, - }) - : undefined - } - showCount - datalistOptions={correspondentOptions} - /> + {renderCorrespondents()} ); }; diff --git a/frontend/src/documents/panel/DocumentsPanel.jsx b/frontend/src/documents/panel/DocumentsPanel.jsx index 5692736..496fc38 100644 --- a/frontend/src/documents/panel/DocumentsPanel.jsx +++ b/frontend/src/documents/panel/DocumentsPanel.jsx @@ -59,6 +59,34 @@ const DocumentsPanel = ({ const showingSearchResults = searchResults !== null; const rows = showingSearchResults ? searchResults : documents; + const currentFolderId = useMemo(() => { + if (showingSearchResults) { + return null; + } + const trail = Array.isArray(breadcrumbs) ? breadcrumbs : []; + if (trail.length === 0) { + return 'root'; + } + return trail[trail.length - 1]?.id || 'root'; + }, [breadcrumbs, showingSearchResults]); + + const selectionContextRef = useRef(null); + useEffect(() => { + const nextContext = showingSearchResults + ? { type: 'search', marker: searchResults } + : { type: 'folder', marker: currentFolderId || 'root' }; + const previous = selectionContextRef.current; + selectionContextRef.current = nextContext; + if (!previous) { + return; + } + const changed = previous.type !== nextContext.type + || previous.marker !== nextContext.marker; + if (changed) { + onClearSelection?.(); + } + }, [showingSearchResults, currentFolderId, searchResults, onClearSelection]); + const entries = useMemo(() => { const list = []; if (!showingSearchResults) { diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.js b/frontend/src/hooks/documents/useDocumentsWorkspace.js index f17a52c..b376d81 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.js +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.js @@ -328,6 +328,7 @@ const useDocumentsWorkspace = ({ const { previewEntries, + previewDocuments, ensurePreviewData, openDocumentPreview, closeDocumentPreview, @@ -337,7 +338,6 @@ const useDocumentsWorkspace = ({ routeDocumentId: previewDocumentId, documents, searchResults, - setDocuments, selectedFolder, assetManager, api, @@ -410,8 +410,13 @@ const useDocumentsWorkspace = ({ if (Array.isArray(searchResults)) { push(searchResults); } + previewDocuments.forEach((doc, id) => { + if (doc && id && !map.has(id)) { + map.set(id, doc); + } + }); return map; - }, [documents, searchResults]); + }, [documents, searchResults, previewDocuments]); const { tags, @@ -1158,6 +1163,7 @@ const useDocumentsWorkspace = ({ } = useDetailWorkspace({ documents, searchResults, + previewDocuments, focusedDocumentId, selectionOrder, selectedDocumentIds, diff --git a/frontend/src/hooks/documents/useTenantManager.js b/frontend/src/hooks/documents/useTenantManager.js index 8dce9ed..ec67f67 100644 --- a/frontend/src/hooks/documents/useTenantManager.js +++ b/frontend/src/hooks/documents/useTenantManager.js @@ -30,10 +30,10 @@ const useTenantManager = ({ } if (refreshOnly) { - const { data } = await apiClient.get('/auth/tenants'); + const { data } = await apiClient.get('/tenants'); appDispatch({ type: 'SET_TENANTS', - tenants: Array.isArray(data?.tenants) ? data.tenants : [], + tenants: Array.isArray(data) ? data : [], }); return; } diff --git a/frontend/src/preview/DocumentViewerLayout.jsx b/frontend/src/preview/DocumentViewerLayout.jsx index 964e5ea..1389c5a 100644 --- a/frontend/src/preview/DocumentViewerLayout.jsx +++ b/frontend/src/preview/DocumentViewerLayout.jsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import DocumentInfoPanel from '../documents/DocumentInfoPanel'; import { DownloadIcon } from '../ui/icons'; @@ -13,7 +13,10 @@ const DocumentViewerLayout = ({ defaultTabId = 'details', infoPanelProps = {}, previewLoadingMessage = 'Preparing preview…', + layoutMode = 'split', }) => { + const isStacked = layoutMode === 'stacked'; + const previewContent = useMemo(() => { if (!document || !previewEntry?.url) { return null; @@ -74,30 +77,65 @@ const DocumentViewerLayout = ({ ); }, [previewEntry, document]); + const renderViewportPane = useCallback(() => ( +
+ {!previewEntry?.url ? ( +
{previewLoadingMessage}
+ ) : ( + previewContent + )} +
+ ), [previewEntry?.url, previewLoadingMessage, previewContent]); + + const viewportPane = renderViewportPane(); + + const stackedLeadingTabs = useMemo(() => ( + isStacked + ? [ + { + id: 'preview', + label: 'Preview', + render: () => renderViewportPane(), + }, + ] + : [] + ), [isStacked, renderViewportPane]); + + const resolvedDefaultTabId = isStacked ? 'preview' : defaultTabId; + const summaryPlacement = 'tabs'; + const tabsPlacement = isStacked ? 'bottom' : 'top'; + const summaryLayout = 'compact'; + + const detailsPane = ( +
+
+ +
+
+ ); + + if (isStacked) { + return detailsPane; + } + return ( <> -
-
- -
-
-
- {!previewEntry?.url ? ( -
{previewLoadingMessage}
- ) : ( - previewContent - )} -
+ {detailsPane} + {viewportPane} ); }; diff --git a/frontend/src/preview/DocumentViewerPanel.jsx b/frontend/src/preview/DocumentViewerPanel.jsx index 8a5cb61..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 = (
@@ -432,6 +684,7 @@ const DocumentViewerPanel = ({ metadataPayload={metadataPayload} contentTabConfig={contentTabConfig} previewLoadingMessage="Loading preview…" + layoutMode={isStackedLayout ? 'stacked' : 'split'} />
@@ -459,7 +712,8 @@ const DocumentViewerPanel = ({ if (isSidebarVariant) { return ( <> -