From 1a1e9a80c3bd6544e8ce15a9fdaceefc1269d94b Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Fri, 31 Oct 2025 17:24:39 +0100 Subject: [PATCH] breadcrumbs --- frontend/src/DesktopWorkspace.jsx | 8 +- frontend/src/app/AppLayout.jsx | 4 +- frontend/src/app/DocumentsRoute.jsx | 44 ++- frontend/src/documents/DocumentsTable.jsx | 27 +- frontend/src/styles.css | 157 ++++++-- frontend/src/ui/BreadcrumbTrail.jsx | 431 ++++++++++++++++++++++ 6 files changed, 633 insertions(+), 38 deletions(-) create mode 100644 frontend/src/ui/BreadcrumbTrail.jsx diff --git a/frontend/src/DesktopWorkspace.jsx b/frontend/src/DesktopWorkspace.jsx index 6339f85..994e8ec 100644 --- a/frontend/src/DesktopWorkspace.jsx +++ b/frontend/src/DesktopWorkspace.jsx @@ -2046,7 +2046,13 @@ export const createDesktopSurface = ({ workspaceProps, renderSidebarToggle }) => return { key: 'workspace', variant: 'workspace', - header: { title, subtitle, leading, actions }, + header: { + title, + subtitle, + leading, + actions, + breadcrumbs: workspaceProps?.breadcrumbs || null, + }, content: , supportsDetail: false, }; diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index d04570f..a17044d 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -23,7 +23,7 @@ import { api, useAppDispatch, useAppState } from './appState'; const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; -const DEFAULT_FOLDER_NAME = 'All Documents'; +const DEFAULT_FOLDER_NAME = 'Documents'; const ROW_KEY_SEPARATOR = ':'; const DOCUMENT_ROW_PREFIX = 'document'; @@ -394,7 +394,7 @@ const AppLayout = () => { const handleNeutralHueChange = useCallback((value) => { if (value === '') { - setNeutralHue(260); + setNeutralHue(180); return; } const parsed = Number(value); diff --git a/frontend/src/app/DocumentsRoute.jsx b/frontend/src/app/DocumentsRoute.jsx index 5e72ece..b0150eb 100644 --- a/frontend/src/app/DocumentsRoute.jsx +++ b/frontend/src/app/DocumentsRoute.jsx @@ -4,6 +4,7 @@ import { useAppShell } from '../appShellContext'; import DocumentsLayout from './DocumentsLayout'; import { useWorkspaceSurface } from './useWorkspaceSurface'; import PanelHeader from '../ui/PanelHeader'; +import BreadcrumbTrail from '../ui/BreadcrumbTrail'; const DocumentsRoute = () => { const { @@ -59,6 +60,14 @@ const DocumentsRoute = () => { navigate(target); }, [navigate, parentBreadcrumb]); + const handleHeaderBreadcrumbClick = useCallback((crumb) => { + if (!crumb || !crumb.id) { + return; + } + const target = crumb.id === 'root' ? '/documents' : `/documents/folder/${crumb.id}`; + navigate(target); + }, [navigate]); + const { surface } = useWorkspaceSurface({ sidebarCollapsed, onExpandSidebar: expandSidebar, @@ -104,16 +113,31 @@ const DocumentsRoute = () => { const header = surface.header || null; - const headerTitle = header - ? header.subtitle - ? ( -

- {header.title} - {header.subtitle} -

- ) - : header.title - : null; + let headerTitle = null; + if (header) { + const breadcrumbEntries = Array.isArray(header.breadcrumbs) ? header.breadcrumbs.filter(Boolean) : []; + const lastIndex = breadcrumbEntries.length - 1; + const trailEntries = breadcrumbEntries.length + ? breadcrumbEntries.map((crumb, index) => ({ + id: crumb.id ?? index, + label: crumb.name ?? crumb.label ?? crumb.title ?? '', + onClick: index < lastIndex ? () => handleHeaderBreadcrumbClick(crumb) : null, + })) + : [{ id: 'current-location', label: header.title }]; + + headerTitle = ( +

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

+ ); + } return ( 0; + const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]); + const trailEntries = useMemo(() => { + if (!breadcrumbEntries.length) { + return [{ id: 'current-folder', label: currentFolderName }]; + } + const lastIndex = breadcrumbEntries.length - 1; + return breadcrumbEntries.map((crumb, index) => ({ + id: crumb.id ?? index, + label: crumb.name ?? crumb.label ?? crumb.title ?? '', + onClick: index < lastIndex && onFolderSelect + ? () => onFolderSelect(crumb.id) + : null, + })); + }, [breadcrumbEntries, currentFolderName, onFolderSelect]); return (
-

{currentFolderName}

+

+ +

{showingSearchResults && (
Search results
)} @@ -1120,6 +1142,7 @@ export const createDocumentsSurface = ({ }) => { const { currentFolderName, + breadcrumbs, searchResults, viewMode, onViewModeChange, @@ -1165,7 +1188,7 @@ export const createDocumentsSurface = ({ return { key: 'documents', variant: 'documents', - header: { title, subtitle, leading, actions }, + header: { title, subtitle, leading, actions, breadcrumbs }, content: , detail, }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 1c5d8a2..b0fe3f9 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,7 +1,7 @@ :root { color-scheme: light dark; - --neutral-hue: 260deg; + --neutral-hue: 180deg; --foreground-hue-offset: 10deg; --accent-hue-offset: -20deg; --dark-hue-offset: 30deg; @@ -640,6 +640,7 @@ button.danger:hover:not([disabled]) { display: flex; flex-direction: column; min-height: 0; + min-width: 0; margin: 0; border-radius: 0; background: var(--surface); @@ -683,20 +684,6 @@ button.danger:hover:not([disabled]) { font-size: 1rem; font-weight: 600; color: var(--fg); - display: inline-flex; - flex-wrap: wrap; - align-items: baseline; - gap: 0.5rem; - flex: 1 1 auto; - min-width: 0; -} - -.main-content__subtitle { - font-size: 0.9rem; - color: var(--muted); - font-weight: 400; - line-height: 1.2; - display: inline-flex; } @@ -724,6 +711,32 @@ button.danger:hover:not([disabled]) { color: var(--muted); } +.main-content__title { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--fg); + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + flex: 1 1 auto; + overflow: hidden; +} + +.main-content__breadcrumbs { + flex: 1 1 auto; + overflow: hidden; +} + +.main-content__subtitle { + font-size: 0.9rem; + color: var(--muted); + font-weight: 400; + line-height: 1.2; + flex-shrink: 0; +} + .panels-main { flex: 1; display: flex; @@ -1156,12 +1169,95 @@ button.danger:hover:not([disabled]) { font-weight: 600; } +.documents-panel__title { + display: flex; + align-items: center; + gap: 0.35rem; + margin: 0; + font-size: 0.9rem; + font-weight: 600; + min-width: 0; + flex-wrap: nowrap; + overflow: hidden; +} + .panel-section__subtitle { margin-top: 0.25rem; font-size: 0.78rem; color: var(--muted); } +.breadcrumb-trail { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: nowrap; + min-width: 0; + flex: 1 1 auto; + max-width: 100%; + overflow: hidden; +} + +.breadcrumb-trail--measure { + position: absolute; + visibility: hidden; + pointer-events: none; + left: -9999px; + top: -9999px; + max-width: none; + overflow: visible; +} + +.breadcrumb-trail__link { + display: inline-flex; + align-items: center; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border: none; + background: none; + padding: 0; + font: inherit; + color: inherit; + cursor: default; +} + +.breadcrumb-trail__link:not(.is-current) { + cursor: pointer; + color: var(--accent); +} + +.breadcrumb-trail__link:not(.is-current):hover, +.breadcrumb-trail__link:not(.is-current):focus-visible { + text-decoration: underline; +} + +.breadcrumb-trail__separator { + color: var(--muted); + margin: 0 0.2rem; +} + +.breadcrumb-trail__ellipsis { + position: relative; + display: inline-flex; +} + +.breadcrumb-trail__ellipsis-button { + cursor: pointer; +} + +.documents-panel__breadcrumbs { + flex: 1 1 auto; + min-width: 0; + max-width: 100%; + overflow: hidden; +} + +.documents-panel__breadcrumbs { + max-width: 22rem; +} + .panel-section__body { flex: 1; display: flex; @@ -1765,6 +1861,23 @@ button.danger:hover:not([disabled]) { gap: 0.5rem; } +.documents-panel__title { + display: flex; + align-items: center; + gap: 0.35rem; + margin: 0; + font-size: 0.9rem; + font-weight: 600; + min-width: 0; + white-space: nowrap; + overflow: hidden; +} + +.documents-panel__breadcrumbs { + flex: 1 1 auto; + overflow: hidden; +} + .documents-panel .panel-section__body { display: flex; flex-direction: column; @@ -2104,10 +2217,6 @@ button.danger:hover:not([disabled]) { color: inherit; } -.doc-name__primary { - font-weight: 600; -} - .doc-correspondent-link { background: none; background-color: transparent; @@ -2292,6 +2401,7 @@ button.danger:hover:not([disabled]) { align-items: center; gap: 0.75rem; padding: 0.5rem; + min-width: 0; } .panel-header__leading, @@ -2306,11 +2416,12 @@ button.danger:hover:not([disabled]) { font-size: 1rem; font-weight: 600; line-height: 1.2; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; flex: 1 1 auto; min-width: 0; + display: flex; + align-items: center; + gap: 0.5rem; + overflow: hidden; } .panel-header > .panel-header__title:first-child { @@ -2325,6 +2436,7 @@ button.danger:hover:not([disabled]) { padding: 0.5rem 1rem; } + .detail-panel .panel-body { flex: 1; display: flex; @@ -2978,7 +3090,6 @@ form.inline { .tags-panel, .correspondents-panel { min-height: auto; - padding: 1.25rem; } } .login-screen { diff --git a/frontend/src/ui/BreadcrumbTrail.jsx b/frontend/src/ui/BreadcrumbTrail.jsx new file mode 100644 index 0000000..040c596 --- /dev/null +++ b/frontend/src/ui/BreadcrumbTrail.jsx @@ -0,0 +1,431 @@ +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import PropTypes from 'prop-types'; + +const normalizeEntries = (entries) => + (Array.isArray(entries) ? entries : []) + .map((entry, index) => { + if (!entry) { + return null; + } + const id = entry.id ?? entry.value ?? index; + const label = entry.label ?? entry.name ?? entry.title ?? ''; + const onClick = typeof entry.onClick === 'function' ? entry.onClick : null; + return label ? { id, label, onClick, raw: entry } : null; + }) + .filter(Boolean); + +const ELLIPSIS = { id: '__breadcrumbs_ellipsis__', label: '…', onClick: null, raw: null }; + +const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => { + const normalized = useMemo(() => normalizeEntries(entries), [entries]); + const containerRef = useRef(null); + const measurementRef = useRef(null); + const ellipsisRef = useRef(null); + const menuRef = useRef(null); + const [availableWidth, setAvailableWidth] = useState(null); + const [startIndex, setStartIndex] = useState(0); + const [showHiddenMenu, setShowHiddenMenu] = useState(false); + const [menuPosition, setMenuPosition] = useState(null); + + useEffect(() => { + setStartIndex(0); + setShowHiddenMenu(false); + setMenuPosition(null); + }, [normalized]); + + useEffect(() => { + const container = containerRef.current; + if (!container || typeof ResizeObserver === 'undefined') { + return undefined; + } + + const target = container.parentElement || container; + + const updateWidth = () => { + const nextWidth = target.getBoundingClientRect().width; + if (!nextWidth) { + return; + } + setAvailableWidth((prev) => (prev && Math.abs(prev - nextWidth) < 0.5 ? prev : nextWidth)); + }; + + updateWidth(); + + const observer = new ResizeObserver(updateWidth); + observer.observe(target); + return () => observer.disconnect(); + }, []); + + useLayoutEffect(() => { + if (!normalized.length) { + return; + } + + const container = containerRef.current; + const measurement = measurementRef.current; + if (!container || !measurement) { + return; + } + + const entryNodes = Array.from(measurement.querySelectorAll('[data-item-type="entry"]')); + if (!entryNodes.length) { + return; + } + + const separatorNodes = Array.from(measurement.querySelectorAll('[data-item-type="separator"]')); + const ellipsisNode = measurement.querySelector('[data-item-type="ellipsis"]'); + + const originalEntryDisplay = entryNodes.map((node) => node.style.display); + const originalSeparatorDisplay = separatorNodes.map((node) => node.style.display); + const originalEllipsisDisplay = ellipsisNode ? ellipsisNode.style.display : null; + + const widths = []; + + for (let start = 0; start < entryNodes.length; start += 1) { + entryNodes.forEach((node, index) => { + // Hide entries that fall before the visible window. + // eslint-disable-next-line no-param-reassign + node.style.display = index < start ? 'none' : ''; + }); + + separatorNodes.forEach((node) => { + const targetIndex = Number(node.getAttribute('data-target-index')); + // eslint-disable-next-line no-param-reassign + node.style.display = targetIndex < Math.max(start, 1) ? 'none' : ''; + }); + + if (ellipsisNode) { + // eslint-disable-next-line no-param-reassign + ellipsisNode.style.display = start > 0 ? '' : 'none'; + } + + widths[start] = measurement.getBoundingClientRect().width; + } + + entryNodes.forEach((node, index) => { + // eslint-disable-next-line no-param-reassign + node.style.display = originalEntryDisplay[index] ?? ''; + }); + + separatorNodes.forEach((node, index) => { + // eslint-disable-next-line no-param-reassign + node.style.display = originalSeparatorDisplay[index] ?? ''; + }); + + if (ellipsisNode) { + // eslint-disable-next-line no-param-reassign + ellipsisNode.style.display = originalEllipsisDisplay ?? 'none'; + } + + const available = availableWidth ?? container.clientWidth; + if (!available || !widths.length) { + return; + } + + const TOLERANCE = 1; + // Reserve a tiny buffer so the live trail doesn't oscillate when the + // container width barely fits; shrink the measured allowance a bit. + const adjustedAvailable = available * 0.98; + let nextStart = widths.length - 1; + for (let start = 0; start < widths.length; start += 1) { + if (widths[start] <= adjustedAvailable + TOLERANCE) { + nextStart = start; + break; + } + } + + if (nextStart !== startIndex) { + setStartIndex(nextStart); + } + }, [normalized, separator, availableWidth, startIndex]); + + useEffect(() => { + if (!showHiddenMenu) { + return undefined; + } + + if (typeof document === 'undefined') { + setShowHiddenMenu(false); + setMenuPosition(null); + return undefined; + } + + const handleGlobalInteraction = (event) => { + const ellipsisEl = ellipsisRef.current; + const menuEl = menuRef.current; + if ((ellipsisEl && ellipsisEl.contains(event.target)) + || (menuEl && menuEl.contains(event.target))) { + return; + } + setShowHiddenMenu(false); + setMenuPosition(null); + }; + + const handleKey = (event) => { + if (event.key === 'Escape') { + setShowHiddenMenu(false); + setMenuPosition(null); + } + }; + + document.addEventListener('mousedown', handleGlobalInteraction); + document.addEventListener('touchstart', handleGlobalInteraction, { passive: true }); + document.addEventListener('focusin', handleGlobalInteraction); + document.addEventListener('keydown', handleKey); + + return () => { + document.removeEventListener('mousedown', handleGlobalInteraction); + document.removeEventListener('touchstart', handleGlobalInteraction); + document.removeEventListener('focusin', handleGlobalInteraction); + document.removeEventListener('keydown', handleKey); + }; + }, [showHiddenMenu]); + + useEffect(() => { + if (!showHiddenMenu) { + return undefined; + } + + if (typeof window === 'undefined') { + setShowHiddenMenu(false); + setMenuPosition(null); + return undefined; + } + + const handleWindowChange = () => { + if (!ellipsisRef.current) { + setShowHiddenMenu(false); + setMenuPosition(null); + return; + } + + const rect = ellipsisRef.current.getBoundingClientRect(); + const menuWidth = 192; + const viewportWidth = window.innerWidth; + const left = Math.min(Math.max(rect.left, 8), Math.max(viewportWidth - menuWidth - 8, 8)); + setMenuPosition({ + top: rect.bottom + 6, + left, + minWidth: menuWidth, + }); + }; + + handleWindowChange(); + window.addEventListener('resize', handleWindowChange); + window.addEventListener('scroll', handleWindowChange, true); + + return () => { + window.removeEventListener('resize', handleWindowChange); + window.removeEventListener('scroll', handleWindowChange, true); + }; + }, [showHiddenMenu]); + + if (!normalized.length) { + return null; + } + + const sliceStart = Math.min(startIndex, Math.max(0, normalized.length - 1)); + const slice = normalized.slice(sliceStart); + const visibleEntries = sliceStart > 0 ? [ELLIPSIS, ...slice] : slice; + const hiddenEntries = sliceStart > 0 ? normalized.slice(0, sliceStart) : []; + const hasHiddenEntries = hiddenEntries.length > 0; + + const wrapperClassName = className + ? `breadcrumb-trail ${className}`.trim() + : 'breadcrumb-trail'; + + useEffect(() => { + if (!hasHiddenEntries && showHiddenMenu) { + setShowHiddenMenu(false); + setMenuPosition(null); + } + }, [hasHiddenEntries, showHiddenMenu]); + + return ( + <> + + {visibleEntries.map((entry, index) => { + const isEllipsis = entry.id === ELLIPSIS.id; + const isLast = index === visibleEntries.length - 1; + const commonProps = { + className: `breadcrumb-trail__link${isLast && !isEllipsis ? ' is-current' : ''}`, + title: entry.label, + 'aria-current': isLast && !isEllipsis ? 'page' : undefined, + }; + + if (isEllipsis) { + return ( + + {index > 0 ? ( + + ) : null} + + + + + ); + } + + const content = !entry.onClick || isLast + ? ( + + {entry.label} + + ) + : ( + + ); + + return ( + + {index > 0 ? ( + + ) : null} + {content} + + ); + })} + + + {showHiddenMenu && hasHiddenEntries && menuPosition && typeof document !== 'undefined' + ? createPortal( +
+
+ {hiddenEntries.map((hiddenEntry) => ( + + ))} +
+
, + document.body, + ) + : null} + + ); +}; + +const breadcrumbEntryShape = PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + name: PropTypes.string, + label: PropTypes.string, + title: PropTypes.string, + onClick: PropTypes.func, +}); + +BreadcrumbTrail.propTypes = { + entries: PropTypes.arrayOf(breadcrumbEntryShape), + className: PropTypes.string, + separator: PropTypes.string, +}; + +export default BreadcrumbTrail;