From 5c8cd98aacc528b8e02861609c78fd204ef878b5 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 15 Oct 2025 00:20:54 +0200 Subject: [PATCH] correspondents --- frontend/src/index.jsx | 636 ++++++++++++++++++++++++++++++- frontend/src/sidebar/Sidebar.jsx | 34 +- frontend/src/styles.css | 68 ++++ frontend/src/ui/icons.js | 11 + 4 files changed, 742 insertions(+), 7 deletions(-) diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 2ffcce5..8a5049b 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -208,6 +208,7 @@ const computeStackAngle = (docId, index) => { }; const MAX_PREVIEW_STACK_ITEMS = 15; +const CORRESPONDENT_ROLES = ['sender', 'receiver', 'other']; const PreviewStack = ({ items = [], @@ -305,6 +306,9 @@ const DetailPanel = ({ onUpdateTitle = async () => false, ensureAssetUrl = null, getDocumentAsset = () => null, + correspondents = [], + onCorrespondentAdd, + onCorrespondentRemove, }) => { const selectedCount = selectedDocuments.length; const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null; @@ -471,6 +475,26 @@ const DetailPanel = ({ }, 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.

; @@ -489,6 +513,20 @@ const DetailPanel = ({ 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 ( <> @@ -642,6 +680,80 @@ const DetailPanel = ({ +
+
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
@@ -842,7 +954,7 @@ const PreviewWorkspace = ({ ); }; -const TagsPanel = ({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) => { +function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) { const [editingId, setEditingId] = useState(null); const [draftLabel, setDraftLabel] = useState(''); const [draftColor, setDraftColor] = useState(''); @@ -1091,7 +1203,244 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) => {
); -}; +} + +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' }) => (
@@ -1175,6 +1524,7 @@ const AppLayout = () => { }, [appDispatch]); const [searchResults, setSearchResults] = useState(null); const [tags, setTags] = useState([]); + const [correspondents, setCorrespondents] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [activeTagFilters, setActiveTagFilters] = useState([]); const [draggedDocumentIds, setDraggedDocumentIds] = useState([]); @@ -1282,6 +1632,7 @@ const AppLayout = () => { setDraggedFolderId(null); setSearchResults(null); setTags([]); + setCorrespondents([]); setSearchQuery(''); setActiveTagFilters([]); setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME }); @@ -1318,6 +1669,16 @@ const AppLayout = () => { return map; }, [tags]); + const correspondentLookupByName = useMemo(() => { + const map = new Map(); + correspondents.forEach((correspondent) => { + if (correspondent?.name) { + map.set(correspondent.name.toLowerCase(), correspondent); + } + }); + return map; + }, [correspondents]); + const updateSelectionOrder = useCallback((nextSelection, interactedIds = []) => { const nextSet = new Set(nextSelection); const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id)); @@ -2010,6 +2371,15 @@ const AppLayout = () => { } }, [api, notifyApiError]); + const refreshCorrespondents = useCallback(async () => { + try { + const { data } = await api.get('/correspondents'); + setCorrespondents(data || []); + } catch (error) { + notifyApiError(error, 'Unable to load correspondents.'); + } + }, [api, notifyApiError]); + const handleTagUpdate = useCallback( async (tagId, changes) => { if (!tagId) { @@ -2062,6 +2432,213 @@ const AppLayout = () => { [api, refreshTags, notifyApiError], ); + const handleCorrespondentUpdate = useCallback( + async (correspondentId, changes) => { + if (!correspondentId) { + throw new Error('Missing correspondent identifier.'); + } + + const payload = {}; + if (typeof changes.name === 'string') { + const trimmed = changes.name.trim(); + if (!trimmed) { + throw new Error('Correspondent name cannot be empty.'); + } + payload.name = trimmed; + } + + if (Object.keys(payload).length === 0) { + return false; + } + + try { + await api.patch(`/correspondents/${correspondentId}`, payload); + await refreshCorrespondents(); + setStatusMessage('Correspondent updated.', 'success'); + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to update correspondent.'; + notifyApiError(error, message); + throw new Error(message); + } + }, + [api, refreshCorrespondents, notifyApiError, setStatusMessage], + ); + + const handleCorrespondentCreate = useCallback( + async ({ name }) => { + const trimmed = (name || '').trim(); + if (!trimmed) { + throw new Error('Correspondent name is required.'); + } + try { + const { data } = await api.post('/correspondents', { name: trimmed }); + await refreshCorrespondents(); + setStatusMessage('Correspondent created.', 'success'); + return data; + } catch (error) { + const message = error.response?.data?.error || 'Failed to create correspondent.'; + notifyApiError(error, message); + throw new Error(message); + } + }, + [api, refreshCorrespondents, notifyApiError, setStatusMessage], + ); + + const handleCorrespondentDelete = useCallback( + async (correspondentId) => { + if (!correspondentId) { + throw new Error('Missing correspondent identifier.'); + } + + const stripFromDoc = (doc) => { + if (!doc || !Array.isArray(doc.correspondents)) { + return doc; + } + const next = doc.correspondents.filter((entry) => entry.id !== correspondentId); + if (next.length === doc.correspondents.length) { + return doc; + } + return { ...doc, correspondents: next }; + }; + + try { + await api.delete(`/correspondents/${correspondentId}`); + await refreshCorrespondents(); + + setDocuments((prev) => prev.map((doc) => stripFromDoc(doc))); + setSearchResults((prev) => + Array.isArray(prev) ? prev.map((doc) => stripFromDoc(doc)) : prev, + ); + setFolderContents((prev) => { + if (!prev.size) { + return prev; + } + let changed = false; + const next = new Map(); + prev.forEach((contents, key) => { + if (!Array.isArray(contents?.documents)) { + next.set(key, contents); + return; + } + const updatedDocs = contents.documents.map((doc) => stripFromDoc(doc)); + const mutated = updatedDocs.some((doc, index) => doc !== contents.documents[index]); + if (mutated) { + changed = true; + next.set(key, { ...contents, documents: updatedDocs }); + } else { + next.set(key, contents); + } + }); + return changed ? next : prev; + }); + + setStatusMessage('Correspondent deleted.', 'success'); + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to delete correspondent.'; + notifyApiError(error, message); + throw new Error(message); + } + }, + [ + api, + refreshCorrespondents, + notifyApiError, + setStatusMessage, + setDocuments, + setSearchResults, + setFolderContents, + ], + ); + + async function handleDocumentCorrespondentAttach({ documentId, correspondentId, role }) { + if (!documentId || !correspondentId || !role) { + throw new Error('Missing document, correspondent or role.'); + } + try { + await api.post(`/documents/${documentId}/correspondents`, { + assignments: [{ correspondent_id: correspondentId, role }], + replace: false, + }); + await refreshCurrentFolder(); + setStatusMessage('Correspondent assigned.', 'success'); + } catch (error) { + const message = error.response?.data?.error || 'Failed to assign correspondent.'; + notifyApiError(error, message); + throw new Error(message); + } + } + + async function handleCorrespondentRemove({ documentId, correspondentId, role }) { + if (!documentId || !correspondentId || !role) { + throw new Error('Missing document, correspondent or role.'); + } + try { + await api.delete(`/documents/${documentId}/correspondents/${correspondentId}`, { + params: { role }, + }); + await refreshCurrentFolder(); + setStatusMessage('Correspondent removed.', 'success'); + } catch (error) { + const message = error.response?.data?.error || 'Failed to remove correspondent.'; + notifyApiError(error, message); + throw new Error(message); + } + } + + const handleCorrespondentAdd = useCallback( + async ({ document, name, role, input }) => { + if (!document?.id) { + throw new Error('Missing document for correspondent assignment.'); + } + const trimmed = (name || '').trim(); + if (!trimmed) { + setStatusMessage('Correspondent name is required.', 'error'); + return; + } + const normalizedRole = (role || '').trim().toLowerCase(); + if (!CORRESPONDENT_ROLES.includes(normalizedRole)) { + setStatusMessage('Select a valid correspondent role.', 'error'); + return; + } + + let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null; + if (!target) { + try { + target = await handleCorrespondentCreate({ name: trimmed }); + } catch (error) { + return; + } + } + + if (!target?.id) { + setStatusMessage('Unable to resolve correspondent.', 'error'); + return; + } + + try { + await handleDocumentCorrespondentAttach({ + documentId: document.id, + correspondentId: target.id, + role: normalizedRole, + }); + if (input) { + input.value = ''; + } + } catch ( + // eslint-disable-next-line no-empty + error + ) {} + }, + [ + handleCorrespondentCreate, + handleDocumentCorrespondentAttach, + correspondentLookupByName, + setStatusMessage, + ], + ); + const handleTagDelete = useCallback( async (tagId) => { if (!tagId) { @@ -2197,7 +2774,7 @@ const AppLayout = () => { const initializeAfterLogin = useCallback(async () => { setLoading(true); try { - await refreshTags(); + await Promise.all([refreshTags(), refreshCorrespondents()]); const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root'; await loadFolder(initialFolder, { showLoading: false }); } catch (error) { @@ -2206,7 +2783,7 @@ const AppLayout = () => { } finally { setLoading(false); } - }, [refreshTags, routeFolderId, loadFolder, notifyApiError]); + }, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder, notifyApiError]); useEffect(() => { if (!token) { @@ -4265,6 +4842,8 @@ const AppLayout = () => { draggedFolderId, onShowTags: () => navigate('/tags'), tags, + onShowCorrespondents: () => navigate('/correspondents'), + correspondents, }; const resolveThumbnailUrlForDoc = useCallback( @@ -4341,6 +4920,9 @@ const AppLayout = () => { onUpdateTitle: handleDocumentTitleUpdate, ensureAssetUrl, getDocumentAsset, + correspondents, + onCorrespondentAdd: handleCorrespondentAdd, + onCorrespondentRemove: handleCorrespondentRemove, }; const skeuoWorkspaceProps = useMemo( @@ -4394,6 +4976,14 @@ const AppLayout = () => { handleTagCreate, handleTagDelete, handleDocumentTagAttach, + correspondents, + refreshCorrespondents, + handleCorrespondentUpdate, + handleCorrespondentCreate, + handleCorrespondentDelete, + handleDocumentCorrespondentAttach, + handleCorrespondentRemove, + handleCorrespondentAdd, previewActive, previewWorkspaceDocument, previewWorkspaceEntry, @@ -4421,6 +5011,14 @@ const AppLayout = () => { handleTagCreate, handleTagDelete, handleDocumentTagAttach, + correspondents, + refreshCorrespondents, + handleCorrespondentUpdate, + handleCorrespondentCreate, + handleCorrespondentDelete, + handleDocumentCorrespondentAttach, + handleCorrespondentRemove, + handleCorrespondentAdd, previewActive, previewWorkspaceDocument, previewWorkspaceEntry, @@ -4657,7 +5255,7 @@ const LoginRoute = () => { ); }; -const TagsRoute = () => { +function TagsRoute() { const { sidebarProps, tags, @@ -4677,7 +5275,32 @@ const TagsRoute = () => { /> ); -}; +} + +function CorrespondentsRoute() { + const { + sidebarProps, + correspondents, + refreshCorrespondents, + handleCorrespondentCreate, + handleCorrespondentUpdate, + handleCorrespondentDelete, + setStatusMessage, + } = useAppShell(); + + return ( + + + + ); +} const AppRouter = () => ( @@ -4692,6 +5315,7 @@ const AppRouter = () => ( element={} /> } /> + } /> } /> diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx index af7e85f..aa7c81c 100644 --- a/frontend/src/sidebar/Sidebar.jsx +++ b/frontend/src/sidebar/Sidebar.jsx @@ -1,6 +1,12 @@ import React, { useCallback } from 'react'; import { useMatch } from 'react-router-dom'; -import { ChevronIcon, FolderIcon, TagIcon, TrashIcon } from '../ui/icons'; +import { + ChevronIcon, + FolderIcon, + TagIcon, + TrashIcon, + CorrespondentIcon, +} from '../ui/icons'; const FolderNode = ({ node, @@ -106,10 +112,15 @@ const Sidebar = ({ draggedFolderId, onShowTags, tags = [], + onShowCorrespondents, + correspondents = [], }) => { const handleShowTags = onShowTags || (() => {}); const tagsRouteMatch = useMatch('/tags'); const isTagsRoute = Boolean(tagsRouteMatch); + const handleShowCorrespondents = onShowCorrespondents || (() => {}); + const correspondentsRouteMatch = useMatch('/correspondents'); + const isCorrespondentsRoute = Boolean(correspondentsRouteMatch); const renderNodes = useCallback( (ids, depth) => @@ -183,6 +194,27 @@ const Sidebar = ({ All tags +
+
+

Correspondents

+ {correspondents.length} +
+ { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleShowCorrespondents(); + } + }} + > + + All correspondents + +
); }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index fbcb57a..be59efb 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -430,6 +430,74 @@ button.icon-button.ghost:hover:not([disabled]) { gap: 0.4rem; } +.correspondents-panel .header-actions { + gap: 0.5rem; +} + +.correspondents-actions__form { + display: flex; + gap: 0.4rem; +} + +.correspondents-actions__form input { + min-width: 14rem; +} + +.correspondent-list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-bottom: 0.5rem; +} + +.correspondent-pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: var(--surface-subtle); + border: 1px solid var(--border-subtle); + border-radius: 999px; + padding: 0.15rem 0.5rem; + font-size: 0.9rem; +} + +.correspondent-pill__label { + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.correspondent-pill__label strong { + font-size: 0.8rem; + text-transform: capitalize; + color: var(--muted); +} + +.correspondent-pill__remove { + border: none; + background: none; + cursor: pointer; + font-size: 1rem; + line-height: 1; + color: var(--muted); + padding: 0; +} + +.correspondent-pill__remove:hover { + color: var(--danger); +} + +.correspondent-form { + display: flex; + gap: 0.4rem; + align-items: center; +} + +.correspondent-form input, +.correspondent-form select { + min-height: 32px; +} + .preview-workspace__message { color: var(--muted); font-size: 0.95rem; diff --git a/frontend/src/ui/icons.js b/frontend/src/ui/icons.js index d6c7044..568636f 100644 --- a/frontend/src/ui/icons.js +++ b/frontend/src/ui/icons.js @@ -4,6 +4,7 @@ import { IconFolderFilled, IconPencil, IconTagFilled, + IconUserFilled, IconTrash, } from '@tabler/icons-react'; @@ -54,6 +55,15 @@ export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => ( /> ); +export const CorrespondentIcon = ({ className, size = '1em', stroke = 0, ...rest }) => ( + +); + export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (