detailpanel

This commit is contained in:
2025-11-01 04:16:31 +01:00
parent fd13edae6d
commit 303ece0529
10 changed files with 1334 additions and 625 deletions
+55 -6
View File
@@ -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,
+26
View File
@@ -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(() => {
+65 -484
View File
@@ -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;
+4 -4
View File
@@ -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,
+113 -91
View File
@@ -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
View File
@@ -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 {
+205
View File
@@ -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;
+11 -3
View File
@@ -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}
+13 -7
View File
@@ -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,