refactor
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
export const CORRESPONDENT_ROLES = ['sender', 'receiver', 'other'];
|
||||
|
||||
export default CORRESPONDENT_ROLES;
|
||||
@@ -107,14 +107,7 @@ function CorrespondentsPanel({
|
||||
return '0';
|
||||
}
|
||||
const total = typeof usage.total === 'number' ? usage.total : 0;
|
||||
const entries = usage.by_role ? Object.entries(usage.by_role) : [];
|
||||
if (!entries.length) {
|
||||
return total.toString();
|
||||
}
|
||||
const roleSummary = entries
|
||||
.map(([role, count]) => `${role}: ${count}`)
|
||||
.join(', ');
|
||||
return `${total} (${roleSummary})`;
|
||||
return total.toString();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,13 +14,10 @@ import { getTagColorStyle } from '../utils/colors';
|
||||
import { formatFileSize } from '../utils/format';
|
||||
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||
import { CORRESPONDENT_ROLES } from '../constants/correspondents';
|
||||
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
||||
|
||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||
|
||||
const normalizeRole = (role) => (role || '').toLowerCase();
|
||||
|
||||
const derivePreviewOrientation = (metadata) => {
|
||||
const width = Number(metadata?.width);
|
||||
const height = Number(metadata?.height);
|
||||
@@ -30,56 +27,41 @@ const derivePreviewOrientation = (metadata) => {
|
||||
return 'landscape';
|
||||
};
|
||||
|
||||
const formatRoleLabel = (role) => {
|
||||
const normalized = normalizeRole(role);
|
||||
if (!normalized) return 'Other';
|
||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
||||
};
|
||||
|
||||
const compareCorrespondents = (a, b) => {
|
||||
const indexA = CORRESPONDENT_ROLES.indexOf(a.role);
|
||||
const indexB = CORRESPONDENT_ROLES.indexOf(b.role);
|
||||
const rankedA = indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA;
|
||||
const rankedB = indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB;
|
||||
if (rankedA !== rankedB) {
|
||||
return rankedA - rankedB;
|
||||
}
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
};
|
||||
|
||||
const sortCorrespondents = (entries = []) =>
|
||||
entries
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name || '',
|
||||
role: normalizeRole(entry.role),
|
||||
count: entry.count,
|
||||
}))
|
||||
.sort(compareCorrespondents);
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
|
||||
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
|
||||
<div className="correspondent-list">
|
||||
{entries.length ? (
|
||||
entries.map((entry) => (
|
||||
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill">
|
||||
<span className="correspondent-pill__label">
|
||||
<strong>{formatRoleLabel(entry.role)}</strong>
|
||||
<span>
|
||||
{entry.name}
|
||||
{showCount && entry.count ? ` (${entry.count})` : ''}
|
||||
entries.map((entry, index) => {
|
||||
const key = entry.id ?? `${entry.name}-${index}`;
|
||||
return (
|
||||
<span key={key} className="correspondent-pill">
|
||||
<span className="correspondent-pill__label">
|
||||
<span>
|
||||
{entry.name}
|
||||
{showCount && entry.count ? ` (${entry.count})` : ''}
|
||||
</span>
|
||||
</span>
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="correspondent-pill__remove"
|
||||
onClick={() => onRemove(entry)}
|
||||
aria-label={`Remove ${entry.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="correspondent-pill__remove"
|
||||
onClick={() => onRemove(entry)}
|
||||
aria-label={`Remove ${entry.name} as ${formatRoleLabel(entry.role)}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
))
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="meta">No correspondents yet.</span>
|
||||
)}
|
||||
@@ -174,11 +156,9 @@ const CorrespondentSection = ({
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const nameInput = form.elements.correspondent;
|
||||
const roleSelect = form.elements.role;
|
||||
const value = nameInput.value.trim();
|
||||
const role = roleSelect.value;
|
||||
if (!value) return;
|
||||
onAdd({ name: value, role, input: nameInput });
|
||||
onAdd({ name: value, input: nameInput });
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
@@ -187,13 +167,6 @@ const CorrespondentSection = ({
|
||||
placeholder={addPlaceholder}
|
||||
list={datalistId}
|
||||
/>
|
||||
<select name="role" defaultValue={CORRESPONDENT_ROLES[0]}>
|
||||
{CORRESPONDENT_ROLES.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit">{addButtonLabel}</button>
|
||||
{datalistId ? (
|
||||
<datalist id={datalistId}>
|
||||
@@ -705,17 +678,14 @@ const DetailPanel = ({
|
||||
if (!doc?.id) return;
|
||||
(doc.correspondents || []).forEach((entry) => {
|
||||
if (!entry?.id) return;
|
||||
const normalizedRole = normalizeRole(entry.role);
|
||||
const key = `${entry.id}:${normalizedRole}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
if (!map.has(entry.id)) {
|
||||
map.set(entry.id, {
|
||||
id: entry.id,
|
||||
name: entry.name || '',
|
||||
role: normalizedRole,
|
||||
documentIds: new Set(),
|
||||
});
|
||||
}
|
||||
map.get(key).documentIds.add(doc.id);
|
||||
map.get(entry.id).documentIds.add(doc.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -723,23 +693,20 @@ const DetailPanel = ({
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
role: entry.role,
|
||||
documentIds: [...entry.documentIds],
|
||||
count: entry.documentIds.size,
|
||||
}))
|
||||
.sort(compareCorrespondents);
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
}, [selectedDocuments]);
|
||||
|
||||
const handleBulkCorrespondentRemove = useCallback(
|
||||
(entry) => {
|
||||
if (!entry?.id) return;
|
||||
const normalizedRole = normalizeRole(entry.role);
|
||||
if (onBulkCorrespondentRemove) {
|
||||
return onBulkCorrespondentRemove({
|
||||
assignments: [
|
||||
{
|
||||
correspondent_id: entry.id,
|
||||
role: normalizedRole,
|
||||
},
|
||||
],
|
||||
documentIds: entry.documentIds,
|
||||
@@ -750,11 +717,7 @@ const DetailPanel = ({
|
||||
const targets = entry.documentIds && entry.documentIds.length
|
||||
? entry.documentIds
|
||||
: selectedDocuments
|
||||
.filter((doc) =>
|
||||
(doc.correspondents || []).some(
|
||||
(item) => item.id === entry.id && normalizeRole(item.role) === normalizedRole,
|
||||
),
|
||||
)
|
||||
.filter((doc) => (doc.correspondents || []).some((item) => item.id === entry.id))
|
||||
.map((doc) => doc.id);
|
||||
|
||||
return Promise.all(
|
||||
@@ -762,12 +725,11 @@ const DetailPanel = ({
|
||||
onCorrespondentRemove({
|
||||
documentId,
|
||||
correspondentId: entry.id,
|
||||
role: normalizedRole,
|
||||
}),
|
||||
),
|
||||
).catch(() => {});
|
||||
},
|
||||
[bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
|
||||
[onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
|
||||
);
|
||||
|
||||
const openZoomPreview = useCallback((config) => {
|
||||
@@ -1158,14 +1120,12 @@ const DetailPanel = ({
|
||||
onCorrespondentRemove?.({
|
||||
documentId: singleDoc.id,
|
||||
correspondentId: entry.id,
|
||||
role: entry.role,
|
||||
})
|
||||
}
|
||||
onAdd={({ name, role, input }) =>
|
||||
onAdd={({ name, input }) =>
|
||||
onCorrespondentAdd?.({
|
||||
document: singleDoc,
|
||||
name,
|
||||
role,
|
||||
input,
|
||||
})
|
||||
}
|
||||
@@ -1310,8 +1270,8 @@ const DetailPanel = ({
|
||||
title="Correspondents"
|
||||
entries={bulkCorrespondents}
|
||||
onRemove={handleBulkCorrespondentRemove}
|
||||
onAdd={({ name, role, input }) =>
|
||||
onBulkCorrespondentAdd?.({ name, role, input })
|
||||
onAdd={({ name, input }) =>
|
||||
onBulkCorrespondentAdd?.({ name, input })
|
||||
}
|
||||
addPlaceholder="Add correspondent to selection"
|
||||
datalistId="correspondent-catalog-bulk"
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon, TrashIcon } from '../ui/icons';
|
||||
import {
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
ViewListIcon,
|
||||
ViewGridIcon,
|
||||
FolderIcon,
|
||||
TrashIcon,
|
||||
RefreshIcon,
|
||||
FolderPlusIcon,
|
||||
MinusVerticalIcon,
|
||||
ArrowUpIcon,
|
||||
} from '../ui/icons';
|
||||
|
||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||
@@ -39,7 +50,6 @@ const resolveCorrespondents = (doc) => {
|
||||
results.push({
|
||||
id,
|
||||
name,
|
||||
role: entry.role || null,
|
||||
key: id ?? `${name}-${index}`,
|
||||
});
|
||||
});
|
||||
@@ -1048,3 +1058,127 @@ const DocumentsTable = ({
|
||||
|
||||
export default DocumentsTable;
|
||||
export { DocumentThumbnailImage };
|
||||
|
||||
export const createDocumentsTableHeaderActions = ({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRequestCreateFolder,
|
||||
creatingFolder,
|
||||
onRefresh,
|
||||
onShowSkeuoWorkspace,
|
||||
}) => {
|
||||
const isGridView = viewMode === 'grid';
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const createDocumentsSurface = ({
|
||||
tableProps,
|
||||
parentBreadcrumb,
|
||||
onNavigateParent,
|
||||
renderSidebarToggle,
|
||||
}) => {
|
||||
const {
|
||||
currentFolderName,
|
||||
searchResults,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRequestCreateFolder,
|
||||
creatingFolder,
|
||||
onRefresh,
|
||||
onShowSkeuoWorkspace,
|
||||
} = tableProps;
|
||||
|
||||
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||
const subtitle = Array.isArray(searchResults)
|
||||
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
||||
: null;
|
||||
|
||||
const actions = createDocumentsTableHeaderActions({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRequestCreateFolder,
|
||||
creatingFolder,
|
||||
onRefresh,
|
||||
onShowSkeuoWorkspace,
|
||||
});
|
||||
|
||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||
const parentControl = parentBreadcrumb
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={onNavigateParent}
|
||||
aria-label="Go to parent folder"
|
||||
title="Go to parent folder"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</button>
|
||||
)
|
||||
: null;
|
||||
const leading = sidebarToggle || parentControl
|
||||
? (
|
||||
<>
|
||||
{sidebarToggle}
|
||||
{parentControl}
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
|
||||
return {
|
||||
key: 'documents',
|
||||
variant: 'documents',
|
||||
header: { title, subtitle, leading, actions },
|
||||
content: <DocumentsTable {...tableProps} showHeader={false} />,
|
||||
supportsDetail: true,
|
||||
};
|
||||
};
|
||||
|
||||
+166
-294
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||
import { formatFileSize } from '../utils/format';
|
||||
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
|
||||
|
||||
const PreviewWorkspace = ({
|
||||
document,
|
||||
@@ -42,3 +44,133 @@ const PreviewWorkspace = ({
|
||||
};
|
||||
|
||||
export default PreviewWorkspace;
|
||||
|
||||
export const createPreviewWorkspaceHeaderActions = ({
|
||||
document,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
onRegenerate,
|
||||
onClose,
|
||||
}) => {
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const downloadHref = document.current_version?.download_path
|
||||
? resolveApiPath(document.current_version.download_path)
|
||||
: null;
|
||||
|
||||
const ocrAsset = getDocumentAsset(document, 'ocr-text');
|
||||
const hasOcr = Boolean(ocrAsset);
|
||||
|
||||
const handleOcrClick = async () => {
|
||||
if (!ocrAsset) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ensured = await ensureAssetUrl(document.id, ocrAsset, { force: false });
|
||||
const entry = ensured || ocrAsset;
|
||||
const url = entry?.url
|
||||
|| resolveDocumentAssetUrl(document, '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.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{downloadHref ? (
|
||||
<a
|
||||
className="icon-button"
|
||||
href={downloadHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Download document"
|
||||
title="Download document"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</a>
|
||||
) : null}
|
||||
{hasOcr ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={handleOcrClick}
|
||||
aria-label="View OCR text"
|
||||
title="View OCR text"
|
||||
>
|
||||
<TextScanIcon />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={() => onRegenerate(document.id)}
|
||||
aria-label="Re-run analysis"
|
||||
title="Re-run analysis"
|
||||
>
|
||||
<AnalyzeIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={() => onClose?.()}
|
||||
aria-label="Close preview"
|
||||
title="Close preview"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const createPreviewSurface = ({
|
||||
document,
|
||||
previewEntry,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
onRegenerate,
|
||||
onClose,
|
||||
renderSidebarToggle,
|
||||
}) => {
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = document.title || document.original_name || 'Document preview';
|
||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||
const leading = sidebarToggle ? <>{sidebarToggle}</> : null;
|
||||
const header = {
|
||||
title,
|
||||
subtitle: null,
|
||||
leading,
|
||||
actions: createPreviewWorkspaceHeaderActions({
|
||||
document,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
onRegenerate,
|
||||
onClose,
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
key: 'preview',
|
||||
variant: 'preview',
|
||||
header,
|
||||
content: <PreviewWorkspace document={document} previewEntry={previewEntry} />,
|
||||
supportsDetail: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import React, {
|
||||
} from 'react';
|
||||
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
|
||||
import { useAssetNavigator } from './hooks/useAssetNavigator';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons';
|
||||
import { ArrowLeftIcon, ArrowRightIcon, RefreshIcon } from './ui/icons';
|
||||
import PreviewZoomOverlay from './detail/PreviewZoomOverlay';
|
||||
import { getTagColorStyle } from './utils/colors';
|
||||
import './skeuomorphic_ws.css';
|
||||
@@ -1785,3 +1785,43 @@ const SkeuomorphicWorkspace = ({
|
||||
};
|
||||
|
||||
export default SkeuomorphicWorkspace;
|
||||
|
||||
export const createSkeuomorphicSurface = ({ workspaceProps, renderSidebarToggle }) => {
|
||||
if (!workspaceProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { currentFolderName, searchResults, onRefresh, onExit } = workspaceProps;
|
||||
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||
const subtitle = Array.isArray(searchResults)
|
||||
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
||||
: null;
|
||||
|
||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||
const leading = sidebarToggle ? <>{sidebarToggle}</> : null;
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={onRefresh}
|
||||
aria-label="Refresh"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={onExit}>
|
||||
Back to List
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return {
|
||||
key: 'workspace',
|
||||
variant: 'workspace',
|
||||
header: { title, subtitle, leading, actions },
|
||||
content: <SkeuomorphicWorkspace {...workspaceProps} />,
|
||||
supportsDetail: false,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user