detailpanel
This commit is contained in:
@@ -1859,7 +1859,7 @@ const AppLayout = () => {
|
||||
);
|
||||
|
||||
const handleCorrespondentAdd = useCallback(
|
||||
async ({ document, name, input }) => {
|
||||
async ({ document, name, input = null, option = null }) => {
|
||||
if (!document?.id) {
|
||||
throw new Error('Missing document for correspondent assignment.');
|
||||
}
|
||||
@@ -1869,7 +1869,12 @@ const AppLayout = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
||||
let target = null;
|
||||
if (option && option.id) {
|
||||
target = correspondentLookupByName.get(trimmed.toLowerCase()) || option;
|
||||
} else {
|
||||
target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
||||
}
|
||||
if (!target) {
|
||||
try {
|
||||
target = await handleCorrespondentCreate({ name: trimmed });
|
||||
@@ -3585,6 +3590,35 @@ const AppLayout = () => {
|
||||
[notifyApiError, setStatusMessage, updateDocumentCaches, extractDocumentFromResponse],
|
||||
);
|
||||
|
||||
const handleDocumentIssuedUpdate = useCallback(
|
||||
async (documentId, nextIssuedDate) => {
|
||||
setLoading(true);
|
||||
const payload = { issued_at: nextIssuedDate || null };
|
||||
try {
|
||||
const { data } = await api.patch(`/documents/${documentId}`, payload);
|
||||
const updatedDocument = extractDocumentFromResponse(data);
|
||||
|
||||
updateDocumentCaches(documentId, (doc) => {
|
||||
if (updatedDocument) {
|
||||
return { ...doc, ...updatedDocument };
|
||||
}
|
||||
return { ...doc, issued_at: payload.issued_at };
|
||||
});
|
||||
|
||||
const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.';
|
||||
setStatusMessage(message, 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to update issued date.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[extractDocumentFromResponse, notifyApiError, setStatusMessage, updateDocumentCaches],
|
||||
);
|
||||
|
||||
const applyTagRemovalToCaches = useCallback(
|
||||
(documentId, tagId) => {
|
||||
if (!documentId || !tagId) {
|
||||
@@ -3631,10 +3665,21 @@ const AppLayout = () => {
|
||||
);
|
||||
|
||||
const handleTagAdd = useCallback(
|
||||
async (document, label, input) => {
|
||||
async (document, label, extras = null) => {
|
||||
const normalizedLabel = tagManager.normalizeLabel(label);
|
||||
let tag =
|
||||
tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
|
||||
const optionCandidate =
|
||||
extras && typeof extras === 'object' && 'option' in extras ? extras.option : null;
|
||||
const input =
|
||||
extras && typeof extras === 'object' && 'input' in extras ? extras.input : null;
|
||||
|
||||
let tag = null;
|
||||
if (optionCandidate && optionCandidate.id) {
|
||||
tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate;
|
||||
}
|
||||
if (!tag) {
|
||||
tag =
|
||||
tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
|
||||
}
|
||||
try {
|
||||
if (!tag) {
|
||||
const payload = tagManager.buildPayload({ label: normalizedLabel });
|
||||
@@ -3644,7 +3689,9 @@ const AppLayout = () => {
|
||||
}
|
||||
await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] });
|
||||
setStatusMessage('Tag assigned.', 'success');
|
||||
input.value = '';
|
||||
if (input && typeof input === 'object') {
|
||||
input.value = '';
|
||||
}
|
||||
await refreshCurrentFolder();
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to assign tag.');
|
||||
@@ -5104,6 +5151,7 @@ const AppLayout = () => {
|
||||
onPromoteSelection: promoteSelectionOrder,
|
||||
activePreviewId,
|
||||
onUpdateTitle: handleDocumentTitleUpdate,
|
||||
onUpdateIssued: handleDocumentIssuedUpdate,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
ensurePreviewData,
|
||||
@@ -5131,6 +5179,7 @@ const AppLayout = () => {
|
||||
handleCorrespondentRemove,
|
||||
handleDetailPanelClose,
|
||||
handleDocumentTitleUpdate,
|
||||
handleDocumentIssuedUpdate,
|
||||
handleTagAdd,
|
||||
handleTagRemove,
|
||||
handleThumbnailRegeneration,
|
||||
|
||||
@@ -68,6 +68,20 @@ export const useWorkspaceSurface = ({
|
||||
if (!showPreviewWorkspace || !previewWorkspaceDocument) {
|
||||
return null;
|
||||
}
|
||||
const detailExtras = detailPanelProps || {};
|
||||
const {
|
||||
tagLookupById,
|
||||
tags: tagOptions,
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
correspondents,
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
resolveFolderPath,
|
||||
onFolderNavigate,
|
||||
} = detailExtras;
|
||||
return createPreviewSurface({
|
||||
document: previewWorkspaceDocument,
|
||||
previewEntry: previewWorkspaceEntry,
|
||||
@@ -79,6 +93,17 @@ export const useWorkspaceSurface = ({
|
||||
onRegenerate: handleThumbnailRegeneration,
|
||||
onClose: closeDocumentPreview,
|
||||
renderSidebarToggle,
|
||||
tagLookupById,
|
||||
tagOptions,
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
correspondents,
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
resolveFolderPath,
|
||||
onFolderNavigate,
|
||||
});
|
||||
}, [
|
||||
showPreviewWorkspace,
|
||||
@@ -92,6 +117,7 @@ export const useWorkspaceSurface = ({
|
||||
handleThumbnailRegeneration,
|
||||
closeDocumentPreview,
|
||||
renderSidebarToggle,
|
||||
detailPanelProps,
|
||||
]);
|
||||
|
||||
const workspaceSurface = useMemo(() => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ChevronsRightIcon,
|
||||
@@ -10,13 +9,18 @@ import {
|
||||
TextScanIcon,
|
||||
} from '../ui/icons';
|
||||
import PanelHeader from '../ui/PanelHeader';
|
||||
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 DocumentSummarySection, {
|
||||
TagSection,
|
||||
CorrespondentSection,
|
||||
sortCorrespondents,
|
||||
buildCorrespondentOptions,
|
||||
} from '../documents/DocumentSummarySection';
|
||||
|
||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||
|
||||
@@ -29,156 +33,6 @@ const derivePreviewOrientation = (metadata) => {
|
||||
return 'landscape';
|
||||
};
|
||||
|
||||
const sortCorrespondents = (entries = []) =>
|
||||
entries
|
||||
.filter((entry) => entry && entry.name)
|
||||
.map(({ id, name, count }) => ({ id, name, count }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
|
||||
<div className="correspondent-list">
|
||||
{entries.length ? (
|
||||
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>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="meta">No correspondents yet.</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const TagSection = ({
|
||||
title,
|
||||
tags = [],
|
||||
onRemove,
|
||||
onAdd,
|
||||
emptyMessage = 'No tags yet.',
|
||||
addPlaceholder = 'Add or create tag',
|
||||
addButtonLabel = 'Add',
|
||||
datalistId,
|
||||
datalistOptions = [],
|
||||
className,
|
||||
}) => (
|
||||
<div className={className}>
|
||||
<dt>{title}</dt>
|
||||
<div className="tag-list">
|
||||
{tags.length ? (
|
||||
tags.map((tag) => {
|
||||
const key = tag.id ?? tag.label;
|
||||
const style = getTagColorStyle(tag.color);
|
||||
const removable = Boolean(onRemove);
|
||||
const className = removable ? 'badge tag-chip tag-chip--removable' : 'badge tag-chip';
|
||||
return (
|
||||
<span key={key} className={className} style={style || undefined}>
|
||||
<span className="tag-chip__label">{tag.label}</span>
|
||||
{removable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tag-chip__remove"
|
||||
onClick={() => onRemove(tag)}
|
||||
aria-label={`Remove tag ${tag.label}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="meta">{emptyMessage}</span>
|
||||
)}
|
||||
</div>
|
||||
{onAdd ? (
|
||||
<form
|
||||
className="inline"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const input = event.currentTarget.elements.tag;
|
||||
const value = input.value.trim();
|
||||
if (!value) return;
|
||||
onAdd({ value, input });
|
||||
}}
|
||||
>
|
||||
<input name="tag" placeholder={addPlaceholder} list={datalistId} />
|
||||
<button type="submit">{addButtonLabel}</button>
|
||||
{datalistId ? (
|
||||
<datalist id={datalistId}>
|
||||
{datalistOptions.map((option) => (
|
||||
<option key={option.id} />
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const CorrespondentSection = ({
|
||||
title,
|
||||
entries = [],
|
||||
onRemove,
|
||||
onAdd,
|
||||
showCount = false,
|
||||
addPlaceholder = 'Add or create correspondent',
|
||||
addButtonLabel = 'Add',
|
||||
datalistId,
|
||||
datalistOptions = [],
|
||||
className,
|
||||
}) => (
|
||||
<div className={className}>
|
||||
<dt>{title}</dt>
|
||||
<CorrespondentPills entries={entries} onRemove={onRemove} showCount={showCount} />
|
||||
{onAdd ? (
|
||||
<form
|
||||
className="correspondent-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const nameInput = form.elements.correspondent;
|
||||
const value = nameInput.value.trim();
|
||||
if (!value) return;
|
||||
onAdd({ name: value, input: nameInput });
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
name="correspondent"
|
||||
placeholder={addPlaceholder}
|
||||
list={datalistId}
|
||||
/>
|
||||
<button type="submit">{addButtonLabel}</button>
|
||||
{datalistId ? (
|
||||
<datalist id={datalistId}>
|
||||
{datalistOptions.map((name) => (
|
||||
<option key={name} value={name} />
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const computeStackAngle = (docId, index) => {
|
||||
if (index === 0) return 0;
|
||||
let hash = 0;
|
||||
@@ -294,6 +148,7 @@ const DetailPanel = ({
|
||||
onBulkCorrespondentRemove,
|
||||
onPromoteSelection,
|
||||
onUpdateTitle = async () => false,
|
||||
onUpdateIssued = async () => false,
|
||||
ensureAssetUrl = null,
|
||||
getDocumentAsset = () => null,
|
||||
ensurePreviewData = () => Promise.resolve(),
|
||||
@@ -337,10 +192,6 @@ const DetailPanel = ({
|
||||
return `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
||||
}, [selectedCount, detailSummary]);
|
||||
|
||||
const [titleEditDocId, setTitleEditDocId] = useState(null);
|
||||
const [titleDraft, setTitleDraft] = useState('');
|
||||
const [titleSaving, setTitleSaving] = useState(false);
|
||||
const [titleError, setTitleError] = useState(null);
|
||||
const [zoomedPreview, setZoomedPreview] = useState(null);
|
||||
|
||||
const bulkDocumentIds = useMemo(
|
||||
@@ -348,67 +199,10 @@ const DetailPanel = ({
|
||||
[selectedDocuments],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!singleDoc) {
|
||||
setTitleEditDocId(null);
|
||||
setTitleDraft('');
|
||||
setTitleError(null);
|
||||
setTitleSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (titleEditDocId && titleEditDocId !== singleDoc.id) {
|
||||
setTitleEditDocId(null);
|
||||
setTitleDraft('');
|
||||
setTitleError(null);
|
||||
setTitleSaving(false);
|
||||
}
|
||||
}, [singleDoc, titleEditDocId]);
|
||||
|
||||
useEffect(() => {
|
||||
setZoomedPreview(null);
|
||||
}, [selectionKey]);
|
||||
|
||||
const startTitleEdit = useCallback(() => {
|
||||
if (!singleDoc) return;
|
||||
setTitleEditDocId(singleDoc.id);
|
||||
setTitleDraft(singleDoc.title || singleDoc.original_name);
|
||||
setTitleError(null);
|
||||
}, [singleDoc]);
|
||||
|
||||
const cancelTitleEdit = useCallback(() => {
|
||||
setTitleEditDocId(null);
|
||||
setTitleDraft('');
|
||||
setTitleError(null);
|
||||
setTitleSaving(false);
|
||||
}, []);
|
||||
|
||||
const submitTitleEdit = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
if (!singleDoc) return;
|
||||
const trimmed = titleDraft.trim();
|
||||
if (!trimmed) {
|
||||
setTitleError('Title cannot be empty.');
|
||||
return;
|
||||
}
|
||||
setTitleSaving(true);
|
||||
try {
|
||||
const ok = await onUpdateTitle(singleDoc.id, trimmed);
|
||||
if (ok) {
|
||||
setTitleEditDocId(null);
|
||||
setTitleDraft('');
|
||||
setTitleError(null);
|
||||
} else {
|
||||
setTitleError('Failed to update title.');
|
||||
}
|
||||
} finally {
|
||||
setTitleSaving(false);
|
||||
}
|
||||
},
|
||||
[singleDoc, titleDraft, onUpdateTitle],
|
||||
);
|
||||
|
||||
const handlePreviewActivate = useCallback(
|
||||
(docId) => {
|
||||
if (!docId) return;
|
||||
@@ -569,134 +363,16 @@ const DetailPanel = ({
|
||||
}, 0);
|
||||
}, [stackPreviews, selectedDocuments]);
|
||||
|
||||
const availableCorrespondents = useMemo(
|
||||
() => (Array.isArray(correspondents) ? correspondents : []),
|
||||
const correspondentOptions = useMemo(
|
||||
() => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []),
|
||||
[correspondents],
|
||||
);
|
||||
|
||||
const correspondentOptions = useMemo(() => {
|
||||
const seen = new Set();
|
||||
return availableCorrespondents.reduce((options, entry) => {
|
||||
if (typeof entry?.name !== 'string') {
|
||||
return options;
|
||||
}
|
||||
const name = entry.name.trim();
|
||||
if (!name) {
|
||||
return options;
|
||||
}
|
||||
const lower = name.toLowerCase();
|
||||
if (seen.has(lower)) {
|
||||
return options;
|
||||
}
|
||||
seen.add(lower);
|
||||
options.push(name);
|
||||
return options;
|
||||
}, []);
|
||||
}, [availableCorrespondents]);
|
||||
|
||||
const singleCorrespondents = useMemo(() => {
|
||||
if (!singleDoc) return [];
|
||||
return sortCorrespondents(singleDoc.correspondents || []);
|
||||
}, [singleDoc]);
|
||||
|
||||
const singleFolderPath = useMemo(() => {
|
||||
if (!singleDoc?.folder_id) {
|
||||
return null;
|
||||
}
|
||||
if (typeof resolveFolderPath !== 'function') {
|
||||
return null;
|
||||
}
|
||||
const segments = resolveFolderPath(singleDoc.folder_id);
|
||||
if (!Array.isArray(segments) || !segments.some((segment) => segment?.id && segment.id !== 'root')) {
|
||||
return null;
|
||||
}
|
||||
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];
|
||||
@@ -971,15 +647,11 @@ const DetailPanel = ({
|
||||
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
||||
}
|
||||
|
||||
const displayName = singleDoc.title || singleDoc.original_name;
|
||||
const isEditingTitle = titleEditDocId === singleDoc.id;
|
||||
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
|
||||
const metadata =
|
||||
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
|
||||
const effectiveCardinality = singleEffectiveCardinality;
|
||||
const effectiveCardinality =
|
||||
singlePreviewNavigator.cardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
|
||||
const canGoPrev = singlePreviewNavigator.canGoPrev;
|
||||
const canGoNext = singlePreviewNavigator.canGoNext;
|
||||
const hasPreviewImage = singleHasPreview;
|
||||
const hasPreviewImage = Boolean(singlePreviewNavigator.currentUrl);
|
||||
const interceptNavPointer = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -997,142 +669,59 @@ const DetailPanel = ({
|
||||
onZoomPreview={handleSingleZoom}
|
||||
/>
|
||||
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
|
||||
<div className="preview-pane__nav preview-pane__nav--overlay">
|
||||
<button
|
||||
type="button"
|
||||
className="preview-pane__nav-button preview-pane__nav-button--prev"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
singlePreviewNavigator.goPrev();
|
||||
}}
|
||||
onPointerDown={interceptNavPointer}
|
||||
onPointerUp={interceptNavPointer}
|
||||
onMouseDown={interceptNavPointer}
|
||||
onMouseUp={interceptNavPointer}
|
||||
disabled={!canGoPrev}
|
||||
aria-label="Previous preview"
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
<div className="preview-pane__nav preview-pane__nav--overlay">
|
||||
<button
|
||||
type="button"
|
||||
className="preview-pane__nav-button preview-pane__nav-button--next"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
singlePreviewNavigator.goNext();
|
||||
}}
|
||||
onPointerDown={interceptNavPointer}
|
||||
onPointerUp={interceptNavPointer}
|
||||
onMouseDown={interceptNavPointer}
|
||||
onMouseUp={interceptNavPointer}
|
||||
disabled={!canGoNext}
|
||||
aria-label="Next preview"
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
</button>
|
||||
className="preview-pane__nav-button preview-pane__nav-button--prev"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
singlePreviewNavigator.goPrev();
|
||||
}}
|
||||
onPointerDown={interceptNavPointer}
|
||||
onPointerUp={interceptNavPointer}
|
||||
onMouseDown={interceptNavPointer}
|
||||
onMouseUp={interceptNavPointer}
|
||||
disabled={!canGoPrev}
|
||||
aria-label="Previous preview"
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="preview-pane__nav-button preview-pane__nav-button--next"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
singlePreviewNavigator.goNext();
|
||||
}}
|
||||
onPointerDown={interceptNavPointer}
|
||||
onPointerUp={interceptNavPointer}
|
||||
onMouseDown={interceptNavPointer}
|
||||
onMouseUp={interceptNavPointer}
|
||||
disabled={!canGoNext}
|
||||
aria-label="Next preview"
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="doc-title-row">
|
||||
{isEditingTitle ? (
|
||||
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
|
||||
<input
|
||||
value={titleDraft}
|
||||
onChange={(event) => {
|
||||
setTitleDraft(event.target.value);
|
||||
if (titleError) {
|
||||
setTitleError(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelTitleEdit();
|
||||
}
|
||||
}}
|
||||
aria-label="Document title"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" disabled={titleSaving}>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={cancelTitleEdit}
|
||||
disabled={titleSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<h3 style={{ margin: 0 }}>{displayName}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={startTitleEdit}
|
||||
aria-label="Edit title"
|
||||
title="Edit title"
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||
<div className="meta">
|
||||
{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"
|
||||
tags={tagsForDoc.map((tag) => ({
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color || tagLookupById.get(tag.id)?.color,
|
||||
}))}
|
||||
onRemove={(tag) => onTagRemove(singleDoc.id, tag.id)}
|
||||
onAdd={({ value, input }) => onTagAdd(singleDoc, value, input)}
|
||||
datalistId="tag-catalog-single"
|
||||
datalistOptions={tags}
|
||||
<DocumentSummarySection
|
||||
document={singleDoc}
|
||||
tagLookupById={tagLookupById}
|
||||
tagOptions={tags}
|
||||
onTagAdd={(doc, value, extras) => onTagAdd(doc, value, extras)}
|
||||
onTagRemove={(docId, tagId) => onTagRemove(docId, tagId)}
|
||||
correspondents={singleCorrespondents}
|
||||
correspondentOptions={correspondentOptions}
|
||||
onCorrespondentAdd={onCorrespondentAdd}
|
||||
onCorrespondentRemove={onCorrespondentRemove}
|
||||
onUpdateTitle={onUpdateTitle}
|
||||
onUpdateIssued={onUpdateIssued}
|
||||
resolveFolderPath={resolveFolderPath}
|
||||
onFolderNavigate={onFolderNavigate}
|
||||
/>
|
||||
<CorrespondentSection
|
||||
title="Correspondents"
|
||||
entries={singleCorrespondents}
|
||||
onRemove={(entry) =>
|
||||
onCorrespondentRemove?.({
|
||||
documentId: singleDoc.id,
|
||||
correspondentId: entry.id,
|
||||
})
|
||||
}
|
||||
onAdd={({ name, input }) =>
|
||||
onCorrespondentAdd?.({
|
||||
document: singleDoc,
|
||||
name,
|
||||
input,
|
||||
})
|
||||
}
|
||||
datalistId="correspondent-catalog-single"
|
||||
datalistOptions={correspondentOptions}
|
||||
/>
|
||||
{metadata && (
|
||||
<div>
|
||||
<dt>Metadata</dt>
|
||||
<pre className="detail-metadata__block">{JSON.stringify(metadata, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1140,6 +729,7 @@ const DetailPanel = ({
|
||||
const renderBulk = () => {
|
||||
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
||||
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
|
||||
const headerLabel = `${countLabel}${sizeLabel ? ` (${sizeLabel})` : ''}`;
|
||||
const topDocIdLocal = topDocId;
|
||||
const topCardinalityLocal = topEffectiveCardinality;
|
||||
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
|
||||
@@ -1200,35 +790,26 @@ const DetailPanel = ({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<h3 style={{ margin: 0 }}>{countLabel}</h3>
|
||||
<div className="meta">
|
||||
<div>
|
||||
<strong>Total size (stack):</strong> {sizeLabel}
|
||||
</div>
|
||||
</div>
|
||||
<h3 style={{ margin: 0 }}>{headerLabel}</h3>
|
||||
<TagSection
|
||||
title="Tags"
|
||||
tags={bulkTagUnion}
|
||||
emptyMessage="No tags assigned."
|
||||
onRemove={(tag) => onBulkTagRemove?.({ label: tag.label, documentIds })}
|
||||
onAdd={({ value, input }) =>
|
||||
onBulkTagAdd?.({ label: value, input, documentIds })
|
||||
onAdd={({ value }) =>
|
||||
onBulkTagAdd?.({ label: value, input: null, documentIds })
|
||||
}
|
||||
addPlaceholder="Add tag to selection"
|
||||
addButtonLabel="Add tag"
|
||||
datalistId="tag-catalog-bulk"
|
||||
datalistOptions={tags}
|
||||
className="bulk-tags"
|
||||
/>
|
||||
<CorrespondentSection
|
||||
title="Correspondents"
|
||||
entries={bulkCorrespondents}
|
||||
onRemove={handleBulkCorrespondentRemove}
|
||||
onAdd={({ name, input }) =>
|
||||
onBulkCorrespondentAdd?.({ name, input, documentIds })
|
||||
onAdd={({ name }) =>
|
||||
onBulkCorrespondentAdd?.({ name, input: null, documentIds })
|
||||
}
|
||||
addPlaceholder="Add correspondent to selection"
|
||||
datalistId="correspondent-catalog-bulk"
|
||||
datalistOptions={correspondentOptions}
|
||||
showCount
|
||||
className="bulk-correspondents"
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { EditIcon, IconX, PlusIcon } from '../ui/icons';
|
||||
import QuickAddMenu from '../ui/QuickAddMenu';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { describeDocumentSummary } from './documentSummary';
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const toDateInputValue = (value) => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const timezoneOffset = date.getTimezoneOffset();
|
||||
const localDate = new Date(date.getTime() - timezoneOffset * 60000);
|
||||
return localDate.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const toIssuedTimestamp = (dateString, fallback) => {
|
||||
if (!dateString) {
|
||||
return null;
|
||||
}
|
||||
const base = fallback ? new Date(fallback) : new Date();
|
||||
if (Number.isNaN(base.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const [year, month, day] = dateString.split('-').map((part) => Number.parseInt(part, 10));
|
||||
if (!year || !month || !day) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = new Date(base);
|
||||
candidate.setUTCFullYear(year, month - 1, day);
|
||||
return candidate.toISOString();
|
||||
};
|
||||
|
||||
export const sortCorrespondents = (entries = []) =>
|
||||
entries
|
||||
.filter((entry) => entry && entry.name)
|
||||
.map(({ id, name, count }) => ({ id, name, count }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
export const buildCorrespondentOptions = (entries = []) => {
|
||||
const seen = new Set();
|
||||
return entries.reduce((options, entry) => {
|
||||
const name = typeof entry?.name === 'string' ? entry.name.trim() : '';
|
||||
if (!name) {
|
||||
return options;
|
||||
}
|
||||
const key = name.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
return options;
|
||||
}
|
||||
seen.add(key);
|
||||
options.push(name);
|
||||
return options;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const normalizeOptions = (options) => (Array.isArray(options) ? options : []);
|
||||
|
||||
export const TagSection = ({
|
||||
tags = [],
|
||||
onRemove,
|
||||
onAdd,
|
||||
emptyMessage = 'No tags yet.',
|
||||
addPlaceholder = 'Add or create tag',
|
||||
addButtonLabel = 'Add',
|
||||
datalistOptions = [],
|
||||
className,
|
||||
}) => {
|
||||
const handleCreate = useCallback(
|
||||
(label) => onAdd?.({ value: label, input: null }),
|
||||
[onAdd],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(option) => {
|
||||
if (!onAdd) return;
|
||||
const label =
|
||||
(option && typeof option === 'object' && option.label) ||
|
||||
(typeof option === 'string' ? option : '');
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
onAdd({ value: label, option });
|
||||
},
|
||||
[onAdd],
|
||||
);
|
||||
|
||||
const normalizedOptions = useMemo(() => normalizeOptions(datalistOptions), [datalistOptions]);
|
||||
const containerClass = className ? `tag-list ${className}` : 'tag-list';
|
||||
const showQuickAdd = Boolean(onAdd);
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
{tags.map((tag) => {
|
||||
const key = tag.id ?? tag.label;
|
||||
const style = getTagColorStyle(tag.color);
|
||||
return (
|
||||
<span key={key} className="badge tag-chip" style={style || undefined}>
|
||||
<span className="tag-chip__label">{tag.label}</span>
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tag-chip__remove"
|
||||
onClick={() => onRemove(tag)}
|
||||
aria-label={`Remove tag ${tag.label}`}
|
||||
>
|
||||
<IconX className="icon-inline" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{showQuickAdd ? (
|
||||
<QuickAddMenu
|
||||
options={normalizedOptions}
|
||||
onCreate={handleCreate}
|
||||
onSelectOption={(original, normalized) => handleSelect(normalized || original)}
|
||||
placeholder={addPlaceholder}
|
||||
createLabel={addButtonLabel}
|
||||
triggerAriaLabel="Add tag"
|
||||
triggerTitle={addButtonLabel}
|
||||
triggerClassName="quick-add__chip quick-add__trigger"
|
||||
triggerContent={(
|
||||
<span className="quick-add__chip-label">
|
||||
<PlusIcon className="icon-inline" aria-hidden="true" /> Add tag
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{!tags.length && !showQuickAdd ? <span className="tag-list__empty meta">{emptyMessage}</span> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CorrespondentSection = ({
|
||||
entries = [],
|
||||
onRemove,
|
||||
onAdd,
|
||||
showCount = false,
|
||||
addPlaceholder = 'Add or create correspondent',
|
||||
addButtonLabel = 'Add',
|
||||
datalistOptions = [],
|
||||
className,
|
||||
}) => {
|
||||
const handleCreate = useCallback(
|
||||
(name) => onAdd?.({ name, input: null }),
|
||||
[onAdd],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(original, normalized) => {
|
||||
if (!onAdd) return;
|
||||
const source = normalized && typeof normalized === 'object' ? normalized : original;
|
||||
const resolvedName =
|
||||
(source && typeof source.name === 'string' && source.name.trim()) ||
|
||||
(typeof source === 'string' ? source.trim() : '') ||
|
||||
(source && typeof source.label === 'string' ? source.label.trim() : '');
|
||||
if (!resolvedName) {
|
||||
return;
|
||||
}
|
||||
const payload =
|
||||
source && typeof source === 'object'
|
||||
? { ...source, name: resolvedName }
|
||||
: { id: null, name: resolvedName };
|
||||
onAdd({ name: resolvedName, option: payload, input: null });
|
||||
},
|
||||
[onAdd],
|
||||
);
|
||||
|
||||
const normalizedOptions = useMemo(() => normalizeOptions(datalistOptions), [datalistOptions]);
|
||||
const hasEntries = entries && entries.length > 0;
|
||||
const showQuickAdd = Boolean(onAdd);
|
||||
const containerClass = className ? `correspondent-list ${className}` : 'correspondent-list';
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
{hasEntries
|
||||
? entries.map((entry) => {
|
||||
const key = entry.id ?? entry.name;
|
||||
return (
|
||||
<span key={key} className="correspondent-pill">
|
||||
<span className="correspondent-pill__label">
|
||||
{entry.name}
|
||||
{showCount && entry.count ? ` (${entry.count})` : ''}
|
||||
</span>
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="correspondent-pill__remove"
|
||||
onClick={() => onRemove(entry)}
|
||||
aria-label={`Remove ${entry.name}`}
|
||||
>
|
||||
<IconX className="icon-inline" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
: !showQuickAdd && <span className="meta">No correspondents yet.</span>}
|
||||
{showQuickAdd ? (
|
||||
<QuickAddMenu
|
||||
options={normalizedOptions}
|
||||
onCreate={handleCreate}
|
||||
onSelectOption={(original, normalized) => handleSelect(original, normalized)}
|
||||
placeholder={addPlaceholder}
|
||||
createLabel={addButtonLabel}
|
||||
triggerAriaLabel="Add correspondent"
|
||||
triggerTitle={addButtonLabel}
|
||||
triggerClassName="quick-add__chip quick-add__trigger"
|
||||
triggerContent={(
|
||||
<span className="quick-add__chip-label">
|
||||
<PlusIcon className="icon-inline" aria-hidden="true" /> Add correspondent
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentSummarySection = ({
|
||||
document,
|
||||
tagLookupById = new Map(),
|
||||
tagOptions = [],
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
correspondents,
|
||||
correspondentOptions = [],
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
resolveFolderPath,
|
||||
onFolderNavigate,
|
||||
}) => {
|
||||
const summary = useMemo(() => {
|
||||
if (!document) {
|
||||
return {
|
||||
title: '',
|
||||
originalName: '',
|
||||
sizeLabel: '—',
|
||||
pageCount: null,
|
||||
};
|
||||
}
|
||||
return describeDocumentSummary(document);
|
||||
}, [document]);
|
||||
const issuedDateLabel = useMemo(() => formatDate(document?.issued_at), [document?.issued_at]);
|
||||
|
||||
const editableTitle = Boolean(document && onUpdateTitle);
|
||||
const editableIssued = Boolean(document && onUpdateIssued);
|
||||
|
||||
const resolvedTags = useMemo(() => {
|
||||
if (!Array.isArray(document?.tags)) {
|
||||
return [];
|
||||
}
|
||||
return document.tags.map((tag) => ({
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color ?? tagLookupById.get(tag.id)?.color ?? null,
|
||||
}));
|
||||
}, [document?.tags, tagLookupById]);
|
||||
|
||||
const resolvedCorrespondents = useMemo(() => {
|
||||
if (Array.isArray(correspondents) && correspondents.length) {
|
||||
return correspondents;
|
||||
}
|
||||
return sortCorrespondents(document?.correspondents || []);
|
||||
}, [correspondents, document?.correspondents]);
|
||||
|
||||
const folderPath = useMemo(() => {
|
||||
if (!document?.folder_id || typeof resolveFolderPath !== 'function') {
|
||||
return null;
|
||||
}
|
||||
const segments = resolveFolderPath(document.folder_id);
|
||||
const filtered = Array.isArray(segments)
|
||||
? segments.filter((segment) => segment?.id && segment.id !== 'root')
|
||||
: null;
|
||||
if (!filtered || !filtered.length) {
|
||||
return null;
|
||||
}
|
||||
return filtered;
|
||||
}, [document?.folder_id, resolveFolderPath]);
|
||||
|
||||
const folderDisplayNode = useMemo(() => {
|
||||
if (!folderPath || !folderPath.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span className="detail-folder-path">
|
||||
{folderPath.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>
|
||||
);
|
||||
}, [folderPath, onFolderNavigate]);
|
||||
|
||||
const metaRows = useMemo(() => {
|
||||
const rows = [];
|
||||
const currentVersionNumber = document?.current_version?.version_number;
|
||||
if (Number.isFinite(currentVersionNumber)) {
|
||||
rows.push({
|
||||
key: 'current-version',
|
||||
label: 'Current version',
|
||||
value: `#${currentVersionNumber}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (summary.sizeLabel && summary.sizeLabel !== '—') {
|
||||
rows.push({ key: 'size', label: 'Size', value: summary.sizeLabel });
|
||||
}
|
||||
|
||||
if (Number.isFinite(summary.pageCount)) {
|
||||
rows.push({ key: 'pages', label: 'Pages', value: String(summary.pageCount) });
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [document?.current_version?.version_number, summary]);
|
||||
|
||||
const [titleDraft, setTitleDraft] = useState('');
|
||||
const [titleSaving, setTitleSaving] = useState(false);
|
||||
const [titleError, setTitleError] = useState(null);
|
||||
const [isTitleEditing, setIsTitleEditing] = useState(false);
|
||||
|
||||
const [issuedDraft, setIssuedDraft] = useState('');
|
||||
const [issuedSaving, setIssuedSaving] = useState(false);
|
||||
const [issuedError, setIssuedError] = useState(null);
|
||||
const [isIssuedEditing, setIsIssuedEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsTitleEditing(false);
|
||||
setTitleDraft('');
|
||||
setTitleError(null);
|
||||
setTitleSaving(false);
|
||||
|
||||
setIsIssuedEditing(false);
|
||||
setIssuedDraft('');
|
||||
setIssuedError(null);
|
||||
setIssuedSaving(false);
|
||||
}, [document?.id]);
|
||||
|
||||
const startTitleEdit = useCallback(() => {
|
||||
if (!editableTitle || !document) return;
|
||||
setIsTitleEditing(true);
|
||||
setTitleDraft(document.title || document.original_name || '');
|
||||
setTitleError(null);
|
||||
}, [document, editableTitle]);
|
||||
|
||||
const cancelTitleEdit = useCallback(() => {
|
||||
setIsTitleEditing(false);
|
||||
setTitleDraft('');
|
||||
setTitleError(null);
|
||||
setTitleSaving(false);
|
||||
}, []);
|
||||
|
||||
const submitTitleEdit = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
if (!editableTitle || !document) return;
|
||||
const trimmed = titleDraft.trim();
|
||||
if (!trimmed) {
|
||||
setTitleError('Title cannot be empty.');
|
||||
return;
|
||||
}
|
||||
setTitleSaving(true);
|
||||
try {
|
||||
const ok = await onUpdateTitle(document.id, trimmed);
|
||||
if (ok) {
|
||||
cancelTitleEdit();
|
||||
} else {
|
||||
setTitleError('Failed to update title.');
|
||||
}
|
||||
} finally {
|
||||
setTitleSaving(false);
|
||||
}
|
||||
},
|
||||
[cancelTitleEdit, document, editableTitle, onUpdateTitle, titleDraft],
|
||||
);
|
||||
|
||||
const startIssuedEdit = useCallback(() => {
|
||||
if (!editableIssued || !document) return;
|
||||
setIsIssuedEditing(true);
|
||||
setIssuedDraft(toDateInputValue(document.issued_at));
|
||||
setIssuedError(null);
|
||||
}, [document, editableIssued]);
|
||||
|
||||
const cancelIssuedEdit = useCallback(() => {
|
||||
setIsIssuedEditing(false);
|
||||
setIssuedDraft('');
|
||||
setIssuedError(null);
|
||||
setIssuedSaving(false);
|
||||
}, []);
|
||||
|
||||
const submitIssuedEdit = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
if (!editableIssued || !document) return;
|
||||
const normalizedValue = issuedDraft ? toIssuedTimestamp(issuedDraft, document.issued_at) : null;
|
||||
setIssuedSaving(true);
|
||||
try {
|
||||
const ok = await onUpdateIssued(document.id, normalizedValue);
|
||||
if (ok) {
|
||||
cancelIssuedEdit();
|
||||
} else {
|
||||
setIssuedError('Failed to update issued date.');
|
||||
}
|
||||
} finally {
|
||||
setIssuedSaving(false);
|
||||
}
|
||||
},
|
||||
[cancelIssuedEdit, document, editableIssued, issuedDraft, onUpdateIssued],
|
||||
);
|
||||
|
||||
const titleDisplay = summary.title || document?.original_name || 'Untitled document';
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="document-summary">
|
||||
<div className="doc-title-row">
|
||||
<div className="doc-title-row__primary">
|
||||
{editableTitle && isTitleEditing ? (
|
||||
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
|
||||
<input
|
||||
value={titleDraft}
|
||||
onChange={(event) => {
|
||||
setTitleDraft(event.target.value);
|
||||
if (titleError) {
|
||||
setTitleError(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelTitleEdit();
|
||||
}
|
||||
}}
|
||||
aria-label="Document title"
|
||||
autoFocus
|
||||
disabled={titleSaving}
|
||||
/>
|
||||
<button type="submit" disabled={titleSaving}>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={cancelTitleEdit}
|
||||
disabled={titleSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="doc-title-row__title">{titleDisplay}</h3>
|
||||
{editableTitle ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={startTitleEdit}
|
||||
aria-label="Edit title"
|
||||
title="Edit title"
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{folderDisplayNode ? (
|
||||
<div className="doc-title-row__path" aria-label="Folder path">
|
||||
{folderDisplayNode}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||
|
||||
<TagSection
|
||||
tags={resolvedTags}
|
||||
onRemove={
|
||||
onTagRemove
|
||||
? (tag) => onTagRemove(document.id, tag.id)
|
||||
: undefined
|
||||
}
|
||||
onAdd={
|
||||
onTagAdd
|
||||
? ({ value, option }) => onTagAdd(document, value, { option })
|
||||
: undefined
|
||||
}
|
||||
datalistOptions={tagOptions}
|
||||
/>
|
||||
|
||||
<CorrespondentSection
|
||||
entries={resolvedCorrespondents}
|
||||
onRemove={
|
||||
onCorrespondentRemove
|
||||
? (entry) =>
|
||||
onCorrespondentRemove({
|
||||
documentId: document.id,
|
||||
correspondentId: entry.id,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onAdd={
|
||||
onCorrespondentAdd
|
||||
? ({ name, option }) =>
|
||||
onCorrespondentAdd({
|
||||
document,
|
||||
name,
|
||||
option,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
showCount
|
||||
datalistOptions={correspondentOptions}
|
||||
/>
|
||||
|
||||
<div className="detail-meta">
|
||||
<div className="detail-meta__row">
|
||||
<span className="detail-meta__label">Issued:</span>
|
||||
{editableIssued && isIssuedEditing ? (
|
||||
<form className="doc-issued-edit" onSubmit={submitIssuedEdit}>
|
||||
<input
|
||||
type="date"
|
||||
value={issuedDraft}
|
||||
onChange={(event) => {
|
||||
setIssuedDraft(event.target.value);
|
||||
if (issuedError) {
|
||||
setIssuedError(null);
|
||||
}
|
||||
}}
|
||||
aria-label="Issued on"
|
||||
disabled={issuedSaving}
|
||||
/>
|
||||
<button type="submit" disabled={issuedSaving}>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={cancelIssuedEdit}
|
||||
disabled={issuedSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<span className="detail-meta__value">{issuedDateLabel || 'Not set'}</span>
|
||||
{editableIssued ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={startIssuedEdit}
|
||||
aria-label={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
|
||||
title={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{issuedError ? <div className="status-inline error">{issuedError}</div> : null}
|
||||
|
||||
{metaRows.map((row) => (
|
||||
<div key={row.key} className="detail-meta__row">
|
||||
<span className="detail-meta__label">{row.label}:</span>
|
||||
<span className="detail-meta__value">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentSummarySection;
|
||||
@@ -47,7 +47,7 @@ export const describeDocumentSummary = (document, options = {}) => {
|
||||
mimeTypeLabel: '—',
|
||||
sizeLabel: '—',
|
||||
createdAtLabel: '—',
|
||||
issuedAtLabel: '—',
|
||||
issuedLabel: '—',
|
||||
updatedAtLabel: '—',
|
||||
pageCount: null,
|
||||
pageCountLabel: '—',
|
||||
@@ -76,7 +76,7 @@ export const describeDocumentSummary = (document, options = {}) => {
|
||||
const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—';
|
||||
|
||||
const createdAtLabel = formatDateTime(document.created_at);
|
||||
const issuedAtLabel = formatDateTime(document.issued_at);
|
||||
const issuedLabel = formatDateTime(document.issued_at);
|
||||
const updatedAtLabel = formatDateTime(document.updated_at);
|
||||
|
||||
const folderLabel = document.folder_path || document.folder_name || null;
|
||||
@@ -96,7 +96,7 @@ export const describeDocumentSummary = (document, options = {}) => {
|
||||
{ key: 'created', label: 'Created', value: createdAtLabel },
|
||||
{ key: 'size', label: 'Size', value: sizeLabel },
|
||||
{ key: 'type', label: 'Type', value: mimeTypeLabel },
|
||||
{ key: 'issued', label: 'Issued', value: issuedAtLabel },
|
||||
{ key: 'issued', label: 'Issued', value: issuedLabel },
|
||||
{ key: 'pages', label: 'Pages', value: pageCountLabel },
|
||||
{ key: 'updated', label: 'Updated', value: updatedAtLabel },
|
||||
{ key: 'folder', label: 'Folder', value: folderLabel || '—' },
|
||||
@@ -110,7 +110,7 @@ export const describeDocumentSummary = (document, options = {}) => {
|
||||
mimeTypeLabel,
|
||||
sizeLabel,
|
||||
createdAtLabel,
|
||||
issuedAtLabel,
|
||||
issuedLabel,
|
||||
updatedAtLabel,
|
||||
pageCount,
|
||||
pageCountLabel,
|
||||
|
||||
@@ -1,92 +1,99 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
|
||||
import { describeDocumentSummary } from '../documents/documentSummary';
|
||||
import DocumentSummarySection, {
|
||||
buildCorrespondentOptions,
|
||||
sortCorrespondents,
|
||||
} from '../documents/DocumentSummarySection';
|
||||
import { createDocumentActionState } from '../documents/documentActions';
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
|
||||
};
|
||||
|
||||
const PreviewWorkspace = ({
|
||||
document,
|
||||
previewEntry,
|
||||
tagLookupById,
|
||||
tagOptions,
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
correspondents,
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
resolveFolderPath,
|
||||
onFolderNavigate,
|
||||
}) => {
|
||||
const sortedCorrespondents = useMemo(
|
||||
() => sortCorrespondents(document?.correspondents || []),
|
||||
[document],
|
||||
);
|
||||
|
||||
const correspondentOptions = useMemo(
|
||||
() => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []),
|
||||
[correspondents],
|
||||
);
|
||||
|
||||
const metadataItems = useMemo(() => {
|
||||
if (!document) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{ label: 'Created at', value: formatDateTime(document.created_at) },
|
||||
{ label: 'Updated at', value: formatDateTime(document.updated_at) },
|
||||
{
|
||||
label: 'Filename',
|
||||
value: document.archive_path || document.filename || '—',
|
||||
},
|
||||
{
|
||||
label: 'Original filename',
|
||||
value: document.original_name || '—',
|
||||
},
|
||||
{
|
||||
label: 'SHA-256 checksum',
|
||||
value: document.current_version?.checksum || '—',
|
||||
},
|
||||
{
|
||||
label: 'Content type',
|
||||
value: document.content_type || '—',
|
||||
},
|
||||
];
|
||||
}, [document]);
|
||||
|
||||
const metadataPayload = useMemo(() => {
|
||||
if (!document || !document.metadata || Object.keys(document.metadata).length === 0) {
|
||||
return null;
|
||||
}
|
||||
return document.metadata;
|
||||
}, [document]);
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = document.title;
|
||||
const summary = describeDocumentSummary(document);
|
||||
const correspondents = Array.isArray(document.correspondents)
|
||||
? document.correspondents.map((entry) => entry?.name).filter(Boolean).join(', ')
|
||||
: '';
|
||||
const tags = Array.isArray(document.tags)
|
||||
? document.tags.map((tag) => tag?.label).filter(Boolean).join(', ')
|
||||
: '';
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
|
||||
};
|
||||
|
||||
const detailItems = [
|
||||
{ label: 'Title', value: summary.title || '—' },
|
||||
{ label: 'Archive Reference', value: document.archive_serial || '—' },
|
||||
{ label: 'Issued On', value: formatDateTime(document.issued_at) },
|
||||
{ label: 'Correspondent', value: correspondents || '—' },
|
||||
{
|
||||
label: 'Filename',
|
||||
value: document.archive_path || document.filename || '—',
|
||||
},
|
||||
{
|
||||
label: 'Original Filename',
|
||||
value: document.original_name || '—',
|
||||
},
|
||||
{ label: 'Tags', value: tags || '—' },
|
||||
];
|
||||
|
||||
const metadataItems = [
|
||||
{ label: 'Modified At', value: formatDateTime(document.updated_at) },
|
||||
{ label: 'Created At', value: formatDateTime(document.created_at) },
|
||||
{
|
||||
label: 'Media Filename',
|
||||
value: document.current_version?.filename || document.archive_path || '—',
|
||||
},
|
||||
{
|
||||
label: 'SHA-256 Checksum',
|
||||
value: document.current_version?.checksum || '—',
|
||||
},
|
||||
{
|
||||
label: 'Original File Size',
|
||||
value: summary.sizeLabel,
|
||||
},
|
||||
{
|
||||
label: 'Original MIME Type',
|
||||
value: document.content_type || '—',
|
||||
},
|
||||
];
|
||||
const metadata =
|
||||
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
|
||||
|
||||
return (
|
||||
<section className="preview-workspace">
|
||||
<div className="preview-workspace__details">
|
||||
<section className="preview-section">
|
||||
<h3 className="preview-section__title">Details</h3>
|
||||
<dl className="preview-section__list">
|
||||
{detailItems.map(({ label, value }) => (
|
||||
<div className="preview-section__item" key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value || '—'}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
<section className="preview-section">
|
||||
<h3 className="preview-section__title">Content</h3>
|
||||
<p className="preview-section__placeholder">
|
||||
Full OCR text will appear here in a future update. Use the toolbar button to open the OCR view for now.
|
||||
</p>
|
||||
</section>
|
||||
<DocumentSummarySection
|
||||
document={document}
|
||||
tagLookupById={tagLookupById}
|
||||
tagOptions={tagOptions}
|
||||
onTagAdd={onTagAdd}
|
||||
onTagRemove={onTagRemove}
|
||||
correspondents={sortedCorrespondents}
|
||||
correspondentOptions={correspondentOptions}
|
||||
onCorrespondentAdd={onCorrespondentAdd}
|
||||
onCorrespondentRemove={onCorrespondentRemove}
|
||||
onUpdateTitle={onUpdateTitle}
|
||||
onUpdateIssued={onUpdateIssued}
|
||||
resolveFolderPath={resolveFolderPath}
|
||||
onFolderNavigate={onFolderNavigate}
|
||||
/>
|
||||
<section className="preview-section">
|
||||
<h3 className="preview-section__title">Metadata</h3>
|
||||
<dl className="preview-section__list">
|
||||
@@ -97,25 +104,13 @@ const PreviewWorkspace = ({
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{metadata ? (
|
||||
{metadataPayload ? (
|
||||
<details className="preview-section__payload">
|
||||
<summary>Show metadata payload</summary>
|
||||
<pre>{JSON.stringify(metadata, null, 2)}</pre>
|
||||
<pre>{JSON.stringify(metadataPayload, null, 2)}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</section>
|
||||
<section className="preview-section">
|
||||
<h3 className="preview-section__title">Notes</h3>
|
||||
<p className="preview-section__placeholder">Custom notes will be editable here once the feature lands.</p>
|
||||
</section>
|
||||
<section className="preview-section">
|
||||
<h3 className="preview-section__title">History</h3>
|
||||
<p className="preview-section__placeholder">Change history will be displayed here in an upcoming release.</p>
|
||||
</section>
|
||||
<section className="preview-section">
|
||||
<h3 className="preview-section__title">Permissions</h3>
|
||||
<p className="preview-section__placeholder">Access control management is planned and will surface here.</p>
|
||||
</section>
|
||||
</div>
|
||||
<div className="preview-workspace__viewer">
|
||||
{!previewEntry?.url ? (
|
||||
@@ -123,7 +118,7 @@ const PreviewWorkspace = ({
|
||||
) : (
|
||||
<iframe
|
||||
src={previewEntry.url}
|
||||
title={`Preview of ${title}`}
|
||||
title={`Preview of ${document.title}`}
|
||||
className="preview-workspace__object"
|
||||
/>
|
||||
)}
|
||||
@@ -208,6 +203,17 @@ export const createPreviewSurface = ({
|
||||
onRegenerate,
|
||||
onClose,
|
||||
renderSidebarToggle,
|
||||
tagLookupById,
|
||||
tagOptions,
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
correspondents,
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
resolveFolderPath,
|
||||
onFolderNavigate,
|
||||
}) => {
|
||||
if (!document) {
|
||||
return null;
|
||||
@@ -255,7 +261,23 @@ export const createPreviewSurface = ({
|
||||
key: 'preview',
|
||||
variant: 'preview',
|
||||
header,
|
||||
content: <PreviewWorkspace document={document} previewEntry={previewEntry} />,
|
||||
content: (
|
||||
<PreviewWorkspace
|
||||
document={document}
|
||||
previewEntry={previewEntry}
|
||||
tagLookupById={tagLookupById}
|
||||
tagOptions={tagOptions}
|
||||
onTagAdd={onTagAdd}
|
||||
onTagRemove={onTagRemove}
|
||||
correspondents={correspondents}
|
||||
onCorrespondentAdd={onCorrespondentAdd}
|
||||
onCorrespondentRemove={onCorrespondentRemove}
|
||||
onUpdateTitle={onUpdateTitle}
|
||||
onUpdateIssued={onUpdateIssued}
|
||||
resolveFolderPath={resolveFolderPath}
|
||||
onFolderNavigate={onFolderNavigate}
|
||||
/>
|
||||
),
|
||||
supportsDetail: false,
|
||||
};
|
||||
};
|
||||
|
||||
+211
-30
@@ -1079,12 +1079,7 @@ button.danger:hover:not([disabled]) {
|
||||
min-width: 14rem;
|
||||
}
|
||||
|
||||
.correspondent-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.correspondent-pill {
|
||||
display: inline-flex;
|
||||
@@ -1094,11 +1089,10 @@ button.danger:hover:not([disabled]) {
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.correspondent-pill__label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@@ -1112,8 +1106,9 @@ button.danger:hover:not([disabled]) {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
padding: 0;
|
||||
}
|
||||
@@ -2333,10 +2328,6 @@ button.danger:hover:not([disabled]) {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tag-chip__label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tag-chip__remove {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -2345,7 +2336,6 @@ button.danger:hover:not([disabled]) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.9em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
@@ -2438,12 +2428,105 @@ button.danger:hover:not([disabled]) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.detail-section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-section__title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.quick-add__trigger {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-add__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
white-space: nowrap;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.18rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.quick-add__chip:hover,
|
||||
.quick-add__chip:focus-visible {
|
||||
border-style: solid;
|
||||
background: var(--selection-soft);
|
||||
color: var(--fg);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.quick-add__chip .icon-inline {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.quick-add__chip-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.quick-add__menu {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.quick-add__form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.quick-add__form input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-add__list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.quick-add__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.quick-add__swatch {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
|
||||
.preview-pane {
|
||||
margin-top: 0.4rem;
|
||||
@@ -2470,54 +2553,92 @@ button.danger:hover:not([disabled]) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.detail-panel .meta {
|
||||
.detail-panel .meta,
|
||||
.document-summary .meta {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path {
|
||||
.detail-panel .detail-folder-path,
|
||||
.document-summary .detail-folder-path {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path__link {
|
||||
.detail-folder-path--block {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path__link,
|
||||
.document-summary .detail-folder-path__link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path__link:hover,
|
||||
.detail-panel .detail-folder-path__link:focus-visible {
|
||||
.detail-panel .detail-folder-path__link:focus-visible,
|
||||
.document-summary .detail-folder-path__link:hover,
|
||||
.document-summary .detail-folder-path__link:focus-visible {
|
||||
text-decoration: underline;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path__separator {
|
||||
.detail-panel .detail-folder-path__separator,
|
||||
.document-summary .detail-folder-path__separator {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path__segment {
|
||||
.detail-panel .detail-folder-path__segment,
|
||||
.document-summary .detail-folder-path__segment {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.detail-panel .doc-title-row {
|
||||
.detail-panel .doc-title-row,
|
||||
.document-summary .doc-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
max-width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-panel .doc-title-edit {
|
||||
.doc-title-row__primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detail-panel .doc-title-edit input {
|
||||
.doc-title-row__title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.doc-title-row__path {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-panel .doc-title-edit,
|
||||
.document-summary .doc-title-edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detail-panel .doc-title-edit input,
|
||||
.document-summary .doc-title-edit input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -2527,6 +2648,59 @@ button.danger:hover:not([disabled]) {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.detail-meta__row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-meta__label {
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.detail-meta__value {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.doc-issued-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.doc-issued-row__label {
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.doc-issued-row__value {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.doc-issued-edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.doc-issued-edit input[type='date'] {
|
||||
font: inherit;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.status-inline.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
@@ -2729,11 +2903,18 @@ button.danger:hover:not([disabled]) {
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.detail-panel .tag-list {
|
||||
.detail-panel .tag-list,
|
||||
.document-summary .tag-list,
|
||||
.correspondent-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag-list__empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-metadata__block {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PlusIcon } from './icons';
|
||||
import useFloatingMenu from './useFloatingMenu';
|
||||
|
||||
const normalizeOption = (option, index) => {
|
||||
if (option == null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof option === 'string') {
|
||||
return {
|
||||
id: option,
|
||||
label: option,
|
||||
original: option,
|
||||
index,
|
||||
};
|
||||
}
|
||||
const label = option.label ?? option.name;
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: option.id ?? label,
|
||||
label,
|
||||
original: option,
|
||||
index,
|
||||
};
|
||||
};
|
||||
|
||||
const QuickAddMenu = ({
|
||||
onSelectOption,
|
||||
onCreate,
|
||||
options = [],
|
||||
placeholder = 'Search or create…',
|
||||
createLabel = 'Add',
|
||||
emptyMessage = 'No matches',
|
||||
className,
|
||||
triggerAriaLabel = 'Add item',
|
||||
triggerTitle = 'Add',
|
||||
renderOption,
|
||||
menuMinWidth = 220,
|
||||
triggerClassName = 'icon-button quick-add__trigger',
|
||||
triggerContent = null,
|
||||
}) => {
|
||||
const anchorRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
toggle,
|
||||
close,
|
||||
menuRef,
|
||||
menuStyle,
|
||||
updatePosition,
|
||||
} = useFloatingMenu({
|
||||
anchorRef,
|
||||
minWidth: menuMinWidth,
|
||||
matchAnchorWidth: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return undefined;
|
||||
}
|
||||
setQuery('');
|
||||
setSubmitting(false);
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select?.();
|
||||
updatePosition();
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [isOpen, updatePosition]);
|
||||
|
||||
const normalizedOptions = useMemo(
|
||||
() =>
|
||||
options
|
||||
.map((option, index) => normalizeOption(option, index))
|
||||
.filter((option) => option && typeof option.label === 'string'),
|
||||
[options],
|
||||
);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!query.trim()) {
|
||||
return normalizedOptions;
|
||||
}
|
||||
const search = query.trim().toLowerCase();
|
||||
return normalizedOptions.filter((option) => option.label.toLowerCase().includes(search));
|
||||
}, [normalizedOptions, query]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (option) => {
|
||||
if (!option || !onSelectOption) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSelectOption(option.original ?? option.label, option);
|
||||
setSubmitting(false);
|
||||
close();
|
||||
} catch (error) {
|
||||
setSubmitting(false);
|
||||
console.error('[quick-add] option selection failed', error);
|
||||
}
|
||||
},
|
||||
[close, onSelectOption],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
if (!onCreate) {
|
||||
return;
|
||||
}
|
||||
const value = query.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onCreate(value);
|
||||
setSubmitting(false);
|
||||
close();
|
||||
} catch (error) {
|
||||
setSubmitting(false);
|
||||
console.error('[quick-add] creation failed', error);
|
||||
}
|
||||
},
|
||||
[close, onCreate, query],
|
||||
);
|
||||
|
||||
const canCreate = Boolean(onCreate);
|
||||
|
||||
return (
|
||||
<div className={className ? `quick-add ${className}` : 'quick-add'}>
|
||||
<button
|
||||
type="button"
|
||||
ref={anchorRef}
|
||||
className={triggerClassName}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
onClick={toggle}
|
||||
aria-label={triggerAriaLabel}
|
||||
title={triggerTitle}
|
||||
>
|
||||
{triggerContent ?? <PlusIcon />}
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div
|
||||
className="menu menu--floating quick-add__menu"
|
||||
ref={menuRef}
|
||||
style={menuStyle || undefined}
|
||||
role="menu"
|
||||
>
|
||||
{canCreate ? (
|
||||
<form className="quick-add__form" onSubmit={handleCreate}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={submitting}
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
<button type="submit" disabled={submitting || !query.trim()}>
|
||||
{createLabel}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
<div className="menu__list quick-add__list" role="presentation">
|
||||
{filteredOptions.length ? (
|
||||
filteredOptions.map((option) => {
|
||||
const key = option.id ?? option.index;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className="menu__item"
|
||||
role="menuitem"
|
||||
onClick={() => handleSelect(option)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{renderOption ? (
|
||||
renderOption(option.original ?? option.label, option)
|
||||
) : (
|
||||
<span className="menu__label">{option.label}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="menu__empty">{emptyMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAddMenu;
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useId } from 'react';
|
||||
import {
|
||||
IconChevronRight as TablerChevronRight,
|
||||
IconDownload as TablerDownload,
|
||||
@@ -22,7 +21,7 @@ import {
|
||||
IconMinusVertical,
|
||||
IconLogout,
|
||||
IconChevronDown,
|
||||
IconX,
|
||||
IconX as TablerIconX,
|
||||
IconSettings,
|
||||
IconCheck,
|
||||
IconPlus,
|
||||
@@ -239,9 +238,18 @@ export const IconFileStack = ({ className, size = 24, stroke = 160, ...rest }) =
|
||||
);
|
||||
};
|
||||
|
||||
export const IconX = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<TablerIconX
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CloseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconX
|
||||
className={composeClassName('icon', className)}
|
||||
className={className}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
|
||||
@@ -64,7 +64,10 @@ const useFloatingMenu = ({
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth);
|
||||
const viewportWidth = resolveViewportWidth();
|
||||
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
|
||||
const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN;
|
||||
const menu = menuRef.current;
|
||||
const menuHeight = menu?.offsetHeight ?? 0;
|
||||
|
||||
let left;
|
||||
if (align === 'end') {
|
||||
@@ -75,14 +78,17 @@ const useFloatingMenu = ({
|
||||
left = rect.left;
|
||||
}
|
||||
|
||||
const maxLeft = viewportWidth > 0
|
||||
? viewportWidth - desiredWidth - safeMargin
|
||||
: left;
|
||||
const clampedLeft = viewportWidth > 0
|
||||
? clamp(left, safeMargin, Math.max(maxLeft, safeMargin))
|
||||
: left;
|
||||
const maxLeft = viewportWidth > 0 ? viewportWidth - desiredWidth - safeMargin : left;
|
||||
const clampedLeft = viewportWidth > 0 ? clamp(left, safeMargin, Math.max(maxLeft, safeMargin)) : left;
|
||||
|
||||
const top = rect.bottom + offset;
|
||||
let top = rect.bottom + offset;
|
||||
if (viewportHeight > 0 && menuHeight > 0) {
|
||||
const projectedBottom = top + menuHeight + safeMargin;
|
||||
if (projectedBottom > viewportHeight) {
|
||||
const upwardTop = rect.top - offset - menuHeight;
|
||||
top = Math.max(upwardTop, safeMargin);
|
||||
}
|
||||
}
|
||||
|
||||
setMenuMetrics({
|
||||
top,
|
||||
|
||||
Reference in New Issue
Block a user