changes
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
export const CORRESPONDENT_ROLES = ['sender', 'receiver', 'other'];
|
||||||
|
|
||||||
|
export default CORRESPONDENT_ROLES;
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
function CorrespondentsPanel({
|
||||||
|
correspondents = [],
|
||||||
|
onRefresh,
|
||||||
|
onCreate,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
onNotify,
|
||||||
|
}) {
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [draftName, setDraftName] = useState('');
|
||||||
|
const [createName, setCreateName] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [deletingId, setDeletingId] = useState(null);
|
||||||
|
|
||||||
|
const startEdit = useCallback((correspondent) => {
|
||||||
|
setEditingId(correspondent.id);
|
||||||
|
setDraftName(correspondent.name || '');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelEdit = useCallback(() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setDraftName('');
|
||||||
|
setSaving(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!editingId) return;
|
||||||
|
const trimmed = draftName.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
onNotify?.('Correspondent name cannot be empty.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onUpdate(editingId, { name: trimmed });
|
||||||
|
cancelEdit();
|
||||||
|
} catch (
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
error
|
||||||
|
) {}
|
||||||
|
}, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (correspondent) => {
|
||||||
|
if (!correspondent?.id) return;
|
||||||
|
setDeletingId(correspondent.id);
|
||||||
|
try {
|
||||||
|
await onDelete(correspondent.id);
|
||||||
|
if (editingId === correspondent.id) {
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
error
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onDelete, editingId, cancelEdit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(
|
||||||
|
async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmed = createName.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
onNotify?.('Correspondent name cannot be empty.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await onCreate({ name: trimmed });
|
||||||
|
setCreateName('');
|
||||||
|
} catch (
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
error
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[createName, onCreate, onNotify],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSave, cancelEdit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderUsage = useCallback((usage) => {
|
||||||
|
if (!usage) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
const total = typeof usage.total === 'number' ? usage.total : 0;
|
||||||
|
const entries = usage.by_role ? Object.entries(usage.by_role) : [];
|
||||||
|
if (!entries.length) {
|
||||||
|
return total.toString();
|
||||||
|
}
|
||||||
|
const roleSummary = entries
|
||||||
|
.map(([role, count]) => `${role}: ${count}`)
|
||||||
|
.join(', ');
|
||||||
|
return `${total} (${roleSummary})`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="correspondents-panel column">
|
||||||
|
<div className="column-header">
|
||||||
|
<div className="column-header__titles">
|
||||||
|
<h2>Correspondents</h2>
|
||||||
|
<div className="column-subtitle">{correspondents.length} total</div>
|
||||||
|
</div>
|
||||||
|
<div className="header-actions correspondents-actions">
|
||||||
|
<form className="correspondents-actions__form" onSubmit={handleCreate}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="New correspondent name"
|
||||||
|
value={createName}
|
||||||
|
onChange={(event) => setCreateName(event.target.value)}
|
||||||
|
disabled={creating}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={creating || !createName.trim()}>
|
||||||
|
{creating ? 'Creating…' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={saving || creating || Boolean(deletingId)}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="column-body tags-panel__body">
|
||||||
|
{correspondents.length === 0 ? (
|
||||||
|
<div className="empty-state">No correspondents created yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="tags-table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Name</th>
|
||||||
|
<th scope="col" className="numeric">
|
||||||
|
Usage
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="actions">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{correspondents.map((correspondent) => {
|
||||||
|
const isEditing = editingId === correspondent.id;
|
||||||
|
return (
|
||||||
|
<tr key={correspondent.id} className={isEditing ? 'editing' : ''}>
|
||||||
|
<td className="tags-table__label">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
className="tags-table__label-input"
|
||||||
|
value={draftName}
|
||||||
|
onChange={(event) => setDraftName(event.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={saving}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{correspondent.name}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="numeric">{renderUsage(correspondent.usage)}</td>
|
||||||
|
<td className="actions">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="tags-table__edit-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={cancelEdit}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="tags-table__row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => startEdit(correspondent)}
|
||||||
|
disabled={deletingId === correspondent.id}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
onClick={() => handleDelete(correspondent)}
|
||||||
|
disabled={deletingId === correspondent.id}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CorrespondentsPanel;
|
||||||
@@ -0,0 +1,676 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { DownloadIcon, EditIcon } from '../ui/icons';
|
||||||
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
|
import { formatFileSize } from '../utils/format';
|
||||||
|
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||||
|
import { CORRESPONDENT_ROLES } from '../constants/correspondents';
|
||||||
|
|
||||||
|
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||||
|
|
||||||
|
const computeStackAngle = (docId, index) => {
|
||||||
|
if (index === 0) return 0;
|
||||||
|
let hash = 0;
|
||||||
|
const source = docId || `stack-${index}`;
|
||||||
|
for (let i = 0; i < source.length; i += 1) {
|
||||||
|
hash = (hash * 31 + source.charCodeAt(i)) % 997;
|
||||||
|
}
|
||||||
|
const magnitude = Math.max(3, (hash % 13) + 3);
|
||||||
|
const sign = index % 2 === 0 ? 1 : -1;
|
||||||
|
return magnitude * sign;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PreviewStack = ({
|
||||||
|
items = [],
|
||||||
|
maxItems = MAX_PREVIEW_STACK_ITEMS,
|
||||||
|
emptyMessage = 'Preview unavailable',
|
||||||
|
onItemActivate,
|
||||||
|
onOpenPreview,
|
||||||
|
activeItemId = null,
|
||||||
|
}) => {
|
||||||
|
if (!items.length) {
|
||||||
|
return <span className="meta">{emptyMessage}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const limited = items.slice(0, maxItems);
|
||||||
|
const hasMultiple = limited.length > 1;
|
||||||
|
const preparedItems = useMemo(
|
||||||
|
() =>
|
||||||
|
limited.map((entry, index) => ({
|
||||||
|
entry,
|
||||||
|
angle: index === 0 ? 0 : computeStackAngle(entry.id, index),
|
||||||
|
})),
|
||||||
|
[limited],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="preview-stack preview-stack--stacked">
|
||||||
|
{preparedItems.map(({ entry, angle }, index) => {
|
||||||
|
const transform = hasMultiple
|
||||||
|
? `translate(-50%, -50%) rotate(${angle}deg)`
|
||||||
|
: 'translate(-50%, -50%)';
|
||||||
|
const isFront = index === 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id || index}
|
||||||
|
className={`preview-stack__item orientation-${entry.orientation || 'landscape'}`}
|
||||||
|
style={{
|
||||||
|
zIndex: preparedItems.length - index,
|
||||||
|
transform,
|
||||||
|
}}
|
||||||
|
aria-hidden={hasMultiple && !onItemActivate && !onOpenPreview ? 'true' : undefined}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={entry.url}
|
||||||
|
alt={entry.alt || ''}
|
||||||
|
className="preview-stack__image"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (isFront && onOpenPreview) {
|
||||||
|
onOpenPreview(entry.id);
|
||||||
|
} else if (onItemActivate) {
|
||||||
|
onItemActivate(entry.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!onItemActivate && !onOpenPreview) return;
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (isFront && onOpenPreview) {
|
||||||
|
onOpenPreview(entry.id);
|
||||||
|
} else {
|
||||||
|
onItemActivate?.(entry.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DetailPanel = ({
|
||||||
|
selectedDocuments = [],
|
||||||
|
tags = [],
|
||||||
|
tagLookupById = new Map(),
|
||||||
|
tagLookupByLabel = new Map(),
|
||||||
|
onTagAdd,
|
||||||
|
onTagRemove,
|
||||||
|
onRegenerateThumbnails,
|
||||||
|
previewEntry,
|
||||||
|
onOpenPreview,
|
||||||
|
onBulkTagAdd,
|
||||||
|
onBulkTagRemove,
|
||||||
|
onBulkMove,
|
||||||
|
onBulkReanalyze,
|
||||||
|
folderOptions = [],
|
||||||
|
defaultMoveTarget = 'root',
|
||||||
|
onPromoteSelection,
|
||||||
|
activePreviewId = null,
|
||||||
|
onUpdateTitle = async () => false,
|
||||||
|
ensureAssetUrl = null,
|
||||||
|
getDocumentAsset = () => null,
|
||||||
|
correspondents = [],
|
||||||
|
onCorrespondentAdd,
|
||||||
|
onCorrespondentRemove,
|
||||||
|
resolveApiPath,
|
||||||
|
}) => {
|
||||||
|
const selectedCount = selectedDocuments.length;
|
||||||
|
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
|
||||||
|
|
||||||
|
const [titleEditDocId, setTitleEditDocId] = useState(null);
|
||||||
|
const [titleDraft, setTitleDraft] = useState('');
|
||||||
|
const [titleSaving, setTitleSaving] = useState(false);
|
||||||
|
const [titleError, setTitleError] = useState(null);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
onPromoteSelection?.(docId);
|
||||||
|
},
|
||||||
|
[onPromoteSelection],
|
||||||
|
);
|
||||||
|
|
||||||
|
const makePreviewItem = useCallback(
|
||||||
|
(doc) => {
|
||||||
|
if (!doc) return null;
|
||||||
|
const url = resolveDocumentAssetUrl(doc, 'preview', {
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
});
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const asset = getDocumentAsset(doc, 'preview');
|
||||||
|
const width = Number(asset?.metadata?.width) || 0;
|
||||||
|
const height = Number(asset?.metadata?.height) || 0;
|
||||||
|
const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape';
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
url,
|
||||||
|
orientation,
|
||||||
|
alt: doc.title || doc.original_name || 'Document preview',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[ensureAssetUrl, getDocumentAsset],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stackDocuments = useMemo(() => {
|
||||||
|
if (!selectedDocuments.length) return [];
|
||||||
|
const seen = new Set();
|
||||||
|
const ordered = [];
|
||||||
|
for (let index = selectedDocuments.length - 1; index >= 0; index -= 1) {
|
||||||
|
const doc = selectedDocuments[index];
|
||||||
|
if (!doc?.id || seen.has(doc.id)) continue;
|
||||||
|
seen.add(doc.id);
|
||||||
|
ordered.push(doc);
|
||||||
|
if (ordered.length >= MAX_PREVIEW_STACK_ITEMS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}, [selectedDocuments]);
|
||||||
|
|
||||||
|
const singlePreviewItems = useMemo(() => {
|
||||||
|
if (!singleDoc) return [];
|
||||||
|
const item = makePreviewItem(singleDoc);
|
||||||
|
return item ? [item] : [];
|
||||||
|
}, [singleDoc, makePreviewItem]);
|
||||||
|
|
||||||
|
const stackPreviews = useMemo(
|
||||||
|
() =>
|
||||||
|
stackDocuments
|
||||||
|
.map((doc) => makePreviewItem(doc))
|
||||||
|
.filter(Boolean),
|
||||||
|
[stackDocuments, makePreviewItem],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ensureAssetUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stackDocuments.forEach((doc) => {
|
||||||
|
resolveDocumentAssetUrl(doc, 'preview', {
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [stackDocuments, ensureAssetUrl, getDocumentAsset]);
|
||||||
|
|
||||||
|
const commonTags = useMemo(() => {
|
||||||
|
if (selectedCount < 2) return [];
|
||||||
|
const tagSets = selectedDocuments.map((doc) => new Set((doc.tags || []).map((tag) => tag.label)));
|
||||||
|
if (!tagSets.length) return [];
|
||||||
|
const intersection = new Set(tagSets[0]);
|
||||||
|
tagSets.slice(1).forEach((set) => {
|
||||||
|
[...intersection].forEach((label) => {
|
||||||
|
if (!set.has(label)) {
|
||||||
|
intersection.delete(label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return [...intersection];
|
||||||
|
}, [selectedDocuments, selectedCount]);
|
||||||
|
|
||||||
|
const stackTotalSizeBytes = useMemo(() => {
|
||||||
|
if (!stackPreviews.length) return 0;
|
||||||
|
const byId = new Map(selectedDocuments.map((doc) => [doc.id, doc]));
|
||||||
|
return stackPreviews.reduce((sum, item) => {
|
||||||
|
const source = byId.get(item.id);
|
||||||
|
const bytes = source?.current_version?.size_bytes;
|
||||||
|
return sum + (typeof bytes === 'number' ? bytes : 0);
|
||||||
|
}, 0);
|
||||||
|
}, [stackPreviews, selectedDocuments]);
|
||||||
|
|
||||||
|
const availableCorrespondents = useMemo(
|
||||||
|
() => (Array.isArray(correspondents) ? correspondents : []),
|
||||||
|
[correspondents],
|
||||||
|
);
|
||||||
|
|
||||||
|
const correspondentOptions = useMemo(() => {
|
||||||
|
const seen = new Set();
|
||||||
|
return availableCorrespondents
|
||||||
|
.map((entry) => (entry?.name || '').trim())
|
||||||
|
.filter((name) => {
|
||||||
|
if (!name) return false;
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (seen.has(lower)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(lower);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [availableCorrespondents]);
|
||||||
|
|
||||||
|
const renderSingle = () => {
|
||||||
|
if (!singleDoc) {
|
||||||
|
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = singleDoc.title || singleDoc.original_name;
|
||||||
|
const downloadHref = singleDoc.current_version?.download_path
|
||||||
|
? resolveApiPath?.(singleDoc.current_version.download_path)
|
||||||
|
: null;
|
||||||
|
const isEditingTitle = titleEditDocId === singleDoc.id;
|
||||||
|
const sizeBytes = Number(singleDoc.current_version?.size_bytes) || 0;
|
||||||
|
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : '—';
|
||||||
|
const issuedAt = singleDoc.issued_at
|
||||||
|
? new Date(singleDoc.issued_at).toLocaleString()
|
||||||
|
: '—';
|
||||||
|
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
|
||||||
|
const metadata =
|
||||||
|
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
|
||||||
|
const docCorrespondents = Array.isArray(singleDoc.correspondents)
|
||||||
|
? singleDoc.correspondents
|
||||||
|
: [];
|
||||||
|
const sortedCorrespondents = docCorrespondents.slice().sort((a, b) => {
|
||||||
|
const roleA = (a.role || '').toLowerCase();
|
||||||
|
const roleB = (b.role || '').toLowerCase();
|
||||||
|
const indexA = CORRESPONDENT_ROLES.indexOf(roleA);
|
||||||
|
const indexB = CORRESPONDENT_ROLES.indexOf(roleB);
|
||||||
|
if (indexA !== indexB) {
|
||||||
|
return (indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA) -
|
||||||
|
(indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB);
|
||||||
|
}
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="preview-pane preview-pane--stack">
|
||||||
|
<PreviewStack
|
||||||
|
items={singlePreviewItems}
|
||||||
|
maxItems={1}
|
||||||
|
emptyMessage="Preview loading…"
|
||||||
|
onItemActivate={handlePreviewActivate}
|
||||||
|
onOpenPreview={onOpenPreview}
|
||||||
|
activeItemId={activePreviewId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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 ghost"
|
||||||
|
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">
|
||||||
|
<div>
|
||||||
|
<strong>Uploaded:</strong>{' '}
|
||||||
|
{singleDoc.uploaded_at ? new Date(singleDoc.uploaded_at).toLocaleString() : '—'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Size:</strong>{' '}
|
||||||
|
{sizeLabel}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Type:</strong> {singleDoc.content_type || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Issued:</strong> {issuedAt}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Original filename:</strong>{' '}
|
||||||
|
{singleDoc.original_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="detail-actions">
|
||||||
|
<a
|
||||||
|
className="button-link with-icon"
|
||||||
|
href={downloadHref || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-disabled={!downloadHref}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!downloadHref) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DownloadIcon className="icon-inline" />
|
||||||
|
<span>Download</span>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => onOpenPreview(singleDoc.id)}
|
||||||
|
>
|
||||||
|
Open preview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => onRegenerateThumbnails(singleDoc.id)}
|
||||||
|
>
|
||||||
|
Re-run analysis
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Tags</dt>
|
||||||
|
<div className="tag-list">
|
||||||
|
{tagsForDoc.length ? (
|
||||||
|
tagsForDoc.map((tag) => {
|
||||||
|
const colorSource = tag?.color || tagLookupById.get(tag.id)?.color;
|
||||||
|
const style = getTagColorStyle(colorSource);
|
||||||
|
return (
|
||||||
|
<span key={tag.id} className="tag-pill" style={style || undefined}>
|
||||||
|
{tag.label}{' '}
|
||||||
|
<button type="button" onClick={() => onTagRemove(singleDoc.id, tag.id)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span className="meta">No tags yet.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="inline"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const input = event.currentTarget.elements.tag;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (!value) return;
|
||||||
|
onTagAdd(singleDoc, value, input);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input name="tag" placeholder="Add or create tag" list="tag-catalog" />
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
<datalist id="tag-catalog">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<option key={tag.id} value={tag.label} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Correspondents</dt>
|
||||||
|
<div className="correspondent-list">
|
||||||
|
{sortedCorrespondents.length ? (
|
||||||
|
sortedCorrespondents.map((entry) => {
|
||||||
|
const roleLabel = entry.role
|
||||||
|
? entry.role.charAt(0).toUpperCase() + entry.role.slice(1)
|
||||||
|
: 'Other';
|
||||||
|
return (
|
||||||
|
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill">
|
||||||
|
<span className="correspondent-pill__label">
|
||||||
|
<strong>{roleLabel}</strong>
|
||||||
|
<span>{entry.name}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="correspondent-pill__remove"
|
||||||
|
onClick={() =>
|
||||||
|
onCorrespondentRemove?.({
|
||||||
|
documentId: singleDoc.id,
|
||||||
|
correspondentId: entry.id,
|
||||||
|
role: entry.role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label={`Remove ${entry.name} as ${roleLabel}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span className="meta">No correspondents yet.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="correspondent-form"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const nameInput = form.elements.correspondent;
|
||||||
|
const roleSelect = form.elements.role;
|
||||||
|
const value = nameInput.value.trim();
|
||||||
|
const role = roleSelect.value;
|
||||||
|
if (!value) return;
|
||||||
|
onCorrespondentAdd?.({
|
||||||
|
document: singleDoc,
|
||||||
|
name: value,
|
||||||
|
role,
|
||||||
|
input: nameInput,
|
||||||
|
});
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name="correspondent"
|
||||||
|
placeholder="Add or create correspondent"
|
||||||
|
list="correspondent-catalog"
|
||||||
|
/>
|
||||||
|
<select name="role" defaultValue={CORRESPONDENT_ROLES[0]}>
|
||||||
|
{CORRESPONDENT_ROLES.map((role) => (
|
||||||
|
<option key={role} value={role}>
|
||||||
|
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
<datalist id="correspondent-catalog">
|
||||||
|
{correspondentOptions.map((name) => (
|
||||||
|
<option key={name} value={name} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{metadata && (
|
||||||
|
<div>
|
||||||
|
<dt>Metadata</dt>
|
||||||
|
<pre>{JSON.stringify(metadata, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBulk = () => {
|
||||||
|
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
||||||
|
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="preview-pane preview-pane--stack">
|
||||||
|
<PreviewStack
|
||||||
|
items={stackPreviews}
|
||||||
|
emptyMessage="No previews available."
|
||||||
|
onItemActivate={handlePreviewActivate}
|
||||||
|
onOpenPreview={onOpenPreview}
|
||||||
|
activeItemId={activePreviewId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 style={{ margin: 0 }}>{countLabel}</h3>
|
||||||
|
<div className="meta">
|
||||||
|
<div>
|
||||||
|
<strong>Total size (stack):</strong> {sizeLabel}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Common tags:</strong>{' '}
|
||||||
|
{commonTags.length ? commonTags.join(', ') : 'None'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{commonTags.length > 0 && (
|
||||||
|
<div className="bulk-tags">
|
||||||
|
<strong>Bulk tag operations</strong>
|
||||||
|
<form
|
||||||
|
className="inline"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const input = event.currentTarget.elements.tag;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (!value) return;
|
||||||
|
onBulkTagAdd?.({ label: value, input });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input name="tag" placeholder="Add tag to selection" list="tag-catalog" />
|
||||||
|
<button type="submit">Add tag</button>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
className="inline"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const input = event.currentTarget.elements.tag;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (!value) return;
|
||||||
|
onBulkTagRemove?.({ label: value, input });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input name="tag" placeholder="Remove tag from selection" list="tag-catalog" />
|
||||||
|
<button type="submit" className="secondary">
|
||||||
|
Remove tag
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<datalist id="tag-catalog">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<option key={tag.id} value={tag.label} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<div className="bulk-move">
|
||||||
|
<label htmlFor="detail-bulk-move" className="meta">
|
||||||
|
Move selection to folder
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="detail-bulk-move"
|
||||||
|
name="target"
|
||||||
|
defaultValue={defaultMoveTarget || 'root'}
|
||||||
|
onChange={(event) => onBulkMove?.({ target: event.target.value })}
|
||||||
|
>
|
||||||
|
{folderOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => onBulkReanalyze?.()}
|
||||||
|
>
|
||||||
|
Re-analyze selection
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="detail-panel column">
|
||||||
|
<div className="column-body scrollable">
|
||||||
|
{selectedCount <= 1 ? renderSingle() : renderBulk()}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DetailPanel;
|
||||||
@@ -302,7 +302,7 @@ const DocumentsTable = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td className="thumb-cell">
|
<td className="thumb-cell">
|
||||||
<div className="thumb-placeholder icon">
|
<div className="thumb-icon">
|
||||||
<FolderIcon className="icon-inline" size={18} />
|
<FolderIcon className="icon-inline" size={18} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
+7
-1161
File diff suppressed because it is too large
Load Diff
@@ -952,14 +952,16 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
box-shadow: 0 1px 3px var(--shadow-medium);
|
box-shadow: 0 1px 3px var(--shadow-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-placeholder.icon {
|
.thumb-icon {
|
||||||
font-size: 1.8rem;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
width: 36px;
|
|
||||||
height: 48px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-placeholder.icon svg {
|
.thumb-icon svg {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { getTagColorStyle, HEX_COLOR_PATTERN } from '../utils/colors';
|
||||||
|
|
||||||
|
function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) {
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [draftLabel, setDraftLabel] = useState('');
|
||||||
|
const [draftColor, setDraftColor] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deletingId, setDeletingId] = useState(null);
|
||||||
|
|
||||||
|
const startEdit = useCallback((tag) => {
|
||||||
|
setEditingId(tag.id);
|
||||||
|
setDraftLabel(tag.label || '');
|
||||||
|
setDraftColor(tag.color || '');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelEdit = useCallback(() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setDraftLabel('');
|
||||||
|
setDraftColor('');
|
||||||
|
setSaving(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const colorPickerValue = useMemo(() => {
|
||||||
|
if (!draftColor) {
|
||||||
|
return '#3366ff';
|
||||||
|
}
|
||||||
|
const match = HEX_COLOR_PATTERN.exec(draftColor.trim());
|
||||||
|
if (!match) {
|
||||||
|
return '#3366ff';
|
||||||
|
}
|
||||||
|
return `#${match[1].toLowerCase()}`;
|
||||||
|
}, [draftColor]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!editingId) return;
|
||||||
|
|
||||||
|
const trimmedLabel = draftLabel.trim();
|
||||||
|
if (!trimmedLabel) {
|
||||||
|
onNotify?.('Tag label cannot be empty.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedColor = draftColor.trim();
|
||||||
|
const colorPattern = /^#([0-9a-fA-F]{6})$/;
|
||||||
|
if (trimmedColor && !colorPattern.test(trimmedColor)) {
|
||||||
|
onNotify?.('Colors must use the #RRGGBB format.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onUpdateTag(editingId, {
|
||||||
|
label: trimmedLabel,
|
||||||
|
color: trimmedColor ? trimmedColor : null,
|
||||||
|
});
|
||||||
|
cancelEdit();
|
||||||
|
} catch (updateError) {
|
||||||
|
const message = updateError?.message || 'Failed to update tag.';
|
||||||
|
onNotify?.(message, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [editingId, draftLabel, draftColor, onUpdateTag, cancelEdit, onNotify]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSave, cancelEdit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (tag) => {
|
||||||
|
if (!tag?.id || typeof onDeleteTag !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeletingId(tag.id);
|
||||||
|
try {
|
||||||
|
await onDeleteTag(tag.id);
|
||||||
|
if (editingId === tag.id) {
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
} catch (deleteError) {
|
||||||
|
const message = deleteError?.message || 'Failed to delete tag.';
|
||||||
|
onNotify?.(message, 'error');
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onDeleteTag, editingId, cancelEdit, onNotify],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="tags-panel column">
|
||||||
|
<div className="column-header">
|
||||||
|
<div className="column-header__titles">
|
||||||
|
<h2>Tags</h2>
|
||||||
|
<div className="column-subtitle">{tags.length} total</div>
|
||||||
|
</div>
|
||||||
|
<div className="header-actions">
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={saving || Boolean(deletingId)}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="column-body tags-panel__body">
|
||||||
|
{tags.length === 0 ? (
|
||||||
|
<div className="empty-state">No tags created yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="tags-table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Tag</th>
|
||||||
|
<th scope="col">Color</th>
|
||||||
|
<th scope="col" className="numeric">
|
||||||
|
Documents
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="actions">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const isEditing = editingId === tag.id;
|
||||||
|
return (
|
||||||
|
<tr key={tag.id} className={isEditing ? 'editing' : ''}>
|
||||||
|
<td className="tags-table__label">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
className="tags-table__label-input"
|
||||||
|
value={draftLabel}
|
||||||
|
onChange={(event) => setDraftLabel(event.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={saving || deletingId === tag.id}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="badge tag-chip"
|
||||||
|
style={getTagColorStyle(tag.color) || undefined}
|
||||||
|
>
|
||||||
|
{tag.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="tags-table__color-editor">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="tags-table__color-picker"
|
||||||
|
value={colorPickerValue}
|
||||||
|
onChange={(event) => setDraftColor(event.target.value)}
|
||||||
|
disabled={saving || deletingId === tag.id}
|
||||||
|
aria-label="Pick tag color"
|
||||||
|
/>
|
||||||
|
{draftColor && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => setDraftColor('')}
|
||||||
|
disabled={saving || deletingId === tag.id}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : tag.color ? (
|
||||||
|
<span
|
||||||
|
className="tags-table__swatch"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
aria-label={`Tag color ${tag.color}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="meta">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="numeric">{tag.usage_count ?? 0}</td>
|
||||||
|
<td className="actions">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="tags-table__edit-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || deletingId === tag.id}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={cancelEdit}
|
||||||
|
disabled={saving || deletingId === tag.id}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
onClick={() => handleDelete(tag)}
|
||||||
|
disabled={deletingId === tag.id}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="tags-table__row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => startEdit(tag)}
|
||||||
|
disabled={deletingId === tag.id}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
onClick={() => handleDelete(tag)}
|
||||||
|
disabled={deletingId === tag.id}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TagsPanel;
|
||||||
Reference in New Issue
Block a user