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
+
+
+
+ setCreateName(event.target.value)} + disabled={creating} + /> + +
+ +
+
+
+ {correspondents.length === 0 ? ( +
No correspondents created yet.
+ ) : ( +
+ + + + + + + + + + {correspondents.map((correspondent) => { + const isEditing = editingId === correspondent.id; + return ( + + + + + + ); + })} + +
Name + Usage + + Actions +
+ {isEditing ? ( + setDraftName(event.target.value)} + onKeyDown={handleKeyDown} + disabled={saving} + autoFocus + /> + ) : ( + {correspondent.name} + )} + {renderUsage(correspondent.usage)} + {isEditing ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ )} +
+
+ ); +} + +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 ( +
+ {entry.alt { + 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 ? ( +
+ { + setTitleDraft(event.target.value); + if (titleError) { + setTitleError(null); + } + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + cancelTitleEdit(); + } + }} + aria-label="Document title" + autoFocus + /> + + +
+ ) : ( + <> +

{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} +
+
+
+ { + if (!downloadHref) { + event.preventDefault(); + } + }} + > + + Download + + + +
+
+
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. + )} +
+
{ + event.preventDefault(); + const input = event.currentTarget.elements.tag; + const value = input.value.trim(); + if (!value) return; + onTagAdd(singleDoc, value, input); + }} + > + + + + {tags.map((tag) => ( + +
+
+
+
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. + )} +
+
{ + 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(); + }} + > + + + + + {correspondentOptions.map((name) => ( + +
+
+ {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 +
{ + event.preventDefault(); + const input = event.currentTarget.elements.tag; + const value = input.value.trim(); + if (!value) return; + onBulkTagAdd?.({ label: value, input }); + }} + > + + +
+
{ + event.preventDefault(); + const input = event.currentTarget.elements.tag; + const value = input.value.trim(); + if (!value) return; + onBulkTagRemove?.({ label: value, input }); + }} + > + + +
+
+ )} + + {tags.map((tag) => ( + +
+ + +
+ + + ); + }; + + 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 ( -
- {entry.alt { - 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 ? ( -
- { - setTitleDraft(event.target.value); - if (titleError) { - setTitleError(null); - } - }} - onKeyDown={(event) => { - if (event.key === 'Escape') { - event.preventDefault(); - cancelTitleEdit(); - } - }} - aria-label="Document title" - autoFocus - /> - - -
- ) : ( - <> -

{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} -
-
-
- { - if (!downloadHref) { - event.preventDefault(); - } - }} - > - - Download - - - -
-
-
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. - )} -
-
{ - event.preventDefault(); - const input = event.currentTarget.elements.tag; - const value = input.value.trim(); - if (!value) return; - onTagAdd(singleDoc, value, input); - }} - > - - - - {tags.map((tag) => ( - -
-
-
-
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. - )} -
-
{ - 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(); - }} - > - - - - - {correspondentOptions.map((name) => ( - -
-
- {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 -
{ - event.preventDefault(); - const input = event.currentTarget.elements.tag; - const value = input.value.trim(); - if (!value) return; - onBulkTagAdd?.({ label: value, input }); - }} - > - - -
-
{ - event.preventDefault(); - const input = event.currentTarget.elements.tag; - const value = input.value.trim(); - if (!value) return; - onBulkTagRemove?.({ label: value, input }); - }} - > - - -
-
- )} - - {tags.map((tag) => ( - -
- - -
- - - ); - }; - - 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.
- ) : ( -
- - - - - - - - - - - {tags.map((tag) => { - const isEditing = editingId === tag.id; - return ( - - - - - - - ); - })} - -
TagColor - Documents - - Actions -
- {isEditing ? ( - setDraftLabel(event.target.value)} - onKeyDown={handleKeyDown} - disabled={saving || deletingId === tag.id} - autoFocus - /> - ) : ( - - {tag.label} - - )} - - {isEditing ? ( -
- setDraftColor(event.target.value)} - disabled={saving || deletingId === tag.id} - aria-label="Pick tag color" - /> - {draftColor && ( - - )} -
- ) : tag.color ? ( - - ) : ( - - )} -
{tag.usage_count ?? 0} - {isEditing ? ( -
- - - -
- ) : ( -
- - -
- )} -
-
- )} -
-
- ); -} - -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
-
-
-
- setCreateName(event.target.value)} - disabled={creating} - /> - -
- -
-
-
- {correspondents.length === 0 ? ( -
No correspondents created yet.
- ) : ( -
- - - - - - - - - - {correspondents.map((correspondent) => { - const isEditing = editingId === correspondent.id; - return ( - - - - - - ); - })} - -
Name - Usage - - Actions -
- {isEditing ? ( - setDraftName(event.target.value)} - onKeyDown={handleKeyDown} - disabled={saving} - autoFocus - /> - ) : ( - {correspondent.name} - )} - {renderUsage(correspondent.usage)} - {isEditing ? ( -
- - -
- ) : ( -
- - -
- )} -
-
- )} -
-
- ); -} - 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.
+ ) : ( +
+ + + + + + + + + + + {tags.map((tag) => { + const isEditing = editingId === tag.id; + return ( + + + + + + + ); + })} + +
TagColor + Documents + + Actions +
+ {isEditing ? ( + setDraftLabel(event.target.value)} + onKeyDown={handleKeyDown} + disabled={saving || deletingId === tag.id} + autoFocus + /> + ) : ( + + {tag.label} + + )} + + {isEditing ? ( +
+ setDraftColor(event.target.value)} + disabled={saving || deletingId === tag.id} + aria-label="Pick tag color" + /> + {draftColor && ( + + )} +
+ ) : tag.color ? ( + + ) : ( + + )} +
{tag.usage_count ?? 0} + {isEditing ? ( +
+ + + +
+ ) : ( +
+ + +
+ )} +
+
+ )} +
+
+ ); +} + +export default TagsPanel;