From 8e7a2f3d6cd962f7cffa50dc943116a605a33a94 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 11 Nov 2025 21:40:29 +0100 Subject: [PATCH 1/9] desaturate theme a bit --- .../hooks/documents/useDocumentsWorkspace.js | 1 + frontend/src/hooks/documents/useFolderTree.js | 20 ++++++++++++- .../hooks/documents/useFolderTreeActions.js | 6 ++++ frontend/src/styles/base/theme.css | 28 +++++++++---------- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.js b/frontend/src/hooks/documents/useDocumentsWorkspace.js index f17a52c..6fe4baa 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.js +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.js @@ -796,6 +796,7 @@ const useDocumentsWorkspace = ({ setDraggedFolderId, isInvalidFolderDrop, setCreatingFolder, + clearSelection, }); const { diff --git a/frontend/src/hooks/documents/useFolderTree.js b/frontend/src/hooks/documents/useFolderTree.js index b0f7675..55ea0be 100644 --- a/frontend/src/hooks/documents/useFolderTree.js +++ b/frontend/src/hooks/documents/useFolderTree.js @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { DEFAULT_FOLDER_NAME, createRootNode, @@ -30,6 +30,8 @@ const useFolderTree = ({ const [currentFolder, setCurrentFolder] = useState(null); const [currentSubfolders, setCurrentSubfolders] = useState([]); + const lastAppliedFolderIdRef = useRef(null); + const { focusedDocumentId, setFocusedDocumentId, @@ -41,6 +43,9 @@ const useFolderTree = ({ const applySelectedFolder = useCallback( (folderId, contents) => { + const folderChanged = lastAppliedFolderIdRef.current !== folderId; + lastAppliedFolderIdRef.current = folderId; + const subfolders = contents?.subfolders ?? []; const docs = assetManager.hydrateDocuments(contents?.documents ?? []); const folderInfo = contents?.folder ?? null; @@ -63,6 +68,11 @@ const useFolderTree = ({ let mergedSelection = []; setSelectedEntries((previous) => { + if (folderChanged) { + nextDocKeys = []; + mergedSelection = []; + return []; + } const previousFolderKeys = previous .filter(isFolderRowKey) .filter((key) => availableFolderKeys.has(key)); @@ -72,6 +82,14 @@ const useFolderTree = ({ return mergedSelection; }); + if (folderChanged) { + setFocusedDocumentId(null); + selectionAnchorRef.current = null; + selectionOrderRef.current = []; + setSelectionOrder([]); + return; + } + const nextFocus = (() => { const currentFocusedKey = resolveDocumentRowKey(focusedDocumentId); if (currentFocusedKey && availableDocKeySet.has(currentFocusedKey)) { diff --git a/frontend/src/hooks/documents/useFolderTreeActions.js b/frontend/src/hooks/documents/useFolderTreeActions.js index 29da7b1..ebb7001 100644 --- a/frontend/src/hooks/documents/useFolderTreeActions.js +++ b/frontend/src/hooks/documents/useFolderTreeActions.js @@ -28,6 +28,7 @@ const useFolderTreeActions = ({ setDraggedFolderId, isInvalidFolderDrop, setCreatingFolder, + clearSelection, }) => { const moveFolder = useCallback( async (folderId, targetFolderId) => { @@ -173,6 +174,10 @@ const useFolderTreeActions = ({ async (folderId, { replace = false, immediate = false } = {}) => { const targetId = folderId && folderId !== 'root' ? folderId : 'root'; + if (typeof clearSelection === 'function') { + clearSelection(); + } + await ensureFolderAncestorsLoaded(targetId); expandFolderAncestors(targetId); @@ -186,6 +191,7 @@ const useFolderTreeActions = ({ navigate(path, { replace }); }, [ + clearSelection, ensureFolderAncestorsLoaded, expandFolderAncestors, isFilterActive, diff --git a/frontend/src/styles/base/theme.css b/frontend/src/styles/base/theme.css index 44bbe35..7f558ef 100644 --- a/frontend/src/styles/base/theme.css +++ b/frontend/src/styles/base/theme.css @@ -9,14 +9,14 @@ --surface-hue-shift: -4deg; /* --- Neutrals --- */ - --bg: oklch(0.97 0.001 calc(var(--neutral-hue) + var(--surface-hue-shift))); - --surface: oklch(0.985 0.001 calc(var(--neutral-hue) + var(--surface-hue-shift))); - --surface-subtle: oklch(0.96 0.002 calc(var(--neutral-hue) + var(--surface-hue-shift))); - --fg: oklch(0.32 0.005 calc(var(--neutral-hue) + var(--foreground-hue-offset))); - --muted: oklch(0.54 0.008 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --bg: oklch(0.97 0 calc(var(--neutral-hue) + var(--surface-hue-shift))); + --surface: oklch(0.985 0 calc(var(--neutral-hue) + var(--surface-hue-shift))); + --surface-subtle: oklch(0.96 0 calc(var(--neutral-hue) + var(--surface-hue-shift))); + --fg: oklch(0.32 0 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --muted: oklch(0.54 0 calc(var(--neutral-hue) + var(--foreground-hue-offset))); --muted-subtle: color-mix(in oklch, var(--muted) 55%, var(--border)); - --sidebar-fg: oklch(0.56 0.007 calc(var(--neutral-hue) + var(--foreground-hue-offset))); - --border: oklch(0.92 0.002 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --sidebar-fg: oklch(0.56 0 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --border: oklch(0.92 0 calc(var(--neutral-hue) + var(--foreground-hue-offset))); /* --- Accent (primary) --- */ --accent: oklch(0.61 0.16 calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset))); @@ -186,14 +186,14 @@ --dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset)); --dark-foreground-hue: calc(var(--neutral-hue) + var(--dark-foreground-hue-offset)); - --bg: oklch(0.15 0.01 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); - --surface: oklch(0.19 0.012 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); - --surface-subtle: oklch(0.23 0.012 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); - --fg: oklch(0.89 0.015 var(--dark-foreground-hue)); - --muted: oklch(0.72 0.02 var(--dark-foreground-hue)); + --bg: oklch(0.15 0 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --surface: oklch(0.19 0 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --surface-subtle: oklch(0.23 0 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --fg: oklch(0.89 0 var(--dark-foreground-hue)); + --muted: oklch(0.72 0 var(--dark-foreground-hue)); --muted-subtle: color-mix(in oklch, var(--muted) 45%, var(--border)); - --sidebar-fg: oklch(0.78 0.02 var(--dark-foreground-hue)); - --border: oklch(0.33 0.01 var(--dark-foreground-hue)); + --sidebar-fg: oklch(0.78 0 var(--dark-foreground-hue)); + --border: oklch(0.33 0 var(--dark-foreground-hue)); --accent: oklch(0.75 0.16 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); --accent-hover: oklch(0.82 0.13 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); From 1111784caaa63fdab2cfe82d2864c2c65a5d0f9f Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 11 Nov 2025 21:50:08 +0100 Subject: [PATCH 2/9] selection fix --- .../src/documents/panel/DocumentsPanel.jsx | 28 +++++++++++++++++++ .../hooks/documents/useDocumentsWorkspace.js | 1 - frontend/src/hooks/documents/useFolderTree.js | 20 +------------ .../hooks/documents/useFolderTreeActions.js | 6 ---- 4 files changed, 29 insertions(+), 26 deletions(-) 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 6fe4baa..f17a52c 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.js +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.js @@ -796,7 +796,6 @@ const useDocumentsWorkspace = ({ setDraggedFolderId, isInvalidFolderDrop, setCreatingFolder, - clearSelection, }); const { diff --git a/frontend/src/hooks/documents/useFolderTree.js b/frontend/src/hooks/documents/useFolderTree.js index 55ea0be..b0f7675 100644 --- a/frontend/src/hooks/documents/useFolderTree.js +++ b/frontend/src/hooks/documents/useFolderTree.js @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { DEFAULT_FOLDER_NAME, createRootNode, @@ -30,8 +30,6 @@ const useFolderTree = ({ const [currentFolder, setCurrentFolder] = useState(null); const [currentSubfolders, setCurrentSubfolders] = useState([]); - const lastAppliedFolderIdRef = useRef(null); - const { focusedDocumentId, setFocusedDocumentId, @@ -43,9 +41,6 @@ const useFolderTree = ({ const applySelectedFolder = useCallback( (folderId, contents) => { - const folderChanged = lastAppliedFolderIdRef.current !== folderId; - lastAppliedFolderIdRef.current = folderId; - const subfolders = contents?.subfolders ?? []; const docs = assetManager.hydrateDocuments(contents?.documents ?? []); const folderInfo = contents?.folder ?? null; @@ -68,11 +63,6 @@ const useFolderTree = ({ let mergedSelection = []; setSelectedEntries((previous) => { - if (folderChanged) { - nextDocKeys = []; - mergedSelection = []; - return []; - } const previousFolderKeys = previous .filter(isFolderRowKey) .filter((key) => availableFolderKeys.has(key)); @@ -82,14 +72,6 @@ const useFolderTree = ({ return mergedSelection; }); - if (folderChanged) { - setFocusedDocumentId(null); - selectionAnchorRef.current = null; - selectionOrderRef.current = []; - setSelectionOrder([]); - return; - } - const nextFocus = (() => { const currentFocusedKey = resolveDocumentRowKey(focusedDocumentId); if (currentFocusedKey && availableDocKeySet.has(currentFocusedKey)) { diff --git a/frontend/src/hooks/documents/useFolderTreeActions.js b/frontend/src/hooks/documents/useFolderTreeActions.js index ebb7001..29da7b1 100644 --- a/frontend/src/hooks/documents/useFolderTreeActions.js +++ b/frontend/src/hooks/documents/useFolderTreeActions.js @@ -28,7 +28,6 @@ const useFolderTreeActions = ({ setDraggedFolderId, isInvalidFolderDrop, setCreatingFolder, - clearSelection, }) => { const moveFolder = useCallback( async (folderId, targetFolderId) => { @@ -174,10 +173,6 @@ const useFolderTreeActions = ({ async (folderId, { replace = false, immediate = false } = {}) => { const targetId = folderId && folderId !== 'root' ? folderId : 'root'; - if (typeof clearSelection === 'function') { - clearSelection(); - } - await ensureFolderAncestorsLoaded(targetId); expandFolderAncestors(targetId); @@ -191,7 +186,6 @@ const useFolderTreeActions = ({ navigate(path, { replace }); }, [ - clearSelection, ensureFolderAncestorsLoaded, expandFolderAncestors, isFilterActive, From b987bdacfef871902de46f8eef471cf74c2a2108 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 11 Nov 2025 22:38:40 +0100 Subject: [PATCH 3/9] theme --- frontend/src/sidebar/Sidebar.jsx | 87 +++++++++++++++- frontend/src/sidebar/SidebarContext.js | 133 +++++++++++++++++++++++-- frontend/src/styles/base/theme.css | 74 +++++++------- 3 files changed, 246 insertions(+), 48 deletions(-) diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx index 96e68bb..f0eddf5 100644 --- a/frontend/src/sidebar/Sidebar.jsx +++ b/frontend/src/sidebar/Sidebar.jsx @@ -195,6 +195,12 @@ const Sidebar = ({ neutralHue, setNeutralHue, resetNeutralHue, + neutralChroma, + setNeutralChroma, + resetNeutralChroma, + neutralContrast, + setNeutralContrast, + resetNeutralContrast, themeMode, cycleThemeMode, themeModes, @@ -204,6 +210,8 @@ const Sidebar = ({ setCollapsed(true); }, [setCollapsed]); const neutralHueInputId = useId(); + const neutralChromaInputId = useId(); + const neutralContrastInputId = useId(); const sortedCorrespondents = useMemo(() => { if (!Array.isArray(correspondents)) { return []; @@ -281,9 +289,11 @@ const Sidebar = ({ [onUploadFiles, selectedFolder], ); - const handleNeutralHueReset = useCallback(() => { + const handleThemeAdjustmentsReset = useCallback(() => { resetNeutralHue(); - }, [resetNeutralHue]); + resetNeutralChroma(); + resetNeutralContrast(); + }, [resetNeutralHue, resetNeutralChroma, resetNeutralContrast]); const handleNeutralHueChange = useCallback( (value) => { @@ -300,6 +310,44 @@ const Sidebar = ({ [resetNeutralHue, setNeutralHue], ); + const handleNeutralChromaChange = useCallback( + (value) => { + if (value === '') { + resetNeutralChroma(); + return; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return; + } + setNeutralChroma(parsed); + }, + [resetNeutralChroma, setNeutralChroma], + ); + + const handleNeutralContrastChange = useCallback( + (value) => { + if (value === '') { + resetNeutralContrast(); + return; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return; + } + setNeutralContrast(parsed); + }, + [resetNeutralContrast, setNeutralContrast], + ); + + const formatSliderValue = useCallback((value) => { + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return value; + } + return parsed.toFixed(2); + }, []); + const themeModeList = themeModes && themeModes.length ? themeModes : THEME_MODES; const themeModeIndex = themeModeList.indexOf(themeMode); const safeThemeModeIndex = themeModeIndex === -1 ? 0 : themeModeIndex; @@ -336,9 +384,9 @@ const Sidebar = ({ @@ -354,9 +402,38 @@ const Sidebar = ({ step="1" value={neutralHue} onChange={(event) => handleNeutralHueChange(event.target.value)} + onDoubleClick={resetNeutralHue} /> {neutralHue}° + + ) : null; diff --git a/frontend/src/sidebar/SidebarContext.js b/frontend/src/sidebar/SidebarContext.js index cb25e96..4ffabfc 100644 --- a/frontend/src/sidebar/SidebarContext.js +++ b/frontend/src/sidebar/SidebarContext.js @@ -12,19 +12,29 @@ const SIDEBAR_COLLAPSE_STORAGE_KEY = 'papercrate_sidebar_collapsed'; const SidebarContext = createContext(null); const THEME_STORAGE_KEY = 'papercrate_theme_settings'; -const DEFAULT_NEUTRAL_HUE = 180; +const DEFAULT_NEUTRAL_HUE = 145; +const DEFAULT_NEUTRAL_CHROMA = 0.15; +const DEFAULT_NEUTRAL_CONTRAST = 0.364; const DEFAULT_THEME_MODE = 'system'; const THEME_MODES = ['system', 'light', 'dark']; const loadInitialThemeSettings = () => { - const defaults = { neutralHue: DEFAULT_NEUTRAL_HUE, mode: DEFAULT_THEME_MODE }; + const defaults = { + neutralHue: DEFAULT_NEUTRAL_HUE, + neutralChroma: DEFAULT_NEUTRAL_CHROMA, + neutralContrast: DEFAULT_NEUTRAL_CONTRAST, + mode: DEFAULT_THEME_MODE, + }; if (typeof window === 'undefined') { if (typeof document !== 'undefined') { - const current = document.documentElement.style.getPropertyValue('--neutral-hue'); + const root = document.documentElement; + const current = root.style.getPropertyValue('--neutral-hue'); const parsed = Number.parseInt(current, 10); return { neutralHue: Number.isNaN(parsed) ? defaults.neutralHue : parsed, + neutralChroma: defaults.neutralChroma, + neutralContrast: defaults.neutralContrast, mode: defaults.mode, }; } @@ -32,13 +42,36 @@ const loadInitialThemeSettings = () => { } const root = document.documentElement; + const readNumberVar = (name, fallback) => { + const inlineValue = root.style.getPropertyValue(name); + const inlineParsed = Number.parseFloat(inlineValue); + if (!Number.isNaN(inlineParsed)) { + return inlineParsed; + } + if (typeof window !== 'undefined' && window.getComputedStyle) { + const computedValue = window.getComputedStyle(root).getPropertyValue(name); + const computedParsed = Number.parseFloat(computedValue); + if (!Number.isNaN(computedParsed)) { + return computedParsed; + } + } + return fallback; + }; + const loadFromRoot = () => { const current = root.style.getPropertyValue('--neutral-hue'); const parsed = Number.parseInt(current, 10); - return Number.isNaN(parsed) ? defaults.neutralHue : parsed; + return { + neutralHue: Number.isNaN(parsed) ? defaults.neutralHue : parsed, + neutralChroma: readNumberVar('--neutral-chroma', defaults.neutralChroma), + neutralContrast: readNumberVar('--neutral-contrast', defaults.neutralContrast), + }; }; - let neutralHueValue = loadFromRoot(); + const rootValues = loadFromRoot(); + let neutralHueValue = rootValues.neutralHue; + let neutralChromaValue = rootValues.neutralChroma; + let neutralContrastValue = rootValues.neutralContrast; let modeValue = defaults.mode; const composite = window.localStorage.getItem(THEME_STORAGE_KEY); @@ -49,6 +82,14 @@ const loadInitialThemeSettings = () => { if (!Number.isNaN(storedHue)) { neutralHueValue = storedHue; } + const storedChroma = Number.parseFloat(parsed?.neutralChroma); + if (!Number.isNaN(storedChroma)) { + neutralChromaValue = Math.min(Math.max(storedChroma, 0), 1); + } + const storedContrast = Number.parseFloat(parsed?.neutralContrast); + if (!Number.isNaN(storedContrast)) { + neutralContrastValue = Math.min(Math.max(storedContrast, 0), 1); + } const storedMode = parsed?.mode; if (THEME_MODES.includes(storedMode)) { modeValue = storedMode; @@ -70,7 +111,12 @@ const loadInitialThemeSettings = () => { } } - return { neutralHue: neutralHueValue, mode: modeValue }; + return { + neutralHue: neutralHueValue, + neutralChroma: neutralChromaValue, + neutralContrast: neutralContrastValue, + mode: modeValue, + }; }; const loadInitialCollapsedState = (defaultValue) => { @@ -95,6 +141,8 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => { const [collapsed, setCollapsedState] = useState(() => loadInitialCollapsedState(initialCollapsed)); const initialTheme = useMemo(() => loadInitialThemeSettings(), []); const [neutralHue, setNeutralHueState] = useState(initialTheme.neutralHue); + const [neutralChroma, setNeutralChromaState] = useState(initialTheme.neutralChroma); + const [neutralContrast, setNeutralContrastState] = useState(initialTheme.neutralContrast); const [themeMode, setThemeModeState] = useState(initialTheme.mode); useEffect(() => { @@ -104,6 +152,20 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => { document.documentElement.style.setProperty('--neutral-hue', `${neutralHue}deg`); }, [neutralHue]); + useEffect(() => { + if (typeof document === 'undefined') { + return; + } + document.documentElement.style.setProperty('--neutral-chroma', String(neutralChroma)); + }, [neutralChroma]); + + useEffect(() => { + if (typeof document === 'undefined') { + return; + } + document.documentElement.style.setProperty('--neutral-contrast', String(neutralContrast)); + }, [neutralContrast]); + useEffect(() => { if (typeof document === 'undefined') { return; @@ -121,14 +183,19 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => { return; } try { - const payload = JSON.stringify({ neutralHue, mode: themeMode }); + const payload = JSON.stringify({ + neutralHue, + neutralChroma, + neutralContrast, + mode: themeMode, + }); window.localStorage.setItem(THEME_STORAGE_KEY, payload); window.localStorage.removeItem('papercrate_neutral_hue'); window.localStorage.removeItem('papercrate_theme_mode'); } catch (error) { console.warn('[theme] failed to persist theme settings', error); } - }, [neutralHue, themeMode]); + }, [neutralHue, neutralChroma, neutralContrast, themeMode]); const setNeutralHue = useCallback((value) => { setNeutralHueState((prev) => { @@ -144,6 +211,42 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => { }); }, []); + const setNeutralChroma = useCallback((value) => { + setNeutralChromaState((prev) => { + if (value === '' || value === null || typeof value === 'undefined') { + return DEFAULT_NEUTRAL_CHROMA; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return prev; + } + const clamped = Math.min(Math.max(parsed, 0), 1); + return Math.round(clamped * 1000) / 1000; + }); + }, []); + + const resetNeutralChroma = useCallback(() => { + setNeutralChromaState(DEFAULT_NEUTRAL_CHROMA); + }, []); + + const setNeutralContrast = useCallback((value) => { + setNeutralContrastState((prev) => { + if (value === '' || value === null || typeof value === 'undefined') { + return DEFAULT_NEUTRAL_CONTRAST; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return prev; + } + const clamped = Math.min(Math.max(parsed, 0), 1); + return Math.round(clamped * 1000) / 1000; + }); + }, []); + + const resetNeutralContrast = useCallback(() => { + setNeutralContrastState(DEFAULT_NEUTRAL_CONTRAST); + }, []); + const resetNeutralHue = useCallback(() => { setNeutralHueState(DEFAULT_NEUTRAL_HUE); }, []); @@ -186,19 +289,33 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => { setCollapsed, neutralHue, setNeutralHue, + neutralChroma, + setNeutralChroma, + neutralContrast, + setNeutralContrast, resetNeutralHue, + resetNeutralChroma, + resetNeutralContrast, themeMode, setThemeMode, cycleThemeMode, themeModes: THEME_MODES, defaultNeutralHue: DEFAULT_NEUTRAL_HUE, + defaultNeutralChroma: DEFAULT_NEUTRAL_CHROMA, + defaultNeutralContrast: DEFAULT_NEUTRAL_CONTRAST, }), [ collapsed, setCollapsed, neutralHue, setNeutralHue, + neutralChroma, + setNeutralChroma, + neutralContrast, + setNeutralContrast, resetNeutralHue, + resetNeutralChroma, + resetNeutralContrast, themeMode, setThemeMode, cycleThemeMode, diff --git a/frontend/src/styles/base/theme.css b/frontend/src/styles/base/theme.css index 7f558ef..7f648c6 100644 --- a/frontend/src/styles/base/theme.css +++ b/frontend/src/styles/base/theme.css @@ -1,26 +1,32 @@ :root { color-scheme: light dark; - --neutral-hue: 180deg; - --foreground-hue-offset: 10deg; - --accent-hue-offset: -20deg; - --dark-hue-offset: 30deg; - --dark-foreground-hue-offset: 10deg; - --surface-hue-shift: -4deg; + --neutral-hue: 145deg; + --surface-hue-shift: 6deg; + --foreground-hue-offset: 20deg; + --accent-hue-offset: 0deg; + --dark-hue-offset: 40deg; + --dark-foreground-hue-offset: 20deg; + --neutral-chroma: 0.15; + --neutral-contrast: 0.5; + --neutral-contrast-amount: calc(0.85 + var(--neutral-contrast) * 0.55); + --neutral-contrast-baseline: 1.125; + --neutral-contrast-delta: calc(var(--neutral-contrast-amount) - var(--neutral-contrast-baseline)); + --neutral-chroma-scale: calc(clamp(0.0001, var(--neutral-chroma), 1) / 0.1); /* --- Neutrals --- */ - --bg: oklch(0.97 0 calc(var(--neutral-hue) + var(--surface-hue-shift))); - --surface: oklch(0.985 0 calc(var(--neutral-hue) + var(--surface-hue-shift))); - --surface-subtle: oklch(0.96 0 calc(var(--neutral-hue) + var(--surface-hue-shift))); - --fg: oklch(0.32 0 calc(var(--neutral-hue) + var(--foreground-hue-offset))); - --muted: oklch(0.54 0 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --bg: oklch(calc(0.97 + var(--neutral-contrast-delta) * 0.12) calc(var(--neutral-chroma) * 0.001) calc(var(--neutral-hue) + var(--surface-hue-shift))); + --surface: oklch(calc(0.985 + var(--neutral-contrast-delta) * 0.1) calc(var(--neutral-chroma) * 0.001) calc(var(--neutral-hue) + var(--surface-hue-shift))); + --surface-subtle: oklch(calc(0.96 + var(--neutral-contrast-delta) * 0.16) calc(var(--neutral-chroma) * 0.002) calc(var(--neutral-hue) + var(--surface-hue-shift))); + --fg: oklch(calc(0.32 - var(--neutral-contrast-delta) * 0.18) calc(var(--neutral-chroma) * 0.003) calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --muted: oklch(calc(0.54 - var(--neutral-contrast-delta) * 0.05) calc(var(--neutral-chroma) * 0.005) calc(var(--neutral-hue) + var(--foreground-hue-offset))); --muted-subtle: color-mix(in oklch, var(--muted) 55%, var(--border)); - --sidebar-fg: oklch(0.56 0 calc(var(--neutral-hue) + var(--foreground-hue-offset))); - --border: oklch(0.92 0 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --sidebar-fg: oklch(calc(0.56 + var(--neutral-contrast-delta) * 0.18) calc(var(--neutral-chroma) * 0.003) calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --border: oklch(calc(0.92 + var(--neutral-contrast-delta) * 0.15) calc(var(--neutral-chroma) * 0.0015) calc(var(--neutral-hue) + var(--foreground-hue-offset))); /* --- Accent (primary) --- */ - --accent: oklch(0.61 0.16 calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset))); - --accent-hover: oklch(0.70 0.12 calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset))); + --accent: oklch(0.61 calc(0.16 * var(--neutral-chroma-scale)) calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset))); + --accent-hover: oklch(0.70 calc(0.12 * var(--neutral-chroma-scale)) calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset))); --on-accent: oklch(1 0 0); --folder-icon-back: color-mix(in oklch, var(--accent) 78%, black 8%); @@ -107,18 +113,17 @@ --dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset)); --dark-foreground-hue: calc(var(--neutral-hue) + var(--dark-foreground-hue-offset)); - - --bg: oklch(0.15 0.01 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); - --surface: oklch(0.19 0.012 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); - --surface-subtle: oklch(0.23 0.012 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); - --fg: oklch(0.89 0.015 var(--dark-foreground-hue)); - --muted: oklch(0.72 0.02 var(--dark-foreground-hue)); + --bg: oklch(calc(0.18 - var(--neutral-contrast-delta) * 0.015) calc(var(--neutral-chroma) * 0.1) calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --surface: oklch(calc(0.22 - var(--neutral-contrast-delta) * 0.012) calc(var(--neutral-chroma) * 0.12) calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --surface-subtle: oklch(calc(0.26 - var(--neutral-contrast-delta) * 0.01) calc(var(--neutral-chroma) * 0.12) calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --fg: oklch(calc(0.9 + var(--neutral-contrast-delta) * 0.02) calc(var(--neutral-chroma) * 0.04) var(--dark-foreground-hue)); + --muted: oklch(calc(0.72 + var(--neutral-contrast-delta) * 0.01) calc(var(--neutral-chroma) * 0.06) var(--dark-foreground-hue)); --muted-subtle: color-mix(in oklch, var(--muted) 45%, var(--border)); - --sidebar-fg: oklch(0.78 0.02 var(--dark-foreground-hue)); - --border: oklch(0.33 0.01 var(--dark-foreground-hue)); + --sidebar-fg: oklch(calc(0.78 + var(--neutral-contrast-delta) * 0.01) calc(var(--neutral-chroma) * 0.06) var(--dark-foreground-hue)); + --border: oklch(calc(0.33 - var(--neutral-contrast-delta) * 0.01) calc(var(--neutral-chroma) * 0.03) var(--dark-foreground-hue)); - --accent: oklch(0.75 0.16 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); - --accent-hover: oklch(0.82 0.13 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); + --accent: oklch(0.75 calc(0.16 * var(--neutral-chroma-scale)) calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); + --accent-hover: oklch(0.82 calc(0.13 * var(--neutral-chroma-scale)) calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); --on-accent: oklch(0.15 0.015 var(--dark-foreground-hue)); --folder-icon-back: color-mix(in oklch, var(--accent) 72%, black 12%); @@ -185,18 +190,17 @@ --dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset)); --dark-foreground-hue: calc(var(--neutral-hue) + var(--dark-foreground-hue-offset)); - - --bg: oklch(0.15 0 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); - --surface: oklch(0.19 0 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); - --surface-subtle: oklch(0.23 0 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); - --fg: oklch(0.89 0 var(--dark-foreground-hue)); - --muted: oklch(0.72 0 var(--dark-foreground-hue)); + --bg: oklch(calc(0.5 - 0.35 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.1) calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --surface: oklch(calc(0.5 - 0.31 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.12) calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --surface-subtle: oklch(calc(0.5 - 0.27 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.12) calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --fg: oklch(calc(0.5 + 0.39 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.04) var(--dark-foreground-hue)); + --muted: oklch(calc(0.5 + 0.22 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.06) var(--dark-foreground-hue)); --muted-subtle: color-mix(in oklch, var(--muted) 45%, var(--border)); - --sidebar-fg: oklch(0.78 0 var(--dark-foreground-hue)); - --border: oklch(0.33 0 var(--dark-foreground-hue)); + --sidebar-fg: oklch(calc(0.5 + 0.28 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.06) var(--dark-foreground-hue)); + --border: oklch(calc(0.5 - 0.17 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.03) var(--dark-foreground-hue)); - --accent: oklch(0.75 0.16 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); - --accent-hover: oklch(0.82 0.13 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); + --accent: oklch(0.75 calc(0.16 * var(--neutral-chroma-scale)) calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); + --accent-hover: oklch(0.82 calc(0.13 * var(--neutral-chroma-scale)) calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); --on-accent: oklch(0.15 0.015 var(--dark-foreground-hue)); --folder-icon-back: color-mix(in oklch, var(--accent) 72%, black 12%); From 5653828ff31381fe6398288965a0cb06abdfc709 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 12 Nov 2025 02:24:31 +0100 Subject: [PATCH 4/9] api change --- frontend/src/app/appState.js | 2 +- frontend/src/hooks/documents/useTenantManager.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/hooks/documents/useTenantManager.js b/frontend/src/hooks/documents/useTenantManager.js index 8dce9ed..8da360d 100644 --- a/frontend/src/hooks/documents/useTenantManager.js +++ b/frontend/src/hooks/documents/useTenantManager.js @@ -30,7 +30,7 @@ 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 : [], From 06683e2a0724916f10b62d26063ea19a66bd76f8 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 11 Nov 2025 23:54:43 +0100 Subject: [PATCH 5/9] cleanup --- frontend/src/app/useDocumentPreview.js | 75 +++++++++++++++++-- frontend/src/detail/useDetailWorkspace.js | 7 +- .../hooks/documents/useDocumentsWorkspace.js | 10 ++- frontend/src/styles/documents/viewer.css | 2 - 4 files changed, 80 insertions(+), 14 deletions(-) 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/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/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/styles/documents/viewer.css b/frontend/src/styles/documents/viewer.css index 8623424..0037025 100644 --- a/frontend/src/styles/documents/viewer.css +++ b/frontend/src/styles/documents/viewer.css @@ -53,13 +53,11 @@ flex-direction: column; justify-content: center; align-items: center; - flex: 2 2 auto; max-height: calc(var(--document-viewer-portrait-height-ratio) * 100vh); order: -1; } .document-viewer--stacked .document-viewer__details-pane { - flex: 1 1 auto; overflow: visible; order: 0; } From d42192b2f19d7e6761d27e6a938a7ccd6dbae52d Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 12 Nov 2025 00:07:44 +0100 Subject: [PATCH 6/9] fix --- frontend/src/styles/workspace/workspace-items.css | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/styles/workspace/workspace-items.css b/frontend/src/styles/workspace/workspace-items.css index 962d1d8..0a764eb 100644 --- a/frontend/src/styles/workspace/workspace-items.css +++ b/frontend/src/styles/workspace/workspace-items.css @@ -6,12 +6,17 @@ cursor: grab; touch-action: none; transform-origin: center center; - transition: box-shadow 0.16s ease; + transition: + opacity 0.55s cubic-bezier(0.4, 0, 0.2, 1), + filter 0.55s cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 0.16s ease; outline: none; will-change: transform; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; + filter: blur(0px) grayscale(0%); + opacity: 1; } .desk-item__body { @@ -41,10 +46,9 @@ } .desk-item.is-filtered-out { - opacity: 0.12; + opacity: 0; pointer-events: none; - filter: blur(15px) grayscale(100%); - transition: opacity 0.6s ease, filter 0.28s ease; + filter: blur(18px) grayscale(100%); z-index: 0; } From 762e4ee315b0aa65accd0ba6efc1fa4436dc1820 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 12 Nov 2025 00:52:33 +0100 Subject: [PATCH 7/9] fix --- frontend/src/hooks/documents/useTenantManager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/hooks/documents/useTenantManager.js b/frontend/src/hooks/documents/useTenantManager.js index 8da360d..ec67f67 100644 --- a/frontend/src/hooks/documents/useTenantManager.js +++ b/frontend/src/hooks/documents/useTenantManager.js @@ -33,7 +33,7 @@ const useTenantManager = ({ const { data } = await apiClient.get('/tenants'); appDispatch({ type: 'SET_TENANTS', - tenants: Array.isArray(data?.tenants) ? data.tenants : [], + tenants: Array.isArray(data) ? data : [], }); return; } From 44c982f0152b5e36bd31ac57fe23cd75760c6195 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 12 Nov 2025 01:29:33 +0100 Subject: [PATCH 8/9] document viewer --- frontend/src/documents/DocumentInfoPanel.jsx | 209 ++++++++---- .../src/documents/DocumentSummarySection.jsx | 302 ++++++++++++------ frontend/src/preview/DocumentViewerLayout.jsx | 84 +++-- frontend/src/preview/DocumentViewerPanel.jsx | 1 + frontend/src/styles/base/theme.css | 2 +- frontend/src/styles/detail/detail-panels.css | 85 ++++- frontend/src/styles/documents/viewer.css | 55 +++- 7 files changed, 546 insertions(+), 192 deletions(-) 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/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..2d92071 100644 --- a/frontend/src/preview/DocumentViewerPanel.jsx +++ b/frontend/src/preview/DocumentViewerPanel.jsx @@ -432,6 +432,7 @@ const DocumentViewerPanel = ({ metadataPayload={metadataPayload} contentTabConfig={contentTabConfig} previewLoadingMessage="Loading preview…" + layoutMode={isStackedLayout ? 'stacked' : 'split'} /> diff --git a/frontend/src/styles/base/theme.css b/frontend/src/styles/base/theme.css index 7f648c6..5bbee84 100644 --- a/frontend/src/styles/base/theme.css +++ b/frontend/src/styles/base/theme.css @@ -100,7 +100,7 @@ font-size: 15px; --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; - --detail-panel-width: 30em; + --detail-panel-width: calc(100vw / 3); --documents-grid-title-size: 0.8rem; } diff --git a/frontend/src/styles/detail/detail-panels.css b/frontend/src/styles/detail/detail-panels.css index f5584c6..b718ca9 100644 --- a/frontend/src/styles/detail/detail-panels.css +++ b/frontend/src/styles/detail/detail-panels.css @@ -489,6 +489,88 @@ gap: 0.25rem; } + +.document-summary--compact { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.document-summary__title-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.document-summary__meta { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.document-summary__meta-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.document-summary__meta-label { + color: var(--muted); +} + +.document-summary__meta-value { + color: var(--fg); + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.document-summary__meta-value .detail-meta__value { + font-size: inherit; + color: inherit; +} + +.document-summary__section { + padding: 0; +} + +.tag-list.document-summary__tags, +.correspondent-list.document-summary__correspondents { + margin-top: 0.25rem; +} + +.document-summary__details { +} + +.document-summary__details-list { + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.document-summary__details-row { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.document-summary__details-row dt { + margin: 0; + font-size: 0.8rem; + color: var(--muted); + word-break: break-word; +} + +.document-summary__details-row dd { + margin: 0; + font-size: 0.95rem; + word-break: break-word; +} + .detail-panel .doc-title-edit, .document-summary .doc-title-edit { display: flex; @@ -773,7 +855,7 @@ .detail-panel dt { font-weight: 600; - margin-top: 0.8rem; + margin-top: 0; } .detail-panel dd { @@ -786,7 +868,6 @@ display: flex; flex-wrap: wrap; gap: 0.5rem; - margin: 1rem 0; align-items: center; } diff --git a/frontend/src/styles/documents/viewer.css b/frontend/src/styles/documents/viewer.css index 0037025..6d2dfbe 100644 --- a/frontend/src/styles/documents/viewer.css +++ b/frontend/src/styles/documents/viewer.css @@ -43,7 +43,7 @@ display: flex; flex-direction: column; gap: 0.5rem; - padding: 0.5rem 1rem 1rem; + padding: 0.5rem 1rem 0.5rem; overflow: auto; } @@ -53,7 +53,8 @@ flex-direction: column; justify-content: center; align-items: center; - max-height: calc(var(--document-viewer-portrait-height-ratio) * 100vh); + flex: 1; + min-height: 0; order: -1; } @@ -189,6 +190,15 @@ min-height: 0; } +.document-viewer__tabs-wrapper--bottom .document-viewer__tabpanes { + order: 1; +} + +.document-viewer__tabs-wrapper--bottom .document-viewer__tabs { + order: 2; + margin-top: 0.75rem; +} + .document-viewer__section { display: flex; flex-direction: column; @@ -268,28 +278,37 @@ font-size: 0.85rem; line-height: 1.35; overflow: auto; + word-break: break-word; + overflow-wrap: anywhere; + white-space: pre-wrap; } .document-viewer__tabs { - display: inline-flex; - align-items: center; - gap: 0.5rem; - border-bottom: 1px solid var(--outline-subtle); + display: flex; + align-items: stretch; + gap: 0.25rem; + padding: 0.25rem; + border-radius: 999px; + background: var(--surface-overlay, var(--surface)); + border: 1px solid var(--outline-subtle); + box-shadow: var(--panel-shadow-soft, none); } .document-viewer__tab { appearance: none; border: none; background: transparent; - padding: 0.4rem 0.75rem; + padding: 0.35rem 0.9rem; font-size: 0.85rem; font-weight: 500; color: var(--muted); cursor: pointer; - border-bottom: 2px solid transparent; transition: color 120ms ease, - border-color 120ms ease; + background-color 120ms ease; + border-radius: 999px; + flex: 1; + text-align: center; } .document-viewer__tab:hover, @@ -298,8 +317,8 @@ } .document-viewer__tab.is-active { - color: var(--accent); - border-color: var(--accent); + color: var(--accent-strong, var(--accent)); + background: var(--accent-soft); } .document-viewer__tabpanes { @@ -308,6 +327,10 @@ display: flex; } +.document-viewer--stacked .document-viewer__tabpanes { + overflow: auto; +} + .document-viewer__tabpanes--single { flex: 1; min-height: 0; @@ -345,7 +368,7 @@ display: flex; position: relative; overflow: hidden; - align-items: flex-start; + align-items: stretch; justify-content: flex-start; max-height: 100%; grid-area: viewport; @@ -369,12 +392,11 @@ } .document-viewer--stacked .document-viewer__object:not(.document-viewer__object--image) { - height: calc(var(--document-viewer-portrait-height-ratio) * 100vh); - max-height: 100%; + height: 100%; } .document-viewer--stacked .document-viewer__object--image { - height: auto; + max-height: 100%; } .document-viewer__unsupported { @@ -408,3 +430,6 @@ width: 1rem; height: 1rem; } +.document-viewer__summary-tab-content { + padding-top: 0.5rem; +} From 32ef3ced59061fb32db718379a6784620f4d4087 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 12 Nov 2025 02:15:04 +0100 Subject: [PATCH 9/9] 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 ( <> -