frontend cleanup
This commit is contained in:
+123
-117
@@ -13,8 +13,9 @@ import { getTagColorStyle } from '../utils/colors';
|
||||
import { formatFileSize } from '../utils/format';
|
||||
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||
import { describeDocumentSummary } from '../documents/documentSummary';
|
||||
import { createDocumentActionState } from '../documents/documentActions';
|
||||
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
||||
import { openOcrTextInNewTab } from '../utils/ocr';
|
||||
|
||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||
|
||||
@@ -314,14 +315,29 @@ const DetailPanel = ({
|
||||
[selectedDocuments],
|
||||
);
|
||||
|
||||
const singleDownloadHref = useMemo(() => {
|
||||
if (!singleDoc) return null;
|
||||
const downloadPath = singleDoc.current_version?.download_path;
|
||||
if (!downloadPath || !resolveApiPath) {
|
||||
return null;
|
||||
const { downloadHref: singleDownloadHref, hasOcr: singleHasOcr, openOcr } = useMemo(
|
||||
() =>
|
||||
createDocumentActionState({
|
||||
document: singleDoc,
|
||||
resolveApiPath,
|
||||
ensurePreviewData,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
}),
|
||||
[singleDoc, resolveApiPath, ensurePreviewData, ensureAssetUrl, getDocumentAsset],
|
||||
);
|
||||
|
||||
const detailSummary = useMemo(() => describeDocumentSummary(singleDoc), [singleDoc]);
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (selectedCount === 0) {
|
||||
return 'Document details';
|
||||
}
|
||||
return resolveApiPath(downloadPath);
|
||||
}, [singleDoc, resolveApiPath]);
|
||||
if (selectedCount === 1) {
|
||||
return detailSummary.title;
|
||||
}
|
||||
return `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
||||
}, [selectedCount, detailSummary]);
|
||||
|
||||
const [titleEditDocId, setTitleEditDocId] = useState(null);
|
||||
const [titleDraft, setTitleDraft] = useState('');
|
||||
@@ -403,27 +419,6 @@ const DetailPanel = ({
|
||||
[onPromoteSelection],
|
||||
);
|
||||
|
||||
const hasOcrAsset = useMemo(
|
||||
() => Boolean(singleDoc && getDocumentAsset(singleDoc, 'ocr-text')),
|
||||
[singleDoc, getDocumentAsset],
|
||||
);
|
||||
|
||||
const openOcr = useCallback(async () => {
|
||||
if (!singleDoc) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openOcrTextInNewTab({
|
||||
document: singleDoc,
|
||||
ensurePreviewData,
|
||||
getDocumentAsset,
|
||||
ensureAssetUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
/* noop */
|
||||
}
|
||||
}, [singleDoc, ensurePreviewData, getDocumentAsset, ensureAssetUrl]);
|
||||
|
||||
const singlePreviewNavigator = useAssetNavigator({
|
||||
document: singleDoc,
|
||||
assetType: 'preview',
|
||||
@@ -614,6 +609,90 @@ const DetailPanel = ({
|
||||
return segments;
|
||||
}, [singleDoc?.folder_id, resolveFolderPath]);
|
||||
|
||||
const folderLabel = detailSummary.folderLabel;
|
||||
|
||||
const folderDisplayNode = useMemo(() => {
|
||||
if (!singleDoc) {
|
||||
return folderLabel || '—';
|
||||
}
|
||||
if (!singleFolderPath?.length) {
|
||||
return folderLabel || '—';
|
||||
}
|
||||
return (
|
||||
<span className="detail-folder-path">
|
||||
{singleFolderPath.map((segment, index) => {
|
||||
const label = segment?.name || '…';
|
||||
const targetId = segment?.id || null;
|
||||
const key = `${targetId || label}-${index}`;
|
||||
const isClickable = Boolean(targetId) && typeof onFolderNavigate === 'function';
|
||||
const href = !isClickable
|
||||
? null
|
||||
: targetId === 'root'
|
||||
? '/documents'
|
||||
: `/documents/folder/${targetId}`;
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
{index > 0 ? <span className="detail-folder-path__separator">/</span> : null}
|
||||
{isClickable ? (
|
||||
<a
|
||||
href={href}
|
||||
className="detail-folder-path__link"
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onFolderNavigate(targetId);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<span className="detail-folder-path__segment">{label}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}, [singleDoc, singleFolderPath, folderLabel, onFolderNavigate]);
|
||||
|
||||
const detailInfoRows = useMemo(() => {
|
||||
if (!singleDoc) {
|
||||
return [];
|
||||
}
|
||||
const allowedKeys = new Set(['uploaded', 'size', 'type', 'issued', 'pages', 'created', 'updated', 'folder']);
|
||||
const rows = detailSummary.summaryRows
|
||||
.filter((row) => {
|
||||
if (!allowedKeys.has(row.key)) {
|
||||
return false;
|
||||
}
|
||||
if (row.key === 'pages') {
|
||||
return Number.isFinite(detailSummary.pageCount);
|
||||
}
|
||||
if (row.key === 'folder') {
|
||||
return Boolean(singleFolderPath?.length);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((row) => (row.key === 'folder' ? { ...row, value: folderDisplayNode } : row));
|
||||
|
||||
rows.push({
|
||||
key: 'original-name',
|
||||
label: 'Original filename',
|
||||
value: singleDoc.original_name || '—',
|
||||
});
|
||||
|
||||
return rows;
|
||||
}, [singleDoc, detailSummary, folderDisplayNode, singleFolderPath]);
|
||||
|
||||
const bulkCorrespondents = useMemo(() => {
|
||||
if (selectedDocuments.length <= 1) {
|
||||
const doc = selectedDocuments[0];
|
||||
@@ -890,20 +969,7 @@ const DetailPanel = ({
|
||||
|
||||
const displayName = singleDoc.title || singleDoc.original_name;
|
||||
const isEditingTitle = titleEditDocId === singleDoc.id;
|
||||
const sizeBytes = Number(singleDoc.current_version?.size_bytes) || 0;
|
||||
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : '—';
|
||||
const issuedAt = singleDoc.issued_at
|
||||
? new Date(singleDoc.issued_at).toLocaleString()
|
||||
: '—';
|
||||
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
|
||||
const pageCountRaw = singleDoc.current_version?.metadata?.page_count;
|
||||
const pageCountValue =
|
||||
typeof pageCountRaw === 'number'
|
||||
? pageCountRaw
|
||||
: pageCountRaw != null && pageCountRaw !== ''
|
||||
? Number.parseInt(pageCountRaw, 10)
|
||||
: null;
|
||||
const hasPageCount = Number.isFinite(pageCountValue) && pageCountValue >= 0;
|
||||
const metadata =
|
||||
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
|
||||
const effectiveCardinality = singleEffectiveCardinality;
|
||||
@@ -1014,76 +1080,17 @@ const DetailPanel = ({
|
||||
</div>
|
||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||
<div className="meta">
|
||||
<div>
|
||||
<strong>Uploaded:</strong>{' '}
|
||||
{singleDoc.uploaded_at ? new Date(singleDoc.uploaded_at).toLocaleString() : '—'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Size:</strong>{' '}
|
||||
{sizeLabel}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Type:</strong> {singleDoc.content_type || 'Unknown'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Issued:</strong> {issuedAt}
|
||||
</div>
|
||||
{hasPageCount ? (
|
||||
<div>
|
||||
<strong>Pages:</strong> {pageCountValue}
|
||||
</div>
|
||||
) : null}
|
||||
{singleFolderPath?.length ? (
|
||||
<div>
|
||||
<strong>Folder:</strong>{' '}
|
||||
<span className="detail-folder-path">
|
||||
{singleFolderPath.map((segment, index) => {
|
||||
const label = segment?.name || '…';
|
||||
const targetId = segment?.id || null;
|
||||
const key = `${targetId || label}-${index}`;
|
||||
const isClickable = Boolean(targetId) && typeof onFolderNavigate === 'function';
|
||||
const href = !isClickable
|
||||
? null
|
||||
: targetId === 'root'
|
||||
? '/documents'
|
||||
: `/documents/folder/${targetId}`;
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
{index > 0 ? <span className="detail-folder-path__separator">/</span> : null}
|
||||
{isClickable ? (
|
||||
<a
|
||||
href={href}
|
||||
className="detail-folder-path__link"
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onFolderNavigate(targetId);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<span className="detail-folder-path__segment">{label}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<strong>Original filename:</strong>{' '}
|
||||
{singleDoc.original_name}
|
||||
</div>
|
||||
{detailInfoRows.map((row) => {
|
||||
const rawValue = row.value;
|
||||
const displayValue =
|
||||
rawValue === null || rawValue === undefined || rawValue === '' ? '—' : rawValue;
|
||||
return (
|
||||
<div key={row.key}>
|
||||
<strong>{row.label}:</strong>{' '}
|
||||
{displayValue}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<TagSection
|
||||
title="Tags"
|
||||
@@ -1227,13 +1234,12 @@ const DetailPanel = ({
|
||||
};
|
||||
|
||||
const isBulkSelection = selectedCount > 1;
|
||||
const showOcrAction = Boolean(singleDoc && hasOcrAsset);
|
||||
const showOcrAction = Boolean(singleDoc && singleHasOcr);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="detail-panel panel">
|
||||
<div className="panel-header">
|
||||
<div className="panel-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
@@ -1258,7 +1264,8 @@ const DetailPanel = ({
|
||||
<WindowMaximizeIcon />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="spacer" />
|
||||
<h3 className="panel-header__title">{headerTitle}</h3>
|
||||
<div className="panel-actions__spacer" />
|
||||
{isBulkSelection && onBulkReanalyze ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1292,7 +1299,7 @@ const DetailPanel = ({
|
||||
className="icon-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openOcr();
|
||||
openOcr().catch(() => {});
|
||||
}}
|
||||
aria-label="View OCR text"
|
||||
title="View OCR text"
|
||||
@@ -1315,7 +1322,6 @@ const DetailPanel = ({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{selectedCount <= 1 ? renderSingle() : renderBulk()}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { openOcrTextInNewTab } from '../utils/ocr';
|
||||
|
||||
const asyncFalse = async () => false;
|
||||
|
||||
const resolveDocumentDownloadHref = (document, resolveApiPath) => {
|
||||
if (!document || typeof resolveApiPath !== 'function') {
|
||||
return null;
|
||||
}
|
||||
const downloadPath = document.current_version?.download_path;
|
||||
if (!downloadPath) {
|
||||
return null;
|
||||
}
|
||||
return resolveApiPath(downloadPath);
|
||||
};
|
||||
|
||||
const hasDocumentOcrAsset = (document, getDocumentAsset) => {
|
||||
if (!document || typeof getDocumentAsset !== 'function') {
|
||||
return false;
|
||||
}
|
||||
return Boolean(getDocumentAsset(document, 'ocr-text'));
|
||||
};
|
||||
|
||||
export const createDocumentActionState = ({
|
||||
document,
|
||||
resolveApiPath,
|
||||
ensurePreviewData,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
notifyApiError,
|
||||
ocrErrorMessage = 'Unable to open OCR text.',
|
||||
}) => {
|
||||
if (!document) {
|
||||
return {
|
||||
downloadHref: null,
|
||||
hasOcr: false,
|
||||
openOcr: asyncFalse,
|
||||
};
|
||||
}
|
||||
|
||||
const downloadHref = resolveDocumentDownloadHref(document, resolveApiPath);
|
||||
const hasOcr = hasDocumentOcrAsset(document, getDocumentAsset);
|
||||
|
||||
const openOcr = hasOcr
|
||||
? async () => {
|
||||
try {
|
||||
const success = await openOcrTextInNewTab({
|
||||
document,
|
||||
ensurePreviewData,
|
||||
getDocumentAsset,
|
||||
ensureAssetUrl,
|
||||
});
|
||||
if (!success && typeof notifyApiError === 'function') {
|
||||
notifyApiError(new Error('OCR text URL unavailable.'), ocrErrorMessage);
|
||||
}
|
||||
return success;
|
||||
} catch (error) {
|
||||
if (typeof notifyApiError === 'function') {
|
||||
notifyApiError(error, ocrErrorMessage);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
: asyncFalse;
|
||||
|
||||
return {
|
||||
downloadHref,
|
||||
hasOcr,
|
||||
openOcr,
|
||||
};
|
||||
};
|
||||
|
||||
export const documentActionsTestExports = {
|
||||
resolveDocumentDownloadHref,
|
||||
hasDocumentOcrAsset,
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { formatFileSize } from '../utils/format';
|
||||
|
||||
const defaultFormatDateTime = (value) => {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const coercePageCount = (metadata) => {
|
||||
const raw = metadata?.page_count;
|
||||
if (typeof raw === 'number') {
|
||||
return Number.isFinite(raw) && raw >= 0 ? raw : null;
|
||||
}
|
||||
if (raw != null && raw !== '') {
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const sanitizeTags = (tags) => {
|
||||
if (!Array.isArray(tags)) {
|
||||
return [];
|
||||
}
|
||||
return tags
|
||||
.filter((tag) => tag && (tag.label || tag.id))
|
||||
.map((tag) => ({
|
||||
id: tag.id,
|
||||
label: tag.label || '',
|
||||
color: tag.color || null,
|
||||
}));
|
||||
};
|
||||
|
||||
const sanitizeCorrespondents = (entries) => {
|
||||
if (!Array.isArray(entries)) {
|
||||
return [];
|
||||
}
|
||||
return entries
|
||||
.filter((entry) => entry && (entry.name || entry.id))
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name || '',
|
||||
count: entry.count,
|
||||
}));
|
||||
};
|
||||
|
||||
export const describeDocumentSummary = (document, options = {}) => {
|
||||
if (!document) {
|
||||
return {
|
||||
title: '',
|
||||
originalName: '',
|
||||
mimeTypeLabel: '—',
|
||||
sizeLabel: '—',
|
||||
uploadedAtLabel: '—',
|
||||
issuedAtLabel: '—',
|
||||
createdAtLabel: '—',
|
||||
updatedAtLabel: '—',
|
||||
pageCount: null,
|
||||
pageCountLabel: '—',
|
||||
folderLabel: null,
|
||||
tags: [],
|
||||
correspondents: [],
|
||||
tagsSummary: '—',
|
||||
correspondentsSummary: '—',
|
||||
summaryRows: [],
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
formatDateTime = defaultFormatDateTime,
|
||||
} = options;
|
||||
|
||||
const title = document.title || '';
|
||||
const originalName = document.original_name || '';
|
||||
const mimeTypeLabel = document.content_type || 'Unknown';
|
||||
|
||||
const sizeBytes = Number(document.current_version?.size_bytes);
|
||||
const sizeLabel = Number.isFinite(sizeBytes) && sizeBytes > 0 ? formatFileSize(sizeBytes) : '—';
|
||||
|
||||
const metadata = document.current_version?.metadata || null;
|
||||
const pageCount = coercePageCount(metadata);
|
||||
const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—';
|
||||
|
||||
const uploadedAtLabel = formatDateTime(document.uploaded_at);
|
||||
const issuedAtLabel = formatDateTime(document.issued_at);
|
||||
const createdAtLabel = formatDateTime(document.created_at);
|
||||
const updatedAtLabel = formatDateTime(document.updated_at);
|
||||
|
||||
const folderLabel = document.folder_path || document.folder_name || null;
|
||||
|
||||
const tags = sanitizeTags(document.tags);
|
||||
const correspondents = sanitizeCorrespondents(document.correspondents);
|
||||
|
||||
const tagLabels = tags.map((tag) => tag.label).filter(Boolean);
|
||||
const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean);
|
||||
|
||||
const tagsSummary = tagLabels.length ? tagLabels.join(', ') : '—';
|
||||
const correspondentsSummary = correspondentLabels.length
|
||||
? correspondentLabels.join(', ')
|
||||
: '—';
|
||||
|
||||
const summaryRows = [
|
||||
{ key: 'uploaded', label: 'Uploaded', value: uploadedAtLabel },
|
||||
{ key: 'size', label: 'Size', value: sizeLabel },
|
||||
{ key: 'type', label: 'Type', value: mimeTypeLabel },
|
||||
{ key: 'issued', label: 'Issued', value: issuedAtLabel },
|
||||
{ key: 'pages', label: 'Pages', value: pageCountLabel },
|
||||
{ key: 'created', label: 'Created', value: createdAtLabel },
|
||||
{ key: 'updated', label: 'Updated', value: updatedAtLabel },
|
||||
{ key: 'folder', label: 'Folder', value: folderLabel || '—' },
|
||||
{ key: 'tags', label: 'Tags', value: tagsSummary },
|
||||
{ key: 'correspondents', label: 'Correspondents', value: correspondentsSummary },
|
||||
];
|
||||
|
||||
return {
|
||||
title,
|
||||
originalName,
|
||||
mimeTypeLabel,
|
||||
sizeLabel,
|
||||
uploadedAtLabel,
|
||||
issuedAtLabel,
|
||||
createdAtLabel,
|
||||
updatedAtLabel,
|
||||
pageCount,
|
||||
pageCountLabel,
|
||||
folderLabel,
|
||||
tags,
|
||||
correspondents,
|
||||
tagsSummary,
|
||||
correspondentsSummary,
|
||||
summaryRows,
|
||||
};
|
||||
};
|
||||
+12
-11
@@ -5454,6 +5454,7 @@ const DocumentsRoute = () => {
|
||||
previewWorkspaceEntry,
|
||||
closeDocumentPreview,
|
||||
handleThumbnailRegeneration,
|
||||
ensurePreviewData,
|
||||
resolveApiPath,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
@@ -5543,6 +5544,7 @@ const DocumentsRoute = () => {
|
||||
document: previewWorkspaceDocument,
|
||||
previewEntry: previewWorkspaceEntry,
|
||||
ensureAssetUrl,
|
||||
ensurePreviewData,
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
@@ -5555,6 +5557,7 @@ const DocumentsRoute = () => {
|
||||
previewWorkspaceDocument,
|
||||
previewWorkspaceEntry,
|
||||
ensureAssetUrl,
|
||||
ensurePreviewData,
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
@@ -5611,17 +5614,15 @@ const DocumentsRoute = () => {
|
||||
<div className={mainContentClass}>
|
||||
{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}
|
||||
</h2>
|
||||
<div className="spacer" />
|
||||
{header.actions}
|
||||
</div>
|
||||
{header.leading}
|
||||
<h2 className="main-content__title">
|
||||
{header.title}
|
||||
{header.subtitle ? (
|
||||
<span className="main-content__subtitle">{header.subtitle}</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<div className="panel-actions__spacer" />
|
||||
{header.actions}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={bodyClass}>{surface.content}</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { formatFileSize } from '../utils/format';
|
||||
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
|
||||
import { openOcrTextInNewTab } from '../utils/ocr';
|
||||
import { describeDocumentSummary } from '../documents/documentSummary';
|
||||
import { createDocumentActionState } from '../documents/documentActions';
|
||||
|
||||
const PreviewWorkspace = ({
|
||||
document,
|
||||
@@ -12,53 +12,43 @@ const PreviewWorkspace = ({
|
||||
}
|
||||
|
||||
const title = document.title;
|
||||
const mime = document.content_type;
|
||||
const sizeBytes = Number(document.current_version?.size_bytes) || 0;
|
||||
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
|
||||
const summary = describeDocumentSummary(document);
|
||||
const baseSummaryRows = summary.summaryRows.filter((row) => {
|
||||
if (row.key === 'pages') {
|
||||
return Number.isFinite(summary.pageCount);
|
||||
}
|
||||
if (row.key === 'folder') {
|
||||
return Boolean(summary.folderLabel);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const summaryRows = [
|
||||
...baseSummaryRows,
|
||||
{
|
||||
key: 'original-name',
|
||||
label: 'Original filename',
|
||||
value: document.original_name || '—',
|
||||
},
|
||||
];
|
||||
const metadata =
|
||||
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
|
||||
const folderName = document.folder_path || document.folder_name || null;
|
||||
const issuedAt = document.issued_at || null;
|
||||
const createdAt = document.created_at || null;
|
||||
const updatedAt = document.updated_at || null;
|
||||
const tags = Array.isArray(document.tags) ? document.tags : [];
|
||||
const correspondents = Array.isArray(document.correspondents) ? document.correspondents : [];
|
||||
|
||||
const metadataSummary = (() => {
|
||||
const rows = [];
|
||||
if (mime) rows.push(['Type', mime]);
|
||||
if (sizeLabel) rows.push(['Size', sizeLabel]);
|
||||
if (issuedAt) rows.push(['Issued', new Date(issuedAt).toLocaleString()]);
|
||||
if (createdAt) rows.push(['Created', new Date(createdAt).toLocaleString()]);
|
||||
if (updatedAt) rows.push(['Updated', new Date(updatedAt).toLocaleString()]);
|
||||
if (folderName) rows.push(['Folder', folderName]);
|
||||
if (tags.length) {
|
||||
rows.push(['Tags', tags.map((tag) => tag.label).filter(Boolean).join(', ')]);
|
||||
}
|
||||
if (correspondents.length) {
|
||||
rows.push([
|
||||
'Correspondents',
|
||||
correspondents
|
||||
.map((entry) => entry.name)
|
||||
.filter(Boolean)
|
||||
.join(', '),
|
||||
]);
|
||||
}
|
||||
return rows;
|
||||
})();
|
||||
|
||||
return (
|
||||
<section className="preview-workspace">
|
||||
<aside className="preview-workspace__sidebar">
|
||||
<div className="preview-workspace__info">
|
||||
{metadataSummary.length ? (
|
||||
{summaryRows.length ? (
|
||||
<dl className="preview-workspace__summary">
|
||||
{metadataSummary.map(([label, value]) => (
|
||||
<div className="preview-workspace__summary-row" key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value || '—'}</dd>
|
||||
</div>
|
||||
))}
|
||||
{summaryRows.map(({ key, label, value }) => {
|
||||
const displayValue =
|
||||
value === null || value === undefined || value === '' ? '—' : value;
|
||||
return (
|
||||
<div className="preview-workspace__summary-row" key={key}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{displayValue}</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -89,6 +79,7 @@ export default PreviewWorkspace;
|
||||
export const createPreviewWorkspaceHeaderActions = ({
|
||||
document,
|
||||
ensureAssetUrl,
|
||||
ensurePreviewData,
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
@@ -98,27 +89,15 @@ export const createPreviewWorkspaceHeaderActions = ({
|
||||
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 () => {
|
||||
try {
|
||||
const success = await openOcrTextInNewTab({
|
||||
document,
|
||||
getDocumentAsset,
|
||||
ensureAssetUrl,
|
||||
});
|
||||
if (!success) {
|
||||
throw new Error('OCR text URL unavailable.');
|
||||
}
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Unable to open OCR text.');
|
||||
}
|
||||
};
|
||||
const { downloadHref, hasOcr, openOcr } = createDocumentActionState({
|
||||
document,
|
||||
resolveApiPath,
|
||||
ensurePreviewData,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
notifyApiError,
|
||||
ocrErrorMessage: 'Unable to open OCR text.',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -138,7 +117,9 @@ export const createPreviewWorkspaceHeaderActions = ({
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={handleOcrClick}
|
||||
onClick={() => {
|
||||
openOcr().catch(() => {});
|
||||
}}
|
||||
aria-label="View OCR text"
|
||||
title="View OCR text"
|
||||
>
|
||||
@@ -162,6 +143,7 @@ export const createPreviewSurface = ({
|
||||
document,
|
||||
previewEntry,
|
||||
ensureAssetUrl,
|
||||
ensurePreviewData,
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
@@ -174,7 +156,6 @@ export const createPreviewSurface = ({
|
||||
}
|
||||
|
||||
const title = document.title;
|
||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||
const closeButton = onClose
|
||||
? (
|
||||
<button
|
||||
@@ -188,7 +169,8 @@ export const createPreviewSurface = ({
|
||||
</button>
|
||||
)
|
||||
: null;
|
||||
const leading = sidebarToggle || closeButton
|
||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||
const leading = closeButton || sidebarToggle
|
||||
? (
|
||||
<>
|
||||
{sidebarToggle}
|
||||
@@ -203,6 +185,7 @@ export const createPreviewSurface = ({
|
||||
actions: createPreviewWorkspaceHeaderActions({
|
||||
document,
|
||||
ensureAssetUrl,
|
||||
ensurePreviewData,
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
|
||||
@@ -350,90 +350,88 @@ const Sidebar = ({
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="panel-header sidebar__header">
|
||||
<div className="panel-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`sidebar__title-button${tenantMenuOpen ? ' is-open' : ''}`}
|
||||
onClick={toggleTenantMenu}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={tenantMenuOpen}
|
||||
ref={tenantButtonRef}
|
||||
>
|
||||
<span className="sidebar__title">
|
||||
Papercrate
|
||||
{tenantName ? <span className="sidebar__tenant"> / {tenantName}</span> : null}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={`sidebar__title-chevron${tenantMenuOpen ? ' is-open' : ''}`}
|
||||
size={16}
|
||||
/>
|
||||
</button>
|
||||
{tenantMenuOpen ? (
|
||||
<div
|
||||
className={`menu${showTenantList ? '' : ' menu--simple'}`}
|
||||
ref={tenantMenuRef}
|
||||
role="menu"
|
||||
aria-label="Account menu"
|
||||
>
|
||||
{showTenantList ? (
|
||||
<>
|
||||
<div className="menu__heading">Switch tenant</div>
|
||||
<div className="menu__list">
|
||||
{tenants.map((tenant) => {
|
||||
const tenantId = tenant?.id || null;
|
||||
const isActive = tenantId === activeTenantId;
|
||||
const tenantLabel = tenant?.name;
|
||||
return (
|
||||
<button
|
||||
key={tenantId || tenantLabel}
|
||||
type="button"
|
||||
className={`menu__item${isActive ? ' active' : ''}`}
|
||||
onClick={() => handleTenantSelect(tenant)}
|
||||
role="menuitem"
|
||||
>
|
||||
<span className="menu__check-slot">
|
||||
{isActive ? <CheckIcon size={16} /> : null}
|
||||
</span>
|
||||
<span className="menu__label">{tenantLabel}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="menu__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="menu__settings"
|
||||
onClick={handleSettingsFromMenu}
|
||||
>
|
||||
<SettingsIcon size={16} />
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="menu__logout"
|
||||
onClick={handleLogoutFromMenu}
|
||||
>
|
||||
<LogoutIcon size={16} />
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="panel-actions__spacer" />
|
||||
{onCollapse ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`sidebar__title-button${tenantMenuOpen ? ' is-open' : ''}`}
|
||||
onClick={toggleTenantMenu}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={tenantMenuOpen}
|
||||
ref={tenantButtonRef}
|
||||
className="icon-button"
|
||||
onClick={onCollapse}
|
||||
aria-label="Collapse sidebar"
|
||||
title="Collapse sidebar"
|
||||
>
|
||||
<span className="sidebar__title">
|
||||
Papercrate
|
||||
{tenantName ? <span className="sidebar__tenant"> / {tenantName}</span> : null}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={`sidebar__title-chevron${tenantMenuOpen ? ' is-open' : ''}`}
|
||||
size={16}
|
||||
/>
|
||||
<ChevronsLeftIcon />
|
||||
</button>
|
||||
{tenantMenuOpen ? (
|
||||
<div
|
||||
className={`menu${showTenantList ? '' : ' menu--simple'}`}
|
||||
ref={tenantMenuRef}
|
||||
role="menu"
|
||||
aria-label="Account menu"
|
||||
>
|
||||
{showTenantList ? (
|
||||
<>
|
||||
<div className="menu__heading">Switch tenant</div>
|
||||
<div className="menu__list">
|
||||
{tenants.map((tenant) => {
|
||||
const tenantId = tenant?.id || null;
|
||||
const isActive = tenantId === activeTenantId;
|
||||
const tenantLabel = tenant?.name;
|
||||
return (
|
||||
<button
|
||||
key={tenantId || tenantLabel}
|
||||
type="button"
|
||||
className={`menu__item${isActive ? ' active' : ''}`}
|
||||
onClick={() => handleTenantSelect(tenant)}
|
||||
role="menuitem"
|
||||
>
|
||||
<span className="menu__check-slot">
|
||||
{isActive ? <CheckIcon size={16} /> : null}
|
||||
</span>
|
||||
<span className="menu__label">{tenantLabel}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="menu__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="menu__settings"
|
||||
onClick={handleSettingsFromMenu}
|
||||
>
|
||||
<SettingsIcon size={16} />
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="menu__logout"
|
||||
onClick={handleLogoutFromMenu}
|
||||
>
|
||||
<LogoutIcon size={16} />
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="spacer" />
|
||||
{onCollapse ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={onCollapse}
|
||||
aria-label="Collapse sidebar"
|
||||
title="Collapse sidebar"
|
||||
>
|
||||
<ChevronsLeftIcon />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="panel-body sidebar__body">
|
||||
{status && (
|
||||
|
||||
+34
-19
@@ -313,9 +313,9 @@ button.danger:hover:not([disabled]) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.panel-actions .icon-button,
|
||||
.panel-actions button,
|
||||
.panel-actions a.icon-button {
|
||||
.panel-header .icon-button,
|
||||
.panel-header button,
|
||||
.panel-header a.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
@@ -330,21 +330,21 @@ button.danger:hover:not([disabled]) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.panel-actions .icon-button:hover:not([disabled]),
|
||||
.panel-actions button:hover:not([disabled]),
|
||||
.panel-actions a.icon-button:hover {
|
||||
.panel-header .icon-button:hover:not([disabled]),
|
||||
.panel-header button:hover:not([disabled]),
|
||||
.panel-header a.icon-button:hover {
|
||||
background: var(--sidebar-hover-bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.panel-actions .icon-button.ghost,
|
||||
.panel-actions button.icon-button.ghost {
|
||||
.panel-header .icon-button.ghost,
|
||||
.panel-header button.icon-button.ghost {
|
||||
color: var(--muted);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.panel-actions .icon-button.ghost:hover:not([disabled]),
|
||||
.panel-actions button.icon-button.ghost:hover:not([disabled]) {
|
||||
.panel-header .icon-button.ghost:hover:not([disabled]),
|
||||
.panel-header button.icon-button.ghost:hover:not([disabled]) {
|
||||
color: var(--fg);
|
||||
background: var(--sidebar-hover-bg);
|
||||
}
|
||||
@@ -607,6 +607,8 @@ button.danger:hover:not([disabled]) {
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.main-content__subtitle {
|
||||
@@ -623,9 +625,9 @@ button.danger:hover:not([disabled]) {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar .panel-actions .icon,
|
||||
.main-content__header .panel-actions .icon,
|
||||
.detail-panel .panel-actions .icon {
|
||||
.sidebar .panel-header .icon,
|
||||
.main-content__header .icon,
|
||||
.detail-panel .panel-header .icon {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
}
|
||||
@@ -2207,18 +2209,35 @@ button.danger:hover:not([disabled]) {
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-header__title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.panel-actions__spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@@ -2227,10 +2246,6 @@ button.danger:hover:not([disabled]) {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-actions .spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.detail-panel .panel-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user