diff --git a/frontend/src/constants/correspondents.js b/frontend/src/constants/correspondents.js
new file mode 100644
index 0000000..eb9f496
--- /dev/null
+++ b/frontend/src/constants/correspondents.js
@@ -0,0 +1,3 @@
+export const CORRESPONDENT_ROLES = ['sender', 'receiver', 'other'];
+
+export default CORRESPONDENT_ROLES;
diff --git a/frontend/src/correspondents/CorrespondentsPanel.jsx b/frontend/src/correspondents/CorrespondentsPanel.jsx
new file mode 100644
index 0000000..25f156a
--- /dev/null
+++ b/frontend/src/correspondents/CorrespondentsPanel.jsx
@@ -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 (
+
+
+
+
Correspondents
+
{correspondents.length} total
+
+
+
+
+
+
+
+ {correspondents.length === 0 ? (
+
No correspondents created yet.
+ ) : (
+
+ )}
+
+
+ );
+}
+
+export default CorrespondentsPanel;
diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx
new file mode 100644
index 0000000..565c169
--- /dev/null
+++ b/frontend/src/detail/DetailPanel.jsx
@@ -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 {emptyMessage};
+ }
+
+ 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 (
+
+ {preparedItems.map(({ entry, angle }, index) => {
+ const transform = hasMultiple
+ ? `translate(-50%, -50%) rotate(${angle}deg)`
+ : 'translate(-50%, -50%)';
+ const isFront = index === 0;
+ return (
+
+

{
+ 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);
+ }
+ }
+ }}
+ />
+
+ );
+ })}
+
+ );
+};
+
+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 Select a document to view metadata, tags and actions.
;
+ }
+
+ 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 (
+ <>
+
+
+ {isEditingTitle ? (
+
+ ) : (
+ <>
+
{displayName}
+
+ >
+ )}
+
+ {titleError ? {titleError}
: null}
+
+
+ Uploaded:{' '}
+ {singleDoc.uploaded_at ? new Date(singleDoc.uploaded_at).toLocaleString() : '—'}
+
+
+ Size:{' '}
+ {sizeLabel}
+
+
+ Type: {singleDoc.content_type || 'Unknown'}
+
+
+ Issued: {issuedAt}
+
+
+ Original filename:{' '}
+ {singleDoc.original_name}
+
+
+
+
+
Tags
+
+ {tagsForDoc.length ? (
+ tagsForDoc.map((tag) => {
+ const colorSource = tag?.color || tagLookupById.get(tag.id)?.color;
+ const style = getTagColorStyle(colorSource);
+ return (
+
+ {tag.label}{' '}
+
+
+ );
+ })
+ ) : (
+ No tags yet.
+ )}
+
+
+
+
+
Correspondents
+
+ {sortedCorrespondents.length ? (
+ sortedCorrespondents.map((entry) => {
+ const roleLabel = entry.role
+ ? entry.role.charAt(0).toUpperCase() + entry.role.slice(1)
+ : 'Other';
+ return (
+
+
+ {roleLabel}
+ {entry.name}
+
+
+
+ );
+ })
+ ) : (
+ No correspondents yet.
+ )}
+
+
+
+ {metadata && (
+
+
Metadata
+
{JSON.stringify(metadata, null, 2)}
+
+ )}
+ >
+ );
+ };
+
+ const renderBulk = () => {
+ const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
+ const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
+
+ return (
+ <>
+
+ {countLabel}
+
+
+ Total size (stack): {sizeLabel}
+
+
+ Common tags:{' '}
+ {commonTags.length ? commonTags.join(', ') : 'None'}
+
+
+ {commonTags.length > 0 && (
+
+ Bulk tag operations
+
+
+
+ )}
+
+
+
+
+
+
+ >
+ );
+ };
+
+ return (
+
+ );
+};
+
+export default DetailPanel;
diff --git a/frontend/src/documents/DocumentsTable.jsx b/frontend/src/documents/DocumentsTable.jsx
index f778b56..7a2dce2 100644
--- a/frontend/src/documents/DocumentsTable.jsx
+++ b/frontend/src/documents/DocumentsTable.jsx
@@ -302,7 +302,7 @@ const DocumentsTable = ({
}}
>
- |
diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx
index 829dec4..52a5440 100644
--- a/frontend/src/index.jsx
+++ b/frontend/src/index.jsx
@@ -23,8 +23,12 @@ import './styles.css';
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './asset_manager';
import useApiError from './hooks/useApiError';
import SkeuomorphicWorkspace from './skeuomorphic_ws';
-import { DownloadIcon, EditIcon } from './ui/icons';
-import { generateRandomTagColor, getTagColorStyle, HEX_COLOR_PATTERN } from './utils/colors';
+import DetailPanel from './detail/DetailPanel';
+import TagsPanel from './tags/TagsPanel';
+import CorrespondentsPanel from './correspondents/CorrespondentsPanel';
+import { CORRESPONDENT_ROLES } from './constants/correspondents';
+import { DownloadIcon } from './ui/icons';
+import { generateRandomTagColor } from './utils/colors';
import { formatFileSize } from './utils/format';
import Sidebar from './sidebar/Sidebar';
import DocumentsTable, { FilterBar } from './documents/DocumentsTable';
@@ -195,677 +199,6 @@ const LoginView = ({ onSubmit, status }) => (
-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); // 3..15
- const sign = index % 2 === 0 ? 1 : -1;
- return magnitude * sign;
-};
-
-const MAX_PREVIEW_STACK_ITEMS = 15;
-const CORRESPONDENT_ROLES = ['sender', 'receiver', 'other'];
-
-const PreviewStack = ({
- items = [],
- maxItems = MAX_PREVIEW_STACK_ITEMS,
- emptyMessage = 'Preview unavailable',
- onItemActivate,
- onOpenPreview,
- activeItemId = null,
-}) => {
- if (!items.length) {
- return {emptyMessage};
- }
-
- 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),
- offset: 0,
- })),
- [limited],
- );
-
- return (
-
- {preparedItems.map(({ entry, angle, offset }, index) => {
- const transform = hasMultiple
- ? `translate(-50%, -50%) rotate(${angle}deg)`
- : 'translate(-50%, -50%)';
- const isFront = index === 0;
- return (
-
-

{
- 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);
- }
- }
- }}
- />
-
- );
- })}
-
- );
-};
-
-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,
-}) => {
- 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 Select a document to view metadata, tags and actions.
;
- }
-
- 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 (
- <>
-
-
- {isEditingTitle ? (
-
- ) : (
- <>
-
{displayName}
-
- >
- )}
-
- {titleError ? {titleError}
: null}
-
-
- Uploaded:{' '}
- {singleDoc.uploaded_at ? new Date(singleDoc.uploaded_at).toLocaleString() : '—'}
-
-
- Size:{' '}
- {sizeLabel}
-
-
- Type: {singleDoc.content_type || 'Unknown'}
-
-
- Issued: {issuedAt}
-
-
- Original filename:{' '}
- {singleDoc.original_name}
-
-
-
-
-
Tags
-
- {tagsForDoc.length ? (
- tagsForDoc.map((tag) => {
- const colorSource = tag?.color || tagLookupById.get(tag.id)?.color;
- const style = getTagColorStyle(colorSource);
- return (
-
- {tag.label}{' '}
-
-
- );
- })
- ) : (
- No tags yet.
- )}
-
-
-
-
-
Correspondents
-
- {sortedCorrespondents.length ? (
- sortedCorrespondents.map((entry) => {
- const roleLabel = entry.role
- ? entry.role.charAt(0).toUpperCase() + entry.role.slice(1)
- : 'Other';
- return (
-
-
- {roleLabel}
- {entry.name}
-
-
-
- );
- })
- ) : (
- No correspondents yet.
- )}
-
-
-
- {metadata && (
-
-
Metadata
-
{JSON.stringify(metadata, null, 2)}
-
- )}
- >
- );
- };
-
- const renderBulk = () => {
- const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
- const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
-
- return (
- <>
-
- {countLabel}
-
-
- Total size (stack): {sizeLabel}
-
-
- Common tags:{' '}
- {commonTags.length ? commonTags.join(', ') : 'None'}
-
-
- {commonTags.length > 0 && (
-
- Bulk tag operations
-
-
-
- )}
-
-
-
-
-
-
- >
- );
- };
-
- return (
-
- );
-};
-
const PreviewWorkspace = ({
document,
previewEntry,
@@ -951,494 +284,6 @@ const PreviewWorkspace = ({
);
};
-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 (
-
-
-
-
Tags
-
{tags.length} total
-
-
-
-
-
-
- {tags.length === 0 ? (
-
No tags created yet.
- ) : (
-
- )}
-
-
- );
-}
-
-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 (
-
-
-
-
Correspondents
-
{correspondents.length} total
-
-
-
-
-
-
-
- {correspondents.length === 0 ? (
-
No correspondents created yet.
- ) : (
-
- )}
-
-
- );
-}
-
const MainLayout = ({ sidebarProps, children, className = 'app-main' }) => (
@@ -5011,6 +3856,7 @@ const AppLayout = () => {
correspondents,
onCorrespondentAdd: handleCorrespondentAdd,
onCorrespondentRemove: handleCorrespondentRemove,
+ resolveApiPath,
};
const skeuoWorkspaceProps = useMemo(
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 4351601..955bca3 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -952,14 +952,16 @@ button.icon-button.ghost:hover:not([disabled]) {
box-shadow: 0 1px 3px var(--shadow-medium);
}
-.thumb-placeholder.icon {
- font-size: 1.8rem;
+.thumb-icon {
+ width: 100%;
+ height: 100%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
color: var(--accent);
- width: 36px;
- height: 48px;
}
-.thumb-placeholder.icon svg {
+.thumb-icon svg {
width: 32px;
height: 32px;
}
diff --git a/frontend/src/tags/TagsPanel.jsx b/frontend/src/tags/TagsPanel.jsx
new file mode 100644
index 0000000..0c2c70e
--- /dev/null
+++ b/frontend/src/tags/TagsPanel.jsx
@@ -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 (
+
+
+
+
Tags
+
{tags.length} total
+
+
+
+
+
+
+ {tags.length === 0 ? (
+
No tags created yet.
+ ) : (
+
+ )}
+
+
+ );
+}
+
+export default TagsPanel;