This commit is contained in:
2025-10-28 01:18:35 +01:00
parent 1179f4fcd4
commit 7df9a6415a
17 changed files with 935 additions and 935 deletions
+166 -294
View File
@@ -25,29 +25,15 @@ import {
import './styles.css';
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from './asset_manager';
import useApiError from './hooks/useApiError';
import SkeuomorphicWorkspace from './skeuomorphic_ws';
import PreviewWorkspace from './preview/PreviewWorkspace';
import DetailPanel from './detail/DetailPanel';
import TagsPanel from './tags/TagsPanel';
import CorrespondentsPanel from './correspondents/CorrespondentsPanel';
import { CORRESPONDENT_ROLES } from './constants/correspondents';
import TagManager from './tag_manager';
import Sidebar from './sidebar/Sidebar';
import {
ChevronsRightIcon,
ChevronsLeftIcon,
ViewListIcon,
ViewGridIcon,
FolderPlusIcon,
RefreshIcon,
ArrowUpIcon,
MinusVerticalIcon,
DownloadIcon,
TextScanIcon,
AnalyzeIcon,
CloseIcon,
} from './ui/icons';
import DocumentsTable from './documents/DocumentsTable';
import { ChevronsRightIcon } from './ui/icons';
import { createDocumentsSurface } from './documents/DocumentsTable';
import { createPreviewSurface } from './preview/PreviewWorkspace';
import { createSkeuomorphicSurface } from './skeuomorphic_ws';
import { AppShellContext, useAppShell } from './appShellContext';
const runtimeApiBase =
@@ -416,6 +402,33 @@ const DocumentsLayout = ({ sidebarProps, children, sidebarCollapsed }) => (
</main>
);
const MainContentShell = ({
className,
header = {},
sidebarToggleButton = null,
children,
}) => {
const { title = '', subtitle = null, leading = null, actions = null } = header || {};
return (
<div className={className}>
<div className="panel-header main-content__header">
<div className="panel-actions main-content__actions">
{sidebarToggleButton}
{leading}
<h2 className="main-content__title">
{title}
{subtitle ? <span className="main-content__subtitle">{subtitle}</span> : null}
</h2>
<div className="spacer" />
{actions}
</div>
</div>
{children}
</div>
);
};
const AppLayout = () => {
const navigate = useNavigate();
const location = useLocation();
@@ -1856,15 +1869,15 @@ const AppLayout = () => {
);
async function handleDocumentCorrespondentAttach(
{ documentId, correspondentId, role },
{ documentId, correspondentId },
{ notify = true, refresh = true } = {},
) {
if (!documentId || !correspondentId || !role) {
throw new Error('Missing document, correspondent or role.');
if (!documentId || !correspondentId) {
throw new Error('Missing document or correspondent.');
}
try {
await api.post(`/documents/${documentId}/correspondents`, {
assignments: [{ correspondent_id: correspondentId, role }],
assignments: [{ correspondent_id: correspondentId }],
replace: false,
});
if (refresh) {
@@ -1882,16 +1895,14 @@ const AppLayout = () => {
}
async function handleCorrespondentRemove(
{ documentId, correspondentId, role },
{ documentId, correspondentId },
{ notify = true, refresh = true } = {},
) {
if (!documentId || !correspondentId || !role) {
throw new Error('Missing document, correspondent or role.');
if (!documentId || !correspondentId) {
throw new Error('Missing document or correspondent.');
}
try {
await api.delete(`/documents/${documentId}/correspondents/${correspondentId}`, {
params: { role },
});
await api.delete(`/documents/${documentId}/correspondents/${correspondentId}`);
if (refresh) {
await refreshCurrentFolder();
}
@@ -1907,7 +1918,7 @@ const AppLayout = () => {
}
const handleCorrespondentAdd = useCallback(
async ({ document, name, role, input }) => {
async ({ document, name, input }) => {
if (!document?.id) {
throw new Error('Missing document for correspondent assignment.');
}
@@ -1916,11 +1927,6 @@ const AppLayout = () => {
setStatusMessage('Correspondent name is required.', 'error');
return;
}
const normalizedRole = (role || '').trim().toLowerCase();
if (!CORRESPONDENT_ROLES.includes(normalizedRole)) {
setStatusMessage('Select a valid correspondent role.', 'error');
return;
}
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
if (!target) {
@@ -1940,7 +1946,6 @@ const AppLayout = () => {
await handleDocumentCorrespondentAttach({
documentId: document.id,
correspondentId: target.id,
role: normalizedRole,
});
if (input) {
input.value = '';
@@ -2228,7 +2233,7 @@ const AppLayout = () => {
}, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]);
const handleBulkCorrespondentAdd = useCallback(
async ({ name, role, input }) => {
async ({ name, input }) => {
const trimmed = (name || '').trim();
if (!trimmed) {
setStatusMessage('Correspondent name is required.', 'error');
@@ -2238,11 +2243,6 @@ const AppLayout = () => {
setStatusMessage('Select documents before assigning correspondents.', 'error');
return;
}
const normalizedRole = (role || '').trim().toLowerCase();
if (!CORRESPONDENT_ROLES.includes(normalizedRole)) {
setStatusMessage('Select a valid correspondent role.', 'error');
return;
}
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
if (!target) {
@@ -2263,7 +2263,6 @@ const AppLayout = () => {
assignments: [
{
correspondent_id: target.id,
role: normalizedRole,
},
],
action: 'add',
@@ -2318,7 +2317,6 @@ const AppLayout = () => {
const normalizedAssignments = assignments.map((entry) => ({
correspondent_id: entry.correspondent_id,
role: (entry.role || '').toLowerCase(),
}));
const response = await api.post('/documents/bulk/correspondents', {
@@ -5414,7 +5412,6 @@ const DocumentsRoute = () => {
skeuoWorkspaceProps,
openTagsModal,
openCorrespondentsModal,
exitSkeuoWorkspace,
previewWorkspaceDocument,
previewWorkspaceEntry,
closeDocumentPreview,
@@ -5440,281 +5437,156 @@ const DocumentsRoute = () => {
[sidebarProps, openTagsModal, openCorrespondentsModal, collapseSidebar],
);
const {
currentFolderName,
viewMode,
onViewModeChange,
onRequestCreateFolder,
creatingFolder,
onRefresh,
onShowSkeuoWorkspace,
searchResults,
breadcrumbs,
} = documentsTableProps;
const breadcrumbs = documentsTableProps?.breadcrumbs || null;
const parentBreadcrumb = useMemo(() => {
if (!Array.isArray(breadcrumbs) || breadcrumbs.length <= 1) {
return null;
}
return breadcrumbs[breadcrumbs.length - 2];
}, [breadcrumbs]);
const handleNavigateParent = useCallback(() => {
if (!parentBreadcrumb) {
return;
}
const target = parentBreadcrumb.id === 'root'
? '/documents'
: `/documents/folder/${parentBreadcrumb.id}`;
navigate(target);
}, [navigate, parentBreadcrumb]);
const isWorkspace = workspaceMode === 'skeuo';
const isPreviewWorkspace = Boolean(previewWorkspaceDocument);
const showPreviewWorkspace = !isWorkspace && isPreviewWorkspace;
const isGridView = viewMode === 'grid';
const showingSearchResults = Array.isArray(searchResults);
let headerTitle;
let headerSubtitle = null;
if (showPreviewWorkspace) {
const previewTitle = previewWorkspaceDocument?.title
|| previewWorkspaceDocument?.original_name
|| 'Document preview';
headerTitle = previewTitle;
} else if (showingSearchResults) {
headerTitle = 'Search results';
headerSubtitle = `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`;
} else {
headerTitle = currentFolderName;
}
const parentBreadcrumb = breadcrumbs && breadcrumbs.length > 1 ? breadcrumbs[breadcrumbs.length - 2] : null;
const handleNavigateParent = parentBreadcrumb
? () => {
const target = parentBreadcrumb.id === 'root'
? '/documents'
: `/documents/folder/${parentBreadcrumb.id}`;
navigate(target);
}
: null;
const detailHasContent = !isWorkspace && !showPreviewWorkspace
&& detailPanelProps.selectedDocuments?.length > 0;
const toggleSidebar = sidebarCollapsed ? expandSidebar : collapseSidebar;
const ToggleIcon = sidebarCollapsed ? ChevronsRightIcon : ChevronsLeftIcon;
const toggleLabel = sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar';
const mainContentClass = (() => {
if (isWorkspace) {
return 'main-content main-content--workspace';
}
if (showPreviewWorkspace) {
return 'main-content main-content--preview';
}
return `main-content main-content--documents${
detailHasContent ? ' main-content--has-detail' : ''
}`;
})();
const bodyClass = (() => {
if (isWorkspace) {
return 'main-content__body main-content__body--workspace';
}
if (showPreviewWorkspace) {
return 'main-content__body main-content__body--preview';
}
return `main-content__body main-content__body--documents${
detailHasContent ? ' main-content__body--has-detail' : ''
}`;
})();
const previewDownloadHref = useMemo(() => {
if (!previewWorkspaceDocument?.current_version?.download_path) {
const renderSidebarToggle = useCallback(() => {
if (!sidebarCollapsed) {
return null;
}
return resolveApiPath(previewWorkspaceDocument.current_version.download_path);
}, [previewWorkspaceDocument, resolveApiPath]);
return (
<button
type="button"
className="icon-button ghost"
onClick={expandSidebar}
aria-label="Expand sidebar"
title="Expand sidebar"
>
<ChevronsRightIcon />
</button>
);
}, [sidebarCollapsed, expandSidebar]);
const previewHasOcr = useMemo(
() => Boolean(
previewWorkspaceDocument && getDocumentAsset(previewWorkspaceDocument, 'ocr-text'),
),
[previewWorkspaceDocument, getDocumentAsset],
);
const handlePreviewOcr = useCallback(async () => {
if (!previewWorkspaceDocument) {
return;
}
const asset = getDocumentAsset(previewWorkspaceDocument, 'ocr-text');
if (!asset) {
return;
}
try {
const ensured = await ensureAssetUrl(previewWorkspaceDocument.id, asset, { force: false });
const entry = ensured || asset;
const url = entry?.url
|| resolveDocumentAssetUrl(previewWorkspaceDocument, 'ocr-text', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
if (!url) {
throw new Error('OCR text URL unavailable.');
}
window.open(url, '_blank', 'noopener,noreferrer');
} catch (error) {
notifyApiError(error, 'Unable to open OCR text.');
const documentsSurface = useMemo(() => {
if (!documentsTableProps) {
return null;
}
return createDocumentsSurface({
tableProps: documentsTableProps,
parentBreadcrumb,
onNavigateParent: parentBreadcrumb ? handleNavigateParent : null,
renderSidebarToggle,
});
}, [
documentsTableProps,
parentBreadcrumb,
handleNavigateParent,
renderSidebarToggle,
]);
const previewSurface = useMemo(() => {
if (!showPreviewWorkspace || !previewWorkspaceDocument) {
return null;
}
return createPreviewSurface({
document: previewWorkspaceDocument,
previewEntry: previewWorkspaceEntry,
ensureAssetUrl,
getDocumentAsset,
resolveApiPath,
notifyApiError,
onRegenerate: handleThumbnailRegeneration,
onClose: closeDocumentPreview,
renderSidebarToggle,
});
}, [
showPreviewWorkspace,
previewWorkspaceDocument,
previewWorkspaceEntry,
ensureAssetUrl,
getDocumentAsset,
resolveApiPath,
notifyApiError,
handleThumbnailRegeneration,
closeDocumentPreview,
renderSidebarToggle,
]);
const workspaceSurface = useMemo(() => {
if (!isWorkspace) {
return null;
}
return createSkeuomorphicSurface({
workspaceProps: skeuoWorkspaceProps,
renderSidebarToggle,
});
}, [isWorkspace, skeuoWorkspaceProps, renderSidebarToggle]);
const surface = showPreviewWorkspace
? previewSurface
: isWorkspace
? workspaceSurface
: documentsSurface;
if (!surface) {
return (
<DocumentsLayout
sidebarProps={sidebarPropsWithActions}
sidebarCollapsed={sidebarCollapsed}
>
<div className="main-content main-content--documents">
<div className="main-content__body main-content__body--documents" />
</div>
</DocumentsLayout>
);
}
const supportsDetail = Boolean(surface.supportsDetail);
const detailCount = detailPanelProps.selectedDocuments?.length || 0;
const showDetailPanel = supportsDetail && detailCount > 0;
const variant = surface.variant || 'documents';
const mainContentClass = `main-content main-content--${variant}${
showDetailPanel ? ' main-content--has-detail' : ''
}`;
const bodyClass = `main-content__body main-content__body--${variant}${
showDetailPanel ? ' main-content__body--has-detail' : ''
}`;
const header = surface.header || null;
return (
<DocumentsLayout
sidebarProps={sidebarPropsWithActions}
sidebarCollapsed={sidebarCollapsed}
>
<div className={mainContentClass}>
<div className="panel-header main-content__header">
<div className="panel-actions main-content__actions">
{sidebarCollapsed ? (
<button
type="button"
className="icon-button ghost"
onClick={toggleSidebar}
aria-label={toggleLabel}
title={toggleLabel}
>
<ToggleIcon />
</button>
) : null}
{parentBreadcrumb && !showPreviewWorkspace ? (
<button
type="button"
className="icon-button ghost"
onClick={handleNavigateParent}
aria-label="Go to parent folder"
title="Go to parent folder"
>
<ArrowUpIcon />
</button>
) : null}
<h2 className="main-content__title">
{headerTitle}
{headerSubtitle ? (
<span className="main-content__subtitle">{headerSubtitle}</span>
) : null}
</h2>
<div className="spacer" />
{isWorkspace ? (
<>
<button
type="button"
className="icon-button ghost"
onClick={skeuoWorkspaceProps.onRefresh}
aria-label="Refresh"
title="Refresh"
>
<RefreshIcon />
</button>
<button type="button" className="secondary" onClick={exitSkeuoWorkspace}>
Back to List
</button>
</>
) : showPreviewWorkspace ? (
<>
{previewDownloadHref ? (
<a
className="icon-button"
href={previewDownloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Download document"
title="Download document"
>
<DownloadIcon />
</a>
{header ? (
<div className="panel-header main-content__header">
<div className="panel-actions main-content__actions">
{header.leading}
<h2 className="main-content__title">
{header.title}
{header.subtitle ? (
<span className="main-content__subtitle">{header.subtitle}</span>
) : null}
{previewHasOcr ? (
<button
type="button"
className="icon-button ghost"
onClick={handlePreviewOcr}
aria-label="View OCR text"
title="View OCR text"
>
<TextScanIcon />
</button>
) : null}
<button
type="button"
className="icon-button ghost"
onClick={() => handleThumbnailRegeneration(previewWorkspaceDocument.id)}
aria-label="Re-run analysis"
title="Re-run analysis"
>
<AnalyzeIcon />
</button>
<button
type="button"
className="icon-button ghost"
onClick={() => closeDocumentPreview()}
aria-label="Close preview"
title="Close preview"
>
<CloseIcon />
</button>
</>
) : (
<>
<div className="view-toggle" role="group" aria-label="Change view">
<button
type="button"
className={`view-toggle__button${isGridView ? '' : ' active'}`}
onClick={() => onViewModeChange?.('list')}
aria-pressed={!isGridView}
title="List view"
>
<ViewListIcon className="view-toggle__icon" size={18} />
</button>
<button
type="button"
className={`view-toggle__button${isGridView ? ' active' : ''}`}
onClick={() => onViewModeChange?.('grid')}
aria-pressed={isGridView}
title="Icons view"
>
<ViewGridIcon className="view-toggle__icon" size={18} />
</button>
</div>
<span className="main-content__actions-divider" aria-hidden="true">
<MinusVerticalIcon />
</span>
<button
type="button"
className="icon-button ghost"
onClick={onRequestCreateFolder}
disabled={creatingFolder}
aria-label={creatingFolder ? 'Creating folder…' : 'Create folder'}
title={creatingFolder ? 'Creating folder…' : 'Create folder'}
>
<FolderPlusIcon />
</button>
<button
type="button"
className="icon-button ghost"
onClick={onRefresh}
aria-label="Refresh"
title="Refresh"
>
<RefreshIcon />
</button>
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
Desk View
</button>
</>
)}
</h2>
<div className="spacer" />
{header.actions}
</div>
</div>
</div>
<div className={bodyClass}>
{isWorkspace ? (
<SkeuomorphicWorkspace {...skeuoWorkspaceProps} />
) : showPreviewWorkspace ? (
<PreviewWorkspace
document={previewWorkspaceDocument}
previewEntry={previewWorkspaceEntry}
/>
) : (
<DocumentsTable {...documentsTableProps} showHeader={false} />
)}
</div>
{!isWorkspace && !showPreviewWorkspace && detailHasContent ? (
<DetailPanel {...detailPanelProps} />
) : null}
<div className={bodyClass}>{surface.content}</div>
{showDetailPanel ? <DetailPanel {...detailPanelProps} /> : null}
</div>
</DocumentsLayout>
);