detailpanel
This commit is contained in:
@@ -1859,7 +1859,7 @@ const AppLayout = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCorrespondentAdd = useCallback(
|
const handleCorrespondentAdd = useCallback(
|
||||||
async ({ document, name, input }) => {
|
async ({ document, name, input = null, option = null }) => {
|
||||||
if (!document?.id) {
|
if (!document?.id) {
|
||||||
throw new Error('Missing document for correspondent assignment.');
|
throw new Error('Missing document for correspondent assignment.');
|
||||||
}
|
}
|
||||||
@@ -1869,7 +1869,12 @@ const AppLayout = () => {
|
|||||||
return;
|
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) {
|
if (!target) {
|
||||||
try {
|
try {
|
||||||
target = await handleCorrespondentCreate({ name: trimmed });
|
target = await handleCorrespondentCreate({ name: trimmed });
|
||||||
@@ -3585,6 +3590,35 @@ const AppLayout = () => {
|
|||||||
[notifyApiError, setStatusMessage, updateDocumentCaches, extractDocumentFromResponse],
|
[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(
|
const applyTagRemovalToCaches = useCallback(
|
||||||
(documentId, tagId) => {
|
(documentId, tagId) => {
|
||||||
if (!documentId || !tagId) {
|
if (!documentId || !tagId) {
|
||||||
@@ -3631,10 +3665,21 @@ const AppLayout = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleTagAdd = useCallback(
|
const handleTagAdd = useCallback(
|
||||||
async (document, label, input) => {
|
async (document, label, extras = null) => {
|
||||||
const normalizedLabel = tagManager.normalizeLabel(label);
|
const normalizedLabel = tagManager.normalizeLabel(label);
|
||||||
let tag =
|
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;
|
tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
const payload = tagManager.buildPayload({ label: normalizedLabel });
|
const payload = tagManager.buildPayload({ label: normalizedLabel });
|
||||||
@@ -3644,7 +3689,9 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] });
|
await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] });
|
||||||
setStatusMessage('Tag assigned.', 'success');
|
setStatusMessage('Tag assigned.', 'success');
|
||||||
|
if (input && typeof input === 'object') {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
}
|
||||||
await refreshCurrentFolder();
|
await refreshCurrentFolder();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyApiError(error, 'Failed to assign tag.');
|
notifyApiError(error, 'Failed to assign tag.');
|
||||||
@@ -5104,6 +5151,7 @@ const AppLayout = () => {
|
|||||||
onPromoteSelection: promoteSelectionOrder,
|
onPromoteSelection: promoteSelectionOrder,
|
||||||
activePreviewId,
|
activePreviewId,
|
||||||
onUpdateTitle: handleDocumentTitleUpdate,
|
onUpdateTitle: handleDocumentTitleUpdate,
|
||||||
|
onUpdateIssued: handleDocumentIssuedUpdate,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
ensurePreviewData,
|
ensurePreviewData,
|
||||||
@@ -5131,6 +5179,7 @@ const AppLayout = () => {
|
|||||||
handleCorrespondentRemove,
|
handleCorrespondentRemove,
|
||||||
handleDetailPanelClose,
|
handleDetailPanelClose,
|
||||||
handleDocumentTitleUpdate,
|
handleDocumentTitleUpdate,
|
||||||
|
handleDocumentIssuedUpdate,
|
||||||
handleTagAdd,
|
handleTagAdd,
|
||||||
handleTagRemove,
|
handleTagRemove,
|
||||||
handleThumbnailRegeneration,
|
handleThumbnailRegeneration,
|
||||||
|
|||||||
@@ -68,6 +68,20 @@ export const useWorkspaceSurface = ({
|
|||||||
if (!showPreviewWorkspace || !previewWorkspaceDocument) {
|
if (!showPreviewWorkspace || !previewWorkspaceDocument) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const detailExtras = detailPanelProps || {};
|
||||||
|
const {
|
||||||
|
tagLookupById,
|
||||||
|
tags: tagOptions,
|
||||||
|
onTagAdd,
|
||||||
|
onTagRemove,
|
||||||
|
correspondents,
|
||||||
|
onCorrespondentAdd,
|
||||||
|
onCorrespondentRemove,
|
||||||
|
onUpdateTitle,
|
||||||
|
onUpdateIssued,
|
||||||
|
resolveFolderPath,
|
||||||
|
onFolderNavigate,
|
||||||
|
} = detailExtras;
|
||||||
return createPreviewSurface({
|
return createPreviewSurface({
|
||||||
document: previewWorkspaceDocument,
|
document: previewWorkspaceDocument,
|
||||||
previewEntry: previewWorkspaceEntry,
|
previewEntry: previewWorkspaceEntry,
|
||||||
@@ -79,6 +93,17 @@ export const useWorkspaceSurface = ({
|
|||||||
onRegenerate: handleThumbnailRegeneration,
|
onRegenerate: handleThumbnailRegeneration,
|
||||||
onClose: closeDocumentPreview,
|
onClose: closeDocumentPreview,
|
||||||
renderSidebarToggle,
|
renderSidebarToggle,
|
||||||
|
tagLookupById,
|
||||||
|
tagOptions,
|
||||||
|
onTagAdd,
|
||||||
|
onTagRemove,
|
||||||
|
correspondents,
|
||||||
|
onCorrespondentAdd,
|
||||||
|
onCorrespondentRemove,
|
||||||
|
onUpdateTitle,
|
||||||
|
onUpdateIssued,
|
||||||
|
resolveFolderPath,
|
||||||
|
onFolderNavigate,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
showPreviewWorkspace,
|
showPreviewWorkspace,
|
||||||
@@ -92,6 +117,7 @@ export const useWorkspaceSurface = ({
|
|||||||
handleThumbnailRegeneration,
|
handleThumbnailRegeneration,
|
||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
renderSidebarToggle,
|
renderSidebarToggle,
|
||||||
|
detailPanelProps,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const workspaceSurface = useMemo(() => {
|
const workspaceSurface = useMemo(() => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
EditIcon,
|
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ChevronsRightIcon,
|
ChevronsRightIcon,
|
||||||
@@ -10,13 +9,18 @@ import {
|
|||||||
TextScanIcon,
|
TextScanIcon,
|
||||||
} from '../ui/icons';
|
} from '../ui/icons';
|
||||||
import PanelHeader from '../ui/PanelHeader';
|
import PanelHeader from '../ui/PanelHeader';
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
|
||||||
import { formatFileSize } from '../utils/format';
|
import { formatFileSize } from '../utils/format';
|
||||||
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||||
import { describeDocumentSummary } from '../documents/documentSummary';
|
import { describeDocumentSummary } from '../documents/documentSummary';
|
||||||
import { createDocumentActionState } from '../documents/documentActions';
|
import { createDocumentActionState } from '../documents/documentActions';
|
||||||
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
||||||
|
import DocumentSummarySection, {
|
||||||
|
TagSection,
|
||||||
|
CorrespondentSection,
|
||||||
|
sortCorrespondents,
|
||||||
|
buildCorrespondentOptions,
|
||||||
|
} from '../documents/DocumentSummarySection';
|
||||||
|
|
||||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||||
|
|
||||||
@@ -29,156 +33,6 @@ const derivePreviewOrientation = (metadata) => {
|
|||||||
return 'landscape';
|
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) => {
|
const computeStackAngle = (docId, index) => {
|
||||||
if (index === 0) return 0;
|
if (index === 0) return 0;
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
@@ -294,6 +148,7 @@ const DetailPanel = ({
|
|||||||
onBulkCorrespondentRemove,
|
onBulkCorrespondentRemove,
|
||||||
onPromoteSelection,
|
onPromoteSelection,
|
||||||
onUpdateTitle = async () => false,
|
onUpdateTitle = async () => false,
|
||||||
|
onUpdateIssued = async () => false,
|
||||||
ensureAssetUrl = null,
|
ensureAssetUrl = null,
|
||||||
getDocumentAsset = () => null,
|
getDocumentAsset = () => null,
|
||||||
ensurePreviewData = () => Promise.resolve(),
|
ensurePreviewData = () => Promise.resolve(),
|
||||||
@@ -337,10 +192,6 @@ const DetailPanel = ({
|
|||||||
return `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
return `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
||||||
}, [selectedCount, detailSummary]);
|
}, [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 [zoomedPreview, setZoomedPreview] = useState(null);
|
||||||
|
|
||||||
const bulkDocumentIds = useMemo(
|
const bulkDocumentIds = useMemo(
|
||||||
@@ -348,67 +199,10 @@ const DetailPanel = ({
|
|||||||
[selectedDocuments],
|
[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(() => {
|
useEffect(() => {
|
||||||
setZoomedPreview(null);
|
setZoomedPreview(null);
|
||||||
}, [selectionKey]);
|
}, [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(
|
const handlePreviewActivate = useCallback(
|
||||||
(docId) => {
|
(docId) => {
|
||||||
if (!docId) return;
|
if (!docId) return;
|
||||||
@@ -569,134 +363,16 @@ const DetailPanel = ({
|
|||||||
}, 0);
|
}, 0);
|
||||||
}, [stackPreviews, selectedDocuments]);
|
}, [stackPreviews, selectedDocuments]);
|
||||||
|
|
||||||
const availableCorrespondents = useMemo(
|
const correspondentOptions = useMemo(
|
||||||
() => (Array.isArray(correspondents) ? correspondents : []),
|
() => buildCorrespondentOptions(Array.isArray(correspondents) ? 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(() => {
|
const singleCorrespondents = useMemo(() => {
|
||||||
if (!singleDoc) return [];
|
if (!singleDoc) return [];
|
||||||
return sortCorrespondents(singleDoc.correspondents || []);
|
return sortCorrespondents(singleDoc.correspondents || []);
|
||||||
}, [singleDoc]);
|
}, [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(() => {
|
const bulkCorrespondents = useMemo(() => {
|
||||||
if (selectedDocuments.length <= 1) {
|
if (selectedDocuments.length <= 1) {
|
||||||
const doc = selectedDocuments[0];
|
const doc = selectedDocuments[0];
|
||||||
@@ -971,15 +647,11 @@ const DetailPanel = ({
|
|||||||
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayName = singleDoc.title || singleDoc.original_name;
|
const effectiveCardinality =
|
||||||
const isEditingTitle = titleEditDocId === singleDoc.id;
|
singlePreviewNavigator.cardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
|
||||||
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 canGoPrev = singlePreviewNavigator.canGoPrev;
|
const canGoPrev = singlePreviewNavigator.canGoPrev;
|
||||||
const canGoNext = singlePreviewNavigator.canGoNext;
|
const canGoNext = singlePreviewNavigator.canGoNext;
|
||||||
const hasPreviewImage = singleHasPreview;
|
const hasPreviewImage = Boolean(singlePreviewNavigator.currentUrl);
|
||||||
const interceptNavPointer = (event) => {
|
const interceptNavPointer = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -1035,104 +707,21 @@ const DetailPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="doc-title-row">
|
<DocumentSummarySection
|
||||||
{isEditingTitle ? (
|
document={singleDoc}
|
||||||
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
|
tagLookupById={tagLookupById}
|
||||||
<input
|
tagOptions={tags}
|
||||||
value={titleDraft}
|
onTagAdd={(doc, value, extras) => onTagAdd(doc, value, extras)}
|
||||||
onChange={(event) => {
|
onTagRemove={(docId, tagId) => onTagRemove(docId, tagId)}
|
||||||
setTitleDraft(event.target.value);
|
correspondents={singleCorrespondents}
|
||||||
if (titleError) {
|
correspondentOptions={correspondentOptions}
|
||||||
setTitleError(null);
|
onCorrespondentAdd={onCorrespondentAdd}
|
||||||
}
|
onCorrespondentRemove={onCorrespondentRemove}
|
||||||
}}
|
onUpdateTitle={onUpdateTitle}
|
||||||
onKeyDown={(event) => {
|
onUpdateIssued={onUpdateIssued}
|
||||||
if (event.key === 'Escape') {
|
resolveFolderPath={resolveFolderPath}
|
||||||
event.preventDefault();
|
onFolderNavigate={onFolderNavigate}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
<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 renderBulk = () => {
|
||||||
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
||||||
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
|
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
|
||||||
|
const headerLabel = `${countLabel}${sizeLabel ? ` (${sizeLabel})` : ''}`;
|
||||||
const topDocIdLocal = topDocId;
|
const topDocIdLocal = topDocId;
|
||||||
const topCardinalityLocal = topEffectiveCardinality;
|
const topCardinalityLocal = topEffectiveCardinality;
|
||||||
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
|
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
|
||||||
@@ -1200,35 +790,26 @@ const DetailPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<h3 style={{ margin: 0 }}>{countLabel}</h3>
|
<h3 style={{ margin: 0 }}>{headerLabel}</h3>
|
||||||
<div className="meta">
|
|
||||||
<div>
|
|
||||||
<strong>Total size (stack):</strong> {sizeLabel}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<TagSection
|
<TagSection
|
||||||
title="Tags"
|
|
||||||
tags={bulkTagUnion}
|
tags={bulkTagUnion}
|
||||||
emptyMessage="No tags assigned."
|
emptyMessage="No tags assigned."
|
||||||
onRemove={(tag) => onBulkTagRemove?.({ label: tag.label, documentIds })}
|
onRemove={(tag) => onBulkTagRemove?.({ label: tag.label, documentIds })}
|
||||||
onAdd={({ value, input }) =>
|
onAdd={({ value }) =>
|
||||||
onBulkTagAdd?.({ label: value, input, documentIds })
|
onBulkTagAdd?.({ label: value, input: null, documentIds })
|
||||||
}
|
}
|
||||||
addPlaceholder="Add tag to selection"
|
addPlaceholder="Add tag to selection"
|
||||||
addButtonLabel="Add tag"
|
addButtonLabel="Add tag"
|
||||||
datalistId="tag-catalog-bulk"
|
|
||||||
datalistOptions={tags}
|
datalistOptions={tags}
|
||||||
className="bulk-tags"
|
className="bulk-tags"
|
||||||
/>
|
/>
|
||||||
<CorrespondentSection
|
<CorrespondentSection
|
||||||
title="Correspondents"
|
|
||||||
entries={bulkCorrespondents}
|
entries={bulkCorrespondents}
|
||||||
onRemove={handleBulkCorrespondentRemove}
|
onRemove={handleBulkCorrespondentRemove}
|
||||||
onAdd={({ name, input }) =>
|
onAdd={({ name }) =>
|
||||||
onBulkCorrespondentAdd?.({ name, input, documentIds })
|
onBulkCorrespondentAdd?.({ name, input: null, documentIds })
|
||||||
}
|
}
|
||||||
addPlaceholder="Add correspondent to selection"
|
addPlaceholder="Add correspondent to selection"
|
||||||
datalistId="correspondent-catalog-bulk"
|
|
||||||
datalistOptions={correspondentOptions}
|
datalistOptions={correspondentOptions}
|
||||||
showCount
|
showCount
|
||||||
className="bulk-correspondents"
|
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: '—',
|
mimeTypeLabel: '—',
|
||||||
sizeLabel: '—',
|
sizeLabel: '—',
|
||||||
createdAtLabel: '—',
|
createdAtLabel: '—',
|
||||||
issuedAtLabel: '—',
|
issuedLabel: '—',
|
||||||
updatedAtLabel: '—',
|
updatedAtLabel: '—',
|
||||||
pageCount: null,
|
pageCount: null,
|
||||||
pageCountLabel: '—',
|
pageCountLabel: '—',
|
||||||
@@ -76,7 +76,7 @@ export const describeDocumentSummary = (document, options = {}) => {
|
|||||||
const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—';
|
const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—';
|
||||||
|
|
||||||
const createdAtLabel = formatDateTime(document.created_at);
|
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 updatedAtLabel = formatDateTime(document.updated_at);
|
||||||
|
|
||||||
const folderLabel = document.folder_path || document.folder_name || null;
|
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: 'created', label: 'Created', value: createdAtLabel },
|
||||||
{ key: 'size', label: 'Size', value: sizeLabel },
|
{ key: 'size', label: 'Size', value: sizeLabel },
|
||||||
{ key: 'type', label: 'Type', value: mimeTypeLabel },
|
{ 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: 'pages', label: 'Pages', value: pageCountLabel },
|
||||||
{ key: 'updated', label: 'Updated', value: updatedAtLabel },
|
{ key: 'updated', label: 'Updated', value: updatedAtLabel },
|
||||||
{ key: 'folder', label: 'Folder', value: folderLabel || '—' },
|
{ key: 'folder', label: 'Folder', value: folderLabel || '—' },
|
||||||
@@ -110,7 +110,7 @@ export const describeDocumentSummary = (document, options = {}) => {
|
|||||||
mimeTypeLabel,
|
mimeTypeLabel,
|
||||||
sizeLabel,
|
sizeLabel,
|
||||||
createdAtLabel,
|
createdAtLabel,
|
||||||
issuedAtLabel,
|
issuedLabel,
|
||||||
updatedAtLabel,
|
updatedAtLabel,
|
||||||
pageCount,
|
pageCount,
|
||||||
pageCountLabel,
|
pageCountLabel,
|
||||||
|
|||||||
@@ -1,92 +1,99 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
|
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';
|
import { createDocumentActionState } from '../documents/documentActions';
|
||||||
|
|
||||||
const PreviewWorkspace = ({
|
const formatDateTime = (value) => {
|
||||||
document,
|
|
||||||
previewEntry,
|
|
||||||
}) => {
|
|
||||||
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) {
|
if (!value) {
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
|
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const detailItems = [
|
const PreviewWorkspace = ({
|
||||||
{ label: 'Title', value: summary.title || '—' },
|
document,
|
||||||
{ label: 'Archive Reference', value: document.archive_serial || '—' },
|
previewEntry,
|
||||||
{ label: 'Issued On', value: formatDateTime(document.issued_at) },
|
tagLookupById,
|
||||||
{ label: 'Correspondent', value: correspondents || '—' },
|
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',
|
label: 'Filename',
|
||||||
value: document.archive_path || document.filename || '—',
|
value: document.archive_path || document.filename || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Original Filename',
|
label: 'Original filename',
|
||||||
value: document.original_name || '—',
|
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',
|
label: 'SHA-256 checksum',
|
||||||
value: document.current_version?.filename || document.archive_path || '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'SHA-256 Checksum',
|
|
||||||
value: document.current_version?.checksum || '—',
|
value: document.current_version?.checksum || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Original File Size',
|
label: 'Content type',
|
||||||
value: summary.sizeLabel,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Original MIME Type',
|
|
||||||
value: document.content_type || '—',
|
value: document.content_type || '—',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const metadata =
|
}, [document]);
|
||||||
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
|
|
||||||
|
const metadataPayload = useMemo(() => {
|
||||||
|
if (!document || !document.metadata || Object.keys(document.metadata).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return document.metadata;
|
||||||
|
}, [document]);
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="preview-workspace">
|
<section className="preview-workspace">
|
||||||
<div className="preview-workspace__details">
|
<div className="preview-workspace__details">
|
||||||
<section className="preview-section">
|
<DocumentSummarySection
|
||||||
<h3 className="preview-section__title">Details</h3>
|
document={document}
|
||||||
<dl className="preview-section__list">
|
tagLookupById={tagLookupById}
|
||||||
{detailItems.map(({ label, value }) => (
|
tagOptions={tagOptions}
|
||||||
<div className="preview-section__item" key={label}>
|
onTagAdd={onTagAdd}
|
||||||
<dt>{label}</dt>
|
onTagRemove={onTagRemove}
|
||||||
<dd>{value || '—'}</dd>
|
correspondents={sortedCorrespondents}
|
||||||
</div>
|
correspondentOptions={correspondentOptions}
|
||||||
))}
|
onCorrespondentAdd={onCorrespondentAdd}
|
||||||
</dl>
|
onCorrespondentRemove={onCorrespondentRemove}
|
||||||
</section>
|
onUpdateTitle={onUpdateTitle}
|
||||||
<section className="preview-section">
|
onUpdateIssued={onUpdateIssued}
|
||||||
<h3 className="preview-section__title">Content</h3>
|
resolveFolderPath={resolveFolderPath}
|
||||||
<p className="preview-section__placeholder">
|
onFolderNavigate={onFolderNavigate}
|
||||||
Full OCR text will appear here in a future update. Use the toolbar button to open the OCR view for now.
|
/>
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
<section className="preview-section">
|
<section className="preview-section">
|
||||||
<h3 className="preview-section__title">Metadata</h3>
|
<h3 className="preview-section__title">Metadata</h3>
|
||||||
<dl className="preview-section__list">
|
<dl className="preview-section__list">
|
||||||
@@ -97,25 +104,13 @@ const PreviewWorkspace = ({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
{metadata ? (
|
{metadataPayload ? (
|
||||||
<details className="preview-section__payload">
|
<details className="preview-section__payload">
|
||||||
<summary>Show metadata payload</summary>
|
<summary>Show metadata payload</summary>
|
||||||
<pre>{JSON.stringify(metadata, null, 2)}</pre>
|
<pre>{JSON.stringify(metadataPayload, null, 2)}</pre>
|
||||||
</details>
|
</details>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</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>
|
||||||
<div className="preview-workspace__viewer">
|
<div className="preview-workspace__viewer">
|
||||||
{!previewEntry?.url ? (
|
{!previewEntry?.url ? (
|
||||||
@@ -123,7 +118,7 @@ const PreviewWorkspace = ({
|
|||||||
) : (
|
) : (
|
||||||
<iframe
|
<iframe
|
||||||
src={previewEntry.url}
|
src={previewEntry.url}
|
||||||
title={`Preview of ${title}`}
|
title={`Preview of ${document.title}`}
|
||||||
className="preview-workspace__object"
|
className="preview-workspace__object"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -208,6 +203,17 @@ export const createPreviewSurface = ({
|
|||||||
onRegenerate,
|
onRegenerate,
|
||||||
onClose,
|
onClose,
|
||||||
renderSidebarToggle,
|
renderSidebarToggle,
|
||||||
|
tagLookupById,
|
||||||
|
tagOptions,
|
||||||
|
onTagAdd,
|
||||||
|
onTagRemove,
|
||||||
|
correspondents,
|
||||||
|
onCorrespondentAdd,
|
||||||
|
onCorrespondentRemove,
|
||||||
|
onUpdateTitle,
|
||||||
|
onUpdateIssued,
|
||||||
|
resolveFolderPath,
|
||||||
|
onFolderNavigate,
|
||||||
}) => {
|
}) => {
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return null;
|
return null;
|
||||||
@@ -255,7 +261,23 @@ export const createPreviewSurface = ({
|
|||||||
key: 'preview',
|
key: 'preview',
|
||||||
variant: 'preview',
|
variant: 'preview',
|
||||||
header,
|
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,
|
supportsDetail: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+211
-30
@@ -1079,12 +1079,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
min-width: 14rem;
|
min-width: 14rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.correspondent-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.4rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.correspondent-pill {
|
.correspondent-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1094,11 +1089,10 @@ button.danger:hover:not([disabled]) {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.correspondent-pill__label {
|
.correspondent-pill__label {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1112,8 +1106,9 @@ button.danger:hover:not([disabled]) {
|
|||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
display: inline-flex;
|
||||||
line-height: 1;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -2333,10 +2328,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-chip__label {
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-chip__remove {
|
.tag-chip__remove {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -2345,7 +2336,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0.9em;
|
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
@@ -2438,12 +2428,105 @@ button.danger:hover:not([disabled]) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 1.25rem;
|
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 {
|
.preview-pane {
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
@@ -2470,54 +2553,92 @@ button.danger:hover:not([disabled]) {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .meta {
|
.detail-panel .meta,
|
||||||
|
.document-summary .meta {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--muted);
|
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;
|
display: inline-flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
align-items: center;
|
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);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .detail-folder-path__link:hover,
|
.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;
|
text-decoration: underline;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .detail-folder-path__separator {
|
.detail-panel .detail-folder-path__separator,
|
||||||
|
.document-summary .detail-folder-path__separator {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .detail-folder-path__segment {
|
.detail-panel .detail-folder-path__segment,
|
||||||
|
.document-summary .detail-folder-path__segment {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .doc-title-row {
|
.detail-panel .doc-title-row,
|
||||||
|
.document-summary .doc-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.35rem;
|
||||||
margin: 0.25rem 0 0.5rem;
|
margin: 0.25rem 0 0.5rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .doc-title-edit {
|
.doc-title-row__primary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
width: 100%;
|
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;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -2527,6 +2648,59 @@ button.danger:hover:not([disabled]) {
|
|||||||
margin-top: 0.2rem;
|
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 {
|
.status-inline.error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
@@ -2729,11 +2903,18 @@ button.danger:hover:not([disabled]) {
|
|||||||
margin: 0.2rem 0 0;
|
margin: 0.2rem 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .tag-list {
|
.detail-panel .tag-list,
|
||||||
|
.document-summary .tag-list,
|
||||||
|
.correspondent-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.3rem;
|
gap: 0.5rem;
|
||||||
margin-top: 0.5rem;
|
margin: 0.5rem 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list__empty {
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-metadata__block {
|
.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 {
|
import {
|
||||||
IconChevronRight as TablerChevronRight,
|
IconChevronRight as TablerChevronRight,
|
||||||
IconDownload as TablerDownload,
|
IconDownload as TablerDownload,
|
||||||
@@ -22,7 +21,7 @@ import {
|
|||||||
IconMinusVertical,
|
IconMinusVertical,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconX,
|
IconX as TablerIconX,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconPlus,
|
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 }) => (
|
export const CloseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<IconX
|
<IconX
|
||||||
className={composeClassName('icon', className)}
|
className={className}
|
||||||
size={size}
|
size={size}
|
||||||
stroke={stroke}
|
stroke={stroke}
|
||||||
{...rest}
|
{...rest}
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ const useFloatingMenu = ({
|
|||||||
const rect = anchor.getBoundingClientRect();
|
const rect = anchor.getBoundingClientRect();
|
||||||
const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth);
|
const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth);
|
||||||
const viewportWidth = resolveViewportWidth();
|
const viewportWidth = resolveViewportWidth();
|
||||||
|
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
|
||||||
const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN;
|
const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN;
|
||||||
|
const menu = menuRef.current;
|
||||||
|
const menuHeight = menu?.offsetHeight ?? 0;
|
||||||
|
|
||||||
let left;
|
let left;
|
||||||
if (align === 'end') {
|
if (align === 'end') {
|
||||||
@@ -75,14 +78,17 @@ const useFloatingMenu = ({
|
|||||||
left = rect.left;
|
left = rect.left;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxLeft = viewportWidth > 0
|
const maxLeft = viewportWidth > 0 ? viewportWidth - desiredWidth - safeMargin : left;
|
||||||
? viewportWidth - desiredWidth - safeMargin
|
const clampedLeft = viewportWidth > 0 ? clamp(left, safeMargin, Math.max(maxLeft, safeMargin)) : left;
|
||||||
: 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({
|
setMenuMetrics({
|
||||||
top,
|
top,
|
||||||
|
|||||||
Reference in New Issue
Block a user