This commit is contained in:
2025-11-18 13:16:35 +01:00
parent 0b0a1a88fd
commit 0c37ef5578
20 changed files with 434 additions and 417 deletions
-23
View File
@@ -1,23 +0,0 @@
import React, { ReactNode } from 'react';
import Sidebar from '../sidebar/Sidebar';
import { useSidebarContext } from '../sidebar/SidebarContext';
import { usePanelManager } from './PanelManagerContext';
interface DocumentsLayoutProps {
sidebarProps?: Record<string, unknown>;
children?: ReactNode;
}
const DocumentsLayout: React.FC<DocumentsLayoutProps> = ({ sidebarProps = {}, children }) => {
const { collapsed } = useSidebarContext();
const { sidebarSuppressed } = usePanelManager();
const sidebarHidden = collapsed || sidebarSuppressed;
return (
<main className={`documents-main${sidebarHidden ? ' documents-main--sidebar-hidden' : ''}`}>
{!sidebarHidden ? <Sidebar {...sidebarProps} /> : null}
{children}
</main>
);
};
export default DocumentsLayout;
+22 -69
View File
@@ -2,17 +2,14 @@ import React, { useCallback, useEffect, useMemo } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAppShell } from '../appShellContext'; import { useAppShell } from '../appShellContext';
import DocumentsLayout from './DocumentsLayout';
import { useWorkspaceSurface } from './useWorkspaceSurface'; import { useWorkspaceSurface } from './useWorkspaceSurface';
import PanelHeader from '../ui/PanelHeader'; import { DocumentsHeaderBreadcrumb } from '../documents/panel/DocumentsPanelHeader';
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext'; import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
import { PanelManagerProvider, usePanelManager } from './PanelManagerContext'; import { PanelManagerProvider, usePanelManager } from './PanelManagerContext';
import Sidebar from '../sidebar/Sidebar';
type Identifier = string | number; type Identifier = string | number;
type Breadcrumb = { id?: Identifier; name?: string; label?: string; title?: string };
type EnsureAssetUrl = ( type EnsureAssetUrl = (
docId: Identifier, docId: Identifier,
asset: unknown, asset: unknown,
@@ -25,7 +22,8 @@ type ResolveApiPath = (path: string) => string;
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void; type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
interface DocumentsTableProps { interface DocumentsTableProps {
breadcrumbs?: Breadcrumb[] | null; breadcrumbs?: DocumentsHeaderBreadcrumb[] | null;
onBreadcrumbNavigate?: (crumb: DocumentsHeaderBreadcrumb) => void;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -88,7 +86,7 @@ const DocumentsRouteContent: React.FC = () => {
const sidebarHidden = sidebarCollapsed || sidebarSuppressed; const sidebarHidden = sidebarCollapsed || sidebarSuppressed;
const handleHeaderBreadcrumbClick = useCallback((crumb: Breadcrumb) => { const handleHeaderBreadcrumbClick = useCallback((crumb: DocumentsHeaderBreadcrumb) => {
if (!crumb || !crumb.id) { if (!crumb || !crumb.id) {
return; return;
} }
@@ -96,10 +94,16 @@ const DocumentsRouteContent: React.FC = () => {
navigate(target); navigate(target);
}, [navigate]); }, [navigate]);
const documentsTablePropsWithNav = useMemo(() => (
documentsTableProps
? { ...documentsTableProps, onBreadcrumbNavigate: handleHeaderBreadcrumbClick }
: null
), [documentsTableProps, handleHeaderBreadcrumbClick]);
const { surface } = useWorkspaceSurface({ const { surface } = useWorkspaceSurface({
sidebarHidden, sidebarHidden,
onExpandSidebar: expandSidebar, onExpandSidebar: expandSidebar,
documentsTableProps, documentsTableProps: documentsTablePropsWithNav,
detailPanelProps, detailPanelProps,
detailPanelOpen, detailPanelOpen,
previewWorkspaceDocument, previewWorkspaceDocument,
@@ -122,75 +126,24 @@ const DocumentsRouteContent: React.FC = () => {
if (!surface) { if (!surface) {
return ( return (
<DocumentsLayout sidebarProps={sidebarPropsWithActions}> <main className={`documents-main${sidebarHidden ? ' documents-main--sidebar-hidden' : ''}`}>
<div className="main-content main-content--documents"> {!sidebarHidden ? <Sidebar {...sidebarPropsWithActions} /> : null}
<div className="main-content__body main-content__body--documents" /> <div className="main-content">
<div className="main-content__body" />
</div> </div>
</DocumentsLayout> </main>
); );
} }
const variant = surface.variant || 'documents';
const surfaceDetail = (surface as { detail?: ReactNode }).detail || null; const surfaceDetail = (surface as { detail?: ReactNode }).detail || null;
const hasDetail = Boolean(surfaceDetail);
const mainContentClass = `main-content main-content--${variant}${
hasDetail ? ' main-content--has-detail' : ''
}`;
const bodyClass = `main-content__body main-content__body--${variant}${
hasDetail ? ' main-content__body--has-detail' : ''
}`;
const header = surface.header || null;
let headerTitle: React.ReactNode = null;
if (header) {
const breadcrumbEntries = Array.isArray(header.breadcrumbs) ? header.breadcrumbs.filter(Boolean) : [];
const lastIndex = breadcrumbEntries.length - 1;
const trailEntries = breadcrumbEntries.length
? breadcrumbEntries.map((crumb, index) => ({
id: crumb.id ?? index,
label: crumb.name ?? crumb.label ?? crumb.title ?? '',
onClick: index < lastIndex ? () => handleHeaderBreadcrumbClick(crumb) : null,
}))
: [{ id: 'current-location', label: header.title }];
headerTitle = (
<h2 className="main-content__title">
<BreadcrumbTrail
entries={trailEntries}
className="main-content__breadcrumbs"
separator="/"
/>
{header.subtitle ? (
<span className="main-content__subtitle">{header.subtitle}</span>
) : null}
</h2>
);
}
return ( return (
<DocumentsLayout sidebarProps={sidebarPropsWithActions}> <main className={`documents-main${sidebarHidden ? ' documents-main--sidebar-hidden' : ''}`}>
<div className={mainContentClass}> {!sidebarHidden ? <Sidebar {...sidebarPropsWithActions} /> : null}
{header ? ( <div className="main-content">
<> <div className="main-content__body">{surface.content}</div>
<div className="main-content__header-wrapper">
<PanelHeader
className="main-content__header"
leading={header.leading}
title={headerTitle}
titleTag="h2"
actions={header.actions}
/>
</div>
{(header.floatingActions)}
</>
) : null}
<div className={bodyClass}>{surface.content}</div>
{surfaceDetail} {surfaceDetail}
</div> </div>
</DocumentsLayout> </main>
); );
}; };
+6 -6
View File
@@ -98,12 +98,13 @@ const persistWidth = (panel: PanelKey, value: number): void => {
window.localStorage.setItem(STORAGE_KEYS[panel], String(Math.round(value))); window.localStorage.setItem(STORAGE_KEYS[panel], String(Math.round(value)));
}; };
const applyPanelWidthToRoot = (panel: PanelKey, width: number): void => { const applyPanelWidthToRoot = (panel: PanelKey, width: number, active: boolean): void => {
if (!Number.isFinite(width)) { if (!Number.isFinite(width)) {
return; return;
} }
const varName = panel === 'sidebar' ? '--sidebar-width' : '--detail-panel-width'; const varName = panel === 'sidebar' ? '--sidebar-width' : '--detail-panel-width';
document.documentElement.style.setProperty(varName, `${width}px`); const resolvedValue = panel === 'detail' && !active ? '0px' : `${width}px`;
document.documentElement.style.setProperty(varName, resolvedValue);
}; };
interface PanelManagerProviderProps { interface PanelManagerProviderProps {
@@ -132,13 +133,13 @@ export const PanelManagerProvider: React.FC<PanelManagerProviderProps> = ({ chil
useEffect(() => { useEffect(() => {
panelWidthsRef.current.sidebar = sidebarWidth; panelWidthsRef.current.sidebar = sidebarWidth;
applyPanelWidthToRoot('sidebar', sidebarWidth); applyPanelWidthToRoot('sidebar', sidebarWidth, true);
}, [sidebarWidth]); }, [sidebarWidth]);
useEffect(() => { useEffect(() => {
panelWidthsRef.current.detail = detailWidth; panelWidthsRef.current.detail = detailWidth;
applyPanelWidthToRoot('detail', detailWidth); applyPanelWidthToRoot('detail', detailWidth, detailPanelOpen);
}, [detailWidth]); }, [detailWidth, detailPanelOpen]);
const handlePanelLayoutChange = useCallback(( const handlePanelLayoutChange = useCallback((
panel: PanelKey, panel: PanelKey,
@@ -187,7 +188,6 @@ export const PanelManagerProvider: React.FC<PanelManagerProviderProps> = ({ chil
setDetailWidthState((prev) => (prev === clamped ? prev : clamped)); setDetailWidthState((prev) => (prev === clamped ? prev : clamped));
} }
panelWidthsRef.current[panel] = clamped; panelWidthsRef.current[panel] = clamped;
applyPanelWidthToRoot(panel, clamped);
if (commit) { if (commit) {
preferredPanelWidthsRef.current[panel] = clamped; preferredPanelWidthsRef.current[panel] = clamped;
persistWidth(panel, clamped); persistWidth(panel, clamped);
+68 -82
View File
@@ -9,7 +9,6 @@ import {
WarningIcon, WarningIcon,
BottombarCollapseIcon, BottombarCollapseIcon,
BottombarExpandIcon, BottombarExpandIcon,
ClearAllIcon,
} from '../ui/icons'; } from '../ui/icons';
import PanelHeader from '../ui/PanelHeader'; import PanelHeader from '../ui/PanelHeader';
@@ -86,11 +85,21 @@ const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProp
const hasActiveUploads = queue.some((item) => item.status === 'uploading' || item.status === 'pending'); const hasActiveUploads = queue.some((item) => item.status === 'uploading' || item.status === 'pending');
const handleClearQueue = () => { const handleDismissOverlay = () => {
if (!onClearQueue || hasActiveUploads) { if (!queue.length) {
setDismissed(true);
return; return;
} }
onClearQueue();
if (hasActiveUploads) {
const confirmed = window.confirm('Uploads are still running. Clear the queue and hide the overlay?');
if (!confirmed) {
return;
}
}
onClearQueue?.();
setDismissed(true);
}; };
if (!queue.length || dismissed) { if (!queue.length || dismissed) {
@@ -100,11 +109,10 @@ const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProp
return ( return (
<div className={`upload-queue-overlay${collapsed ? ' upload-queue-overlay--collapsed' : ''}`}> <div className={`upload-queue-overlay${collapsed ? ' upload-queue-overlay--collapsed' : ''}`}>
<PanelHeader <PanelHeader
className="upload-queue-overlay__header"
title={( title={(
<span className="upload-queue-overlay__title"> <span>
<span>Uploads</span> <span>Uploads</span>
<span className="upload-queue-overlay__summary">{summary}</span> <span className="panel-header__subtitle">{summary}</span>
</span> </span>
)} )}
actions={( actions={(
@@ -117,20 +125,11 @@ const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProp
> >
{collapsed ? <BottombarExpandIcon size={16} /> : <BottombarCollapseIcon size={16} />} {collapsed ? <BottombarExpandIcon size={16} /> : <BottombarCollapseIcon size={16} />}
</button> </button>
<button
type="button"
className="icon-button ghost"
onClick={handleClearQueue}
disabled={hasActiveUploads || !queue.length}
aria-label="Clear completed uploads"
>
<ClearAllIcon size={16} />
</button>
<button <button
type="button" type="button"
className="icon-button" className="icon-button"
onClick={() => setDismissed(true)} onClick={handleDismissOverlay}
aria-label="Hide upload queue" aria-label="Clear uploads and hide overlay"
> >
<CloseIcon size={16} /> <CloseIcon size={16} />
</button> </button>
@@ -138,82 +137,69 @@ const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProp
)} )}
/> />
{!collapsed ? ( {!collapsed ? (
<ul className="upload-queue-overlay__list"> <div className="upload-queue-overlay__body">
{[...queue] <ul className="upload-queue-overlay__list">
.slice() {[...queue]
.reverse() .slice()
.map((item) => { .reverse()
const meta = STATUS_META[item.status] || STATUS_META.pending; .map((item) => {
const fileLabel = item.name; const meta = STATUS_META[item.status] || STATUS_META.pending;
const duplicateLabel = item.status === 'duplicate' ? item.document?.title || null : null; const fileLabel = item.name;
const documentId = item.document?.id || item.conflictDocumentId || null; const documentTitle = item.document?.title || null;
const hasLink = Boolean(documentId); const duplicateLabel = item.status === 'duplicate' ? documentTitle : null;
const handleNavigate = () => { const documentId = item.document?.id || item.conflictDocumentId || null;
if (!documentId) { const hasLink = Boolean(documentId);
return; const handleNavigate = () => {
} if (!documentId) {
navigate(`/documents/${documentId}`); return;
}; }
return ( navigate(`/documents/${documentId}`);
<li key={item.id} className={`upload-queue-overlay__item upload-queue-overlay__item--${item.status}`}> };
<span className={`upload-queue-overlay__status upload-queue-overlay__status--${meta.tone}`}> return (
{meta.icon} <li key={item.id} className={`upload-queue-overlay__item upload-queue-overlay__item--${item.status}`}>
</span> <span className={`upload-queue-overlay__status upload-queue-overlay__status--${meta.tone}`}>
<div className="upload-queue-overlay__details"> {meta.icon}
{item.status === 'success' && hasLink ? ( </span>
<button <div className="upload-queue-overlay__details">
type="button" {item.status === 'success' && hasLink ? (
className="upload-queue-overlay__name-link"
onClick={handleNavigate}
>
{fileLabel}
</button>
) : (
<div className="upload-queue-overlay__name">
{fileLabel}
</div>
)}
<div className="upload-queue-overlay__meta-line">
{item.status === 'duplicate' && duplicateLabel ? (
<span className="upload-queue-overlay__meta-duplicate">
Duplicate of{' '}
<button <button
type="button" type="button"
className="upload-queue-overlay__meta-link" className="upload-queue-overlay__name-link"
onClick={handleNavigate} onClick={handleNavigate}
> >
{duplicateLabel} {fileLabel}
</button> </button>
</span> ) : (
) : item.status === 'error' && item.error ? ( <div className="upload-queue-overlay__name">
<span className="upload-queue-overlay__meta-error" title={item.error}> {fileLabel}
{item.error} </div>
</span> )}
) : ( <div className="upload-queue-overlay__meta-line">
<> {item.status === 'duplicate' && duplicateLabel ? (
<span>{meta.label}</span> <span className="upload-queue-overlay__meta-duplicate">
{documentId ? ( Duplicate of{' '}
<span className="upload-queue-overlay__meta-id">
(
<button <button
type="button" type="button"
className="upload-queue-overlay__meta-link" className="upload-queue-overlay__meta-link"
onClick={handleNavigate} onClick={handleNavigate}
disabled={!hasLink}
> >
{documentId} {duplicateLabel}
</button> </button>
)
</span> </span>
) : null} ) : item.status === 'error' && item.error ? (
</> <span className="upload-queue-overlay__meta-error" title={item.error}>
)} {item.error}
</div> </span>
</div> ) : (
</li> <span>{meta.label}</span>
); )}
})} </div>
</ul> </div>
</li>
);
})}
</ul>
</div>
) : null} ) : null}
</div> </div>
); );
+118 -120
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import DocumentsGrid from '../DocumentsGrid'; import DocumentsGrid from '../DocumentsGrid';
import DocumentsList from '../DocumentsList'; import DocumentsList from '../DocumentsList';
import DesktopWorkspace from '../../desktop/DesktopWorkspace'; import DesktopWorkspace from '../../desktop/DesktopWorkspace';
@@ -6,6 +7,10 @@ import { isTagTransferEvent } from '../tagTransfer';
import PreviewZoomOverlay from '../../detail/PreviewZoomOverlay'; import PreviewZoomOverlay from '../../detail/PreviewZoomOverlay';
import { isPointerModifierEvent, isPrimaryPointerEvent } from '../useEntryPointer'; import { isPointerModifierEvent, isPrimaryPointerEvent } from '../useEntryPointer';
import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext'; import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext';
import DocumentsPanelHeader, {
DocumentsPanelHeaderConfig,
DocumentsHeaderBreadcrumb,
} from './DocumentsPanelHeader';
const DEFAULT_GRID_ICON_SIZE = 144; const DEFAULT_GRID_ICON_SIZE = 144;
@@ -15,6 +20,8 @@ const EntryType = {
}; };
interface DocumentsPanelProps { interface DocumentsPanelProps {
headerConfig?: DocumentsPanelHeaderConfig;
onBreadcrumbNavigate?: (crumb: DocumentsHeaderBreadcrumb) => void;
[key: string]: any; [key: string]: any;
} }
@@ -23,6 +30,8 @@ const defaultGetDocumentAsset = (_doc?: unknown, _type?: string) => null;
export type PreviewEntryLike = { url?: string | null; contentType?: string | null }; export type PreviewEntryLike = { url?: string | null; contentType?: string | null };
const DocumentsPanel: React.FC<DocumentsPanelProps> = ({ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
headerConfig,
onBreadcrumbNavigate,
currentFolderName: _currentFolderName, currentFolderName: _currentFolderName,
breadcrumbs, breadcrumbs,
subfolders, subfolders,
@@ -56,7 +65,7 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
previewEntries, previewEntries,
ensureDownloadUrl, ensureDownloadUrl,
deskWorkspaceProps = null, deskWorkspaceProps = null,
}) => { }): ReactNode => {
const { const {
selectedEntries, selectedEntries,
focusedRowKey, focusedRowKey,
@@ -123,20 +132,8 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
() => new Set(activeCorrespondentIds || []), () => new Set(activeCorrespondentIds || []),
[activeCorrespondentIds], [activeCorrespondentIds],
); );
const scrollRef = useRef(null); const scrollRef = useRef<HTMLElement | null>(null);
const suppressDocumentClickRef = useRef(false); const suppressDocumentClickRef = useRef(false);
const [, forceVisibilityTick] = useState(0);
const lastScrollNodeRef = useRef(null);
const assignScrollRef = useCallback((node) => {
if (lastScrollNodeRef.current === node) {
return;
}
lastScrollNodeRef.current = node;
scrollRef.current = node;
if (node) {
forceVisibilityTick((value) => value + 1);
}
}, []);
const isGridView = viewMode === 'grid'; const isGridView = viewMode === 'grid';
const isDeskView = viewMode === 'desk'; const isDeskView = viewMode === 'desk';
@@ -577,44 +574,34 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading; const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading;
const renderBody = () => { const renderBody = () => {
if (isDeskView) { if (isDeskView) {
return ( return deskWorkspaceProps ? (
<div className="panel-section__body panel-section__body--desk"> <DesktopWorkspace {...deskWorkspaceProps} />
{deskWorkspaceProps ? ( ) : (
<DesktopWorkspace {...deskWorkspaceProps} /> <div className="empty-state empty-state--global">
) : ( Desk view is unavailable.
<div className="empty-state empty-state--global">
Desk view is unavailable.
</div>
)}
</div> </div>
); );
} }
if (showDefaultEmptyState) { if (showDefaultEmptyState) {
return ( return (
<div className="panel-section__body"> <div className="empty-state">
<div className="empty-state"> Drop files anywhere or onto a folder to upload documents.
Drop files anywhere or onto a folder to upload documents.
</div>
</div> </div>
); );
} }
if (showGridSearchEmptyState) { if (showGridSearchEmptyState) {
return ( return (
<div className="panel-section__body"> <div className="empty-state empty-state--global">
<div className="empty-state empty-state--global"> No documents match the current filters.
No documents match the current filters.
</div>
</div> </div>
); );
} }
if (showListSearchEmptyState) { if (showListSearchEmptyState) {
return ( return (
<div className="panel-section__body"> <div className="empty-state">No documents match the current filters.</div>
<div className="empty-state">No documents match the current filters.</div>
</div>
); );
} }
@@ -622,102 +609,113 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
return null; return null;
} }
if (isGridView) {
return (
<DocumentsGrid
entries={entries}
draggingDocumentIdsSet={draggingSet}
draggedFolderId={draggedFolderId}
onFolderClick={handleFolderClick}
onFolderSelect={onFolderSelect}
onFolderDragOver={onFolderDragOver}
onFolderDragLeave={onFolderDragLeave}
onFolderDrop={onFolderDrop}
onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd}
onDocumentClick={handleDocumentClick}
onDocumentActivate={handleDocumentActivate}
onDocumentDragStart={handleDocumentDragStartLocal}
onDocumentDragEnd={handleDocumentDragEndLocal}
onDocumentTagDragOver={handleDocumentTagDragOver}
onDocumentTagDragLeave={handleDocumentTagDragLeave}
onDocumentTagDrop={onDocumentTagDrop}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
gridIconSize={gridIconSize}
tagLookupById={tagLookupById}
onTagClick={onTagClick}
scrollRef={scrollRef}
onCorrespondentClick={onCorrespondentClick}
activeCorrespondentIdSet={activeCorrespondentIdSet}
onDocumentRename={onDocumentRename}
onFolderRename={onFolderRename}
/>
);
}
return ( return (
<div className="panel-section__body"> <DocumentsList
<div entries={entries}
ref={assignScrollRef} focusedRowKey={focusedRowKey}
className="documents-scroll" draggingDocumentIdsSet={draggingSet}
tabIndex={0} draggedFolderId={draggedFolderId}
onFocus={(event) => { ensureAssetUrl={ensureAssetUrl}
if (event.target === scrollRef.current) { getDocumentAsset={getDocumentAsset}
handlePanelFocus(); onFolderClick={handleFolderClick}
} onFolderSelect={onFolderSelect}
}} onFolderDragOver={onFolderDragOver}
onKeyDown={(event) => { onFolderDragLeave={onFolderDragLeave}
if (event.target !== scrollRef.current) { onFolderDrop={onFolderDrop}
return; onFolderDragStart={onFolderDragStart}
} onFolderDragEnd={onFolderDragEnd}
handlePanelKeyDown(event); onFolderRename={onFolderRename}
}} onDocumentClick={handleDocumentClick}
onClick={(event) => { onDocumentActivate={handleDocumentActivate}
if (event.target === event.currentTarget) { onDocumentDragStart={handleDocumentDragStartLocal}
clearSelection(); onDocumentDragEnd={handleDocumentDragEndLocal}
} onDocumentTagDragOver={handleDocumentTagDragOver}
}} onDocumentTagDragLeave={handleDocumentTagDragLeave}
aria-activedescendant={isGridView ? undefined : activeDescendantId} onDocumentTagDrop={handleDocumentTagDrop}
> onDocumentRename={onDocumentRename}
{isGridView ? ( tagLookupById={tagLookupById}
<DocumentsGrid onTagClick={onTagClick}
entries={entries} onCorrespondentClick={onCorrespondentClick}
draggingDocumentIdsSet={draggingSet} activeCorrespondentIdSet={activeCorrespondentIdSet}
draggedFolderId={draggedFolderId} scrollRef={scrollRef}
onFolderClick={handleFolderClick} />
onFolderSelect={onFolderSelect}
onFolderDragOver={onFolderDragOver}
onFolderDragLeave={onFolderDragLeave}
onFolderDrop={onFolderDrop}
onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd}
onDocumentClick={handleDocumentClick}
onDocumentActivate={handleDocumentActivate}
onDocumentDragStart={handleDocumentDragStartLocal}
onDocumentDragEnd={handleDocumentDragEndLocal}
onDocumentTagDragOver={handleDocumentTagDragOver}
onDocumentTagDragLeave={handleDocumentTagDragLeave}
onDocumentTagDrop={handleDocumentTagDrop}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
gridIconSize={gridIconSize}
tagLookupById={tagLookupById}
onTagClick={onTagClick}
scrollRef={scrollRef}
onCorrespondentClick={onCorrespondentClick}
activeCorrespondentIdSet={activeCorrespondentIdSet}
onDocumentRename={onDocumentRename}
onFolderRename={onFolderRename}
/>
) : (
<DocumentsList
entries={entries}
focusedRowKey={focusedRowKey}
draggingDocumentIdsSet={draggingSet}
draggedFolderId={draggedFolderId}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
onFolderClick={handleFolderClick}
onFolderSelect={onFolderSelect}
onFolderDragOver={onFolderDragOver}
onFolderDragLeave={onFolderDragLeave}
onFolderDrop={onFolderDrop}
onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd}
onFolderRename={onFolderRename}
onDocumentClick={handleDocumentClick}
onDocumentActivate={handleDocumentActivate}
onDocumentDragStart={handleDocumentDragStartLocal}
onDocumentDragEnd={handleDocumentDragEndLocal}
onDocumentTagDragOver={handleDocumentTagDragOver}
onDocumentTagDragLeave={handleDocumentTagDragLeave}
onDocumentTagDrop={handleDocumentTagDrop}
onDocumentRename={onDocumentRename}
tagLookupById={tagLookupById}
onTagClick={onTagClick}
onCorrespondentClick={onCorrespondentClick}
activeCorrespondentIdSet={activeCorrespondentIdSet}
scrollRef={scrollRef}
/>
)}
</div>
</div>
); );
}; };
const panelVariant = isDeskView ? 'desk' : isGridView ? 'grid' : 'list'; const panelVariant = isDeskView ? 'desk' : isGridView ? 'grid' : 'list';
const shouldHandlePanelInteractions = !isDeskView && showTableRows;
const handleSectionFocus = useCallback((event: React.FocusEvent<HTMLElement>) => {
if (!shouldHandlePanelInteractions || event.target !== event.currentTarget) {
return;
}
handlePanelFocus();
}, [shouldHandlePanelInteractions, handlePanelFocus]);
const handleSectionKeyDown = useCallback((event: React.KeyboardEvent<HTMLElement>) => {
if (!shouldHandlePanelInteractions || event.target !== event.currentTarget) {
return;
}
handlePanelKeyDown(event);
}, [shouldHandlePanelInteractions, handlePanelKeyDown]);
const handleSectionClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
if (!shouldHandlePanelInteractions || event.target !== event.currentTarget) {
return;
}
clearSelection();
}, [shouldHandlePanelInteractions, clearSelection]);
return ( return (
<> <>
{headerConfig ? (
<DocumentsPanelHeader
header={headerConfig}
onBreadcrumbClick={onBreadcrumbNavigate}
/>
) : null}
<section <section
ref={scrollRef}
className={`documents-panel documents-panel--view-${panelVariant}`} className={`documents-panel documents-panel--view-${panelVariant}`}
tabIndex={shouldHandlePanelInteractions ? 0 : undefined}
onFocus={handleSectionFocus}
onKeyDown={handleSectionKeyDown}
onClick={handleSectionClick}
aria-activedescendant={shouldHandlePanelInteractions && !isGridView ? activeDescendantId : undefined}
> >
{renderBody()} {renderBody()}
</section> </section>
@@ -0,0 +1,73 @@
import React from 'react';
import type { ReactNode } from 'react';
import PanelHeader from '../../ui/PanelHeader';
import BreadcrumbTrail from '../../ui/BreadcrumbTrail';
type Identifier = string | number;
export interface DocumentsHeaderBreadcrumb {
id?: Identifier;
name?: string;
label?: string;
title?: string;
}
export interface DocumentsPanelHeaderConfig {
title?: ReactNode;
subtitle?: ReactNode;
leading?: ReactNode;
actions?: ReactNode;
breadcrumbs?: DocumentsHeaderBreadcrumb[] | null;
floatingActions?: ReactNode;
}
interface DocumentsPanelHeaderProps {
header?: DocumentsPanelHeaderConfig | null;
onBreadcrumbClick?: (crumb: DocumentsHeaderBreadcrumb) => void;
}
const DocumentsPanelHeader: React.FC<DocumentsPanelHeaderProps> = ({
header,
onBreadcrumbClick,
}) => {
if (!header) {
return null;
}
const breadcrumbEntries = Array.isArray(header.breadcrumbs)
? header.breadcrumbs.filter(Boolean)
: [];
const lastIndex = breadcrumbEntries.length - 1;
const trailEntries = breadcrumbEntries.length
? breadcrumbEntries.map((crumb, index) => ({
id: crumb.id ?? index,
label: crumb.name ?? crumb.label ?? crumb.title ?? '',
onClick: index < lastIndex && onBreadcrumbClick
? () => onBreadcrumbClick(crumb)
: null,
}))
: [{ id: 'current-location', label: header.title }];
const headerTitle = (
<h2>
<BreadcrumbTrail entries={trailEntries} separator="/" />
{header.subtitle ? (
<span className="panel-header__subtitle">{header.subtitle}</span>
) : null}
</h2>
);
return (
<>
<PanelHeader
leading={header.leading}
title={headerTitle}
titleTag="h2"
actions={header.actions}
/>
{header.floatingActions}
</>
);
};
export default DocumentsPanelHeader;
@@ -111,7 +111,7 @@ export const createDocumentsTableHeaderActions = ({
<div className="view-toggle" role="group" aria-label="Change view"> <div className="view-toggle" role="group" aria-label="Change view">
<button <button
type="button" type="button"
className={`view-toggle__button${isListView ? ' active' : ''}`} className={`toggle-button${isListView ? ' active' : ''}`}
onClick={() => onViewModeChange?.('list')} onClick={() => onViewModeChange?.('list')}
aria-pressed={isListView} aria-pressed={isListView}
title="List view" title="List view"
@@ -120,7 +120,7 @@ export const createDocumentsTableHeaderActions = ({
</button> </button>
<button <button
type="button" type="button"
className={`view-toggle__button${isGridView ? ' active' : ''}`} className={`toggle-button${isGridView ? ' active' : ''}`}
onClick={() => onViewModeChange?.('grid')} onClick={() => onViewModeChange?.('grid')}
aria-pressed={isGridView} aria-pressed={isGridView}
title="Icons view" title="Icons view"
@@ -129,7 +129,7 @@ export const createDocumentsTableHeaderActions = ({
</button> </button>
<button <button
type="button" type="button"
className={`view-toggle__button${isDeskView ? ' active' : ''}`} className={`toggle-button${isDeskView ? ' active' : ''}`}
onClick={() => onViewModeChange?.('desk')} onClick={() => onViewModeChange?.('desk')}
aria-pressed={isDeskView} aria-pressed={isDeskView}
title="Desk view" title="Desk view"
@@ -50,7 +50,7 @@ const SortFieldQuickMenu: React.FC<SortFieldQuickMenuProps> = ({ sortField, onCh
className="documents-sort__quickmenu" className="documents-sort__quickmenu"
options={options} options={options}
onSelectOption={handleSelect} onSelectOption={handleSelect}
triggerClassName="view-toggle__button documents-sort__trigger quick-add__trigger" triggerClassName="toggle-button documents-sort__trigger quick-add__trigger"
triggerContent={( triggerContent={(
<span className="documents-sort__trigger-content"> <span className="documents-sort__trigger-content">
<span className="documents-sort__label">{label}</span> <span className="documents-sort__label">{label}</span>
@@ -2,6 +2,7 @@ import React, { ReactNode } from 'react';
import DocumentViewerPanel from '../../preview/DocumentViewerPanel'; import DocumentViewerPanel from '../../preview/DocumentViewerPanel';
import createWorkspaceSurfaceConfig from '../workspaceHeader'; import createWorkspaceSurfaceConfig from '../workspaceHeader';
import DocumentsPanel from './DocumentsPanel'; import DocumentsPanel from './DocumentsPanel';
import type { DocumentsPanelHeaderConfig } from './DocumentsPanelHeader';
import { SelectionFloatingPanel } from '../SelectionFloatingActions'; import { SelectionFloatingPanel } from '../SelectionFloatingActions';
import { createDocumentsTableHeaderActions } from './DocumentsToolbar'; import { createDocumentsTableHeaderActions } from './DocumentsToolbar';
@@ -100,6 +101,15 @@ const createDocumentsSurface = ({
})() })()
: null; : null;
const headerConfig: DocumentsPanelHeaderConfig = {
title,
subtitle,
leading: sidebarToggle,
actions,
breadcrumbs,
floatingActions,
};
return createWorkspaceSurfaceConfig({ return createWorkspaceSurfaceConfig({
key: isDeskView ? 'workspace' : 'documents', key: isDeskView ? 'workspace' : 'documents',
variant: isDeskView ? 'workspace' : 'documents', variant: isDeskView ? 'workspace' : 'documents',
@@ -113,6 +123,7 @@ const createDocumentsSurface = ({
content: ( content: (
<DocumentsPanel <DocumentsPanel
{...tableProps} {...tableProps}
headerConfig={headerConfig}
onInspectDocument={onInspectDocument} onInspectDocument={onInspectDocument}
/> />
), ),
@@ -48,6 +48,37 @@ interface DocumentViewerLayoutProps {
layoutMode?: LayoutMode; layoutMode?: LayoutMode;
} }
const AUDIO_EXTENSIONS = new Set([
'aac',
'aiff',
'flac',
'm4a',
'mp3',
'ogg',
'oga',
'opus',
'wav',
'weba',
]);
const VIDEO_EXTENSIONS = new Set([
'avi',
'mkv',
'mov',
'mp4',
'm4v',
'webm',
'wmv',
]);
const getFileExtension = (filename?: string | null) => {
if (!filename) {
return '';
}
const match = filename.toLowerCase().match(/\.([a-z0-9]+)$/);
return match ? match[1] : '';
};
const DocumentViewerLayout = ({ const DocumentViewerLayout = ({
document, document,
previewEntry, previewEntry,
@@ -73,9 +104,21 @@ const DocumentViewerLayout = ({
|| document.content_type || document.content_type
|| '') || '')
.toLowerCase(); .toLowerCase();
const normalizedFilename = previewEntry.filename
|| document.filename
|| document.original_name
|| '';
const fileExtension = getFileExtension(normalizedFilename);
const isImage = normalizedContentType.startsWith('image/'); const isImage = normalizedContentType.startsWith('image/');
const isPdf = normalizedContentType === 'application/pdf' const isPdf = normalizedContentType === 'application/pdf'
|| normalizedContentType === 'application/x-pdf'; || normalizedContentType === 'application/x-pdf';
const isAudio = normalizedContentType.startsWith('audio/')
|| AUDIO_EXTENSIONS.has(fileExtension);
const isVideo = normalizedContentType.startsWith('video/')
|| VIDEO_EXTENSIONS.has(fileExtension);
const mediaLabel = document.title
|| normalizedFilename
|| 'Document preview';
if (isImage) { if (isImage) {
return ( return (
@@ -101,6 +144,32 @@ const DocumentViewerLayout = ({
); );
} }
if (isAudio) {
return (
<audio
className="document-viewer__object document-viewer__object--audio"
controls
preload="metadata"
src={previewEntry.url}
aria-label={`Audio preview of ${mediaLabel}`}
>
</audio>
);
}
if (isVideo) {
return (
<video
className="document-viewer__object document-viewer__object--video"
controls
preload="metadata"
src={previewEntry.url}
aria-label={`Video preview of ${mediaLabel}`}
>
</video>
);
}
const displayContentType = document.content_type || previewEntry.contentType || 'this file type'; const displayContentType = document.content_type || previewEntry.contentType || 'this file type';
const displayFilename = previewEntry.filename const displayFilename = previewEntry.filename
|| document.filename || document.filename
-1
View File
@@ -814,7 +814,6 @@ const Sidebar: React.FC<SidebarProps> = ({
onChange={handleUploadInputChange} onChange={handleUploadInputChange}
/> />
<PanelHeader <PanelHeader
className="sidebar__header"
leading={( leading={(
<> <>
<button <button
@@ -63,6 +63,12 @@
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem; padding: 0.5rem;
min-width: 0; min-width: 0;
flex-shrink: 0;
}
.panel-header .icon {
width: 1.35rem;
height: 1.35rem;
} }
.panel-header__leading, .panel-header__leading,
+4 -9
View File
@@ -4,7 +4,7 @@
gap: 0.25rem; gap: 0.25rem;
} }
.view-toggle__button { .toggle-button {
border: 1px solid var(--border); border: 1px solid var(--border);
background: transparent; background: transparent;
color: var(--muted); color: var(--muted);
@@ -14,24 +14,19 @@
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
} }
.view-toggle__button:hover, .toggle-button:hover,
.view-toggle__button:focus-visible { .toggle-button:focus-visible {
background: var(--surface-subtle); background: var(--surface-subtle);
color: var(--fg); color: var(--fg);
outline: none; outline: none;
} }
.view-toggle__button.active { .toggle-button.active {
background: var(--accent); background: var(--accent);
color: var(--on-accent); color: var(--on-accent);
border-color: var(--accent); border-color: var(--accent);
} }
.view-toggle__icon {
width: 1.4rem;
height: 1.4rem;
}
.documents-actions__sort-group { .documents-actions__sort-group {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+9 -22
View File
@@ -2,10 +2,18 @@
padding: 0 0.75rem 0.75rem 0.75rem; padding: 0 0.75rem 0.75rem 0.75rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow-y: auto;
--documents-list-row-height: calc(48px + 0.2rem); --documents-list-row-height: calc(48px + 0.2rem);
} }
.documents-panel:focus-visible {
outline: 2px solid var(--selection-ring);
outline-offset: 2px;
border-radius: 0.25rem;
}
.tags-panel, .tags-panel,
.correspondents-panel { .correspondents-panel {
padding: 1.25rem; padding: 1.25rem;
@@ -53,12 +61,6 @@
overflow: hidden; overflow: hidden;
} }
.documents-panel .panel-section__body {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.panel-floating-region { .panel-floating-region {
position: sticky; position: sticky;
top: 0.5rem; top: 0.5rem;
@@ -221,25 +223,10 @@
} }
} }
.documents-panel .documents-scroll {
overflow-y: auto;
background: transparent;
min-height: 0;
padding-right: 0.3rem;
flex-grow: 1;
}
.documents-panel .documents-scroll:focus-visible {
outline: 2px solid var(--selection-ring);
outline-offset: 2px;
border-radius: 0.25rem;
}
.documents-panel table { .documents-panel table {
max-width: 100%; max-width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.88rem; font-size: 0.88rem;
margin-top: 0.4rem;
} }
.documents-panel thead th { .documents-panel thead th {
@@ -258,7 +245,7 @@
text-align: left; text-align: left;
} }
.documents-panel--view-grid .documents-scroll { .documents-panel--view-grid {
padding: 0.35rem 0.5rem 1rem; padding: 0.35rem 0.5rem 1rem;
} }
@@ -51,12 +51,13 @@
.breadcrumb-trail { .breadcrumb-trail {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.05rem;
flex-wrap: nowrap; flex-wrap: nowrap;
min-width: 0; min-width: 0;
flex: 1 1 auto; flex: 1 1 auto;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
margin-left: -0.25rem;
} }
.breadcrumb-trail--measure { .breadcrumb-trail--measure {
@@ -91,7 +92,8 @@
} }
button.breadcrumb-trail__link, button.breadcrumb-trail__link,
.breadcrumb-trail__ellipsis-button { .breadcrumb-trail__ellipsis-button,
.breadcrumb-trail__link.is-current {
padding: 0.25rem; padding: 0.25rem;
background: none; background: none;
border-radius: 0.25rem; border-radius: 0.25rem;
@@ -19,8 +19,3 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.panel-header .icon-button svg {
width: 1.41rem;
height: 1.41rem;
}
+19
View File
@@ -396,6 +396,25 @@
height: 100%; height: 100%;
} }
.document-viewer__object--audio,
.document-viewer__object--video {
width: 100%;
max-width: 100%;
margin: auto;
background: var(--surface-overlay, #000);
}
.document-viewer__object--audio {
height: auto;
background: transparent;
}
.document-viewer__object--video {
height: 100%;
max-height: 100%;
object-fit: contain;
}
.document-viewer__object:not(.document-viewer__object--image) { .document-viewer__object:not(.document-viewer__object--image) {
height: 100%; height: 100%;
} }
+8 -49
View File
@@ -1,5 +1,6 @@
.app-shell { .app-shell {
height: 100%; height: 100dvh;
height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
color: var(--fg); color: var(--fg);
@@ -7,10 +8,9 @@
.documents-main { .documents-main {
height: 100%;
flex: 1; flex: 1;
display: flex; display: flex;
min-height: 100vh;
min-height: 100dvh;
width: 100%; width: 100%;
position: relative; position: relative;
} }
@@ -22,6 +22,7 @@
.main-content { .main-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
margin: 0; margin: 0;
@@ -35,20 +36,10 @@
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
} }
.main-content__header {
padding: 0.5rem 1.25rem;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.main-content__header-wrapper {
position: relative;
}
.main-content__body { .main-content__body {
position: relative; position: relative;
flex: 1 1 auto; flex: 1 1 auto;
flex-direction: column;
display: flex; display: flex;
min-height: 0; min-height: 0;
} }
@@ -58,38 +49,19 @@
min-height: 0; min-height: 0;
} }
.main-content__body > *:not(.detail-panel) { .main-content__body--workspace.main-content__body > .desk-shell {
flex: 1 1 auto;
}
.main-content__body--workspace.main-content__body--has-detail > .desk-shell {
margin-right: calc(-1 * var(--detail-panel-width)); margin-right: calc(-1 * var(--detail-panel-width));
} }
.main-content--has-detail { .main-content {
padding-right: var(--detail-panel-width); padding-right: var(--detail-panel-width);
} }
.main-content__title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--fg);
}
.documents-main--sidebar-hidden { .documents-main--sidebar-hidden {
position: relative; position: relative;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
} }
.sidebar .panel-header .icon,
.main-content__header .icon,
.detail-panel .panel-header .icon {
width: 1.35rem;
height: 1.35rem;
}
.main-content__actions { .main-content__actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -102,25 +74,12 @@
color: var(--muted-subtle); color: var(--muted-subtle);
} }
.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 { .main-content__breadcrumbs {
flex: 1 1 auto; flex: 1 1 auto;
overflow: hidden; overflow: hidden;
} }
.main-content__subtitle { .panel-header__subtitle {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--muted); color: var(--muted);
font-weight: 400; font-weight: 400;
+7 -4
View File
@@ -134,6 +134,7 @@
background: var(--bg); background: var(--bg);
position: relative; position: relative;
z-index: 1000000; z-index: 1000000;
container-type: inline-size;
} }
.sidebar--resizing { .sidebar--resizing {
@@ -210,15 +211,17 @@
display: inline-block; display: inline-block;
} }
@container (max-width: 20rem) {
.sidebar__title-text {
display: none;
}
}
.sidebar__tenant { .sidebar__tenant {
color: var(--muted); color: var(--muted);
font-weight: 500; font-weight: 500;
} }
.sidebar__header {
position: relative;
}
.sidebar__title-button { .sidebar__title-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+6 -21
View File
@@ -1,7 +1,7 @@
.upload-queue-overlay { .upload-queue-overlay {
position: fixed; position: fixed;
bottom: 24px; bottom: 24px;
right: 24px; right: calc(24px + var(--detail-panel-width, 0px));
width: min(360px, calc(100vw - 32px)); width: min(360px, calc(100vw - 32px));
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -10,30 +10,15 @@
z-index: 400; z-index: 400;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem;
padding: 0.9rem;
color: var(--fg); color: var(--fg);
} }
.upload-queue-overlay__header { .upload-queue-overlay__body {
padding: 0; padding: 0.6rem 0.9rem 0.9rem;
border: none; display: flex;
} flex-direction: column;
gap: 0.6rem;
.upload-queue-overlay__title {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
font-size: 0.95rem;
min-width: 0;
}
.upload-queue-overlay__summary {
font-size: 0.8rem;
color: var(--muted);
white-space: nowrap;
} }
.upload-queue-overlay__controls { .upload-queue-overlay__controls {