correspondents
This commit is contained in:
+630
-6
@@ -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 <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
||||
@@ -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 = ({
|
||||
</datalist>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Correspondents</dt>
|
||||
<div className="correspondent-list">
|
||||
{sortedCorrespondents.length ? (
|
||||
sortedCorrespondents.map((entry) => {
|
||||
const roleLabel = entry.role
|
||||
? entry.role.charAt(0).toUpperCase() + entry.role.slice(1)
|
||||
: 'Other';
|
||||
return (
|
||||
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill">
|
||||
<span className="correspondent-pill__label">
|
||||
<strong>{roleLabel}</strong>
|
||||
<span>{entry.name}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="correspondent-pill__remove"
|
||||
onClick={() =>
|
||||
onCorrespondentRemove?.({
|
||||
documentId: singleDoc.id,
|
||||
correspondentId: entry.id,
|
||||
role: entry.role,
|
||||
})
|
||||
}
|
||||
aria-label={`Remove ${entry.name} as ${roleLabel}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="meta">No correspondents yet.</span>
|
||||
)}
|
||||
</div>
|
||||
<form
|
||||
className="correspondent-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const nameInput = form.elements.correspondent;
|
||||
const roleSelect = form.elements.role;
|
||||
const value = nameInput.value.trim();
|
||||
const role = roleSelect.value;
|
||||
if (!value) return;
|
||||
onCorrespondentAdd?.({
|
||||
document: singleDoc,
|
||||
name: value,
|
||||
role,
|
||||
input: nameInput,
|
||||
});
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
name="correspondent"
|
||||
placeholder="Add or create correspondent"
|
||||
list="correspondent-catalog"
|
||||
/>
|
||||
<select name="role" defaultValue={CORRESPONDENT_ROLES[0]}>
|
||||
{CORRESPONDENT_ROLES.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit">Add</button>
|
||||
<datalist id="correspondent-catalog">
|
||||
{correspondentOptions.map((name) => (
|
||||
<option key={name} value={name} />
|
||||
))}
|
||||
</datalist>
|
||||
</form>
|
||||
</div>
|
||||
{metadata && (
|
||||
<div>
|
||||
<dt>Metadata</dt>
|
||||
@@ -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 }) => {
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function CorrespondentsPanel({
|
||||
correspondents = [],
|
||||
onRefresh,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onNotify,
|
||||
}) {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [draftName, setDraftName] = useState('');
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState(null);
|
||||
|
||||
const startEdit = useCallback((correspondent) => {
|
||||
setEditingId(correspondent.id);
|
||||
setDraftName(correspondent.name || '');
|
||||
}, []);
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setDraftName('');
|
||||
setSaving(false);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!editingId) return;
|
||||
const trimmed = draftName.trim();
|
||||
if (!trimmed) {
|
||||
onNotify?.('Correspondent name cannot be empty.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onUpdate(editingId, { name: trimmed });
|
||||
cancelEdit();
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
}, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (correspondent) => {
|
||||
if (!correspondent?.id) return;
|
||||
setDeletingId(correspondent.id);
|
||||
try {
|
||||
await onDelete(correspondent.id);
|
||||
if (editingId === correspondent.id) {
|
||||
cancelEdit();
|
||||
}
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {
|
||||
return;
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
},
|
||||
[onDelete, editingId, cancelEdit],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
const trimmed = createName.trim();
|
||||
if (!trimmed) {
|
||||
onNotify?.('Correspondent name cannot be empty.', 'error');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
await onCreate({ name: trimmed });
|
||||
setCreateName('');
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {
|
||||
return;
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
},
|
||||
[createName, onCreate, onNotify],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleSave();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
},
|
||||
[handleSave, cancelEdit],
|
||||
);
|
||||
|
||||
const renderUsage = useCallback((usage) => {
|
||||
if (!usage) {
|
||||
return '0';
|
||||
}
|
||||
const total = typeof usage.total === 'number' ? usage.total : 0;
|
||||
const entries = usage.by_role ? Object.entries(usage.by_role) : [];
|
||||
if (!entries.length) {
|
||||
return total.toString();
|
||||
}
|
||||
const roleSummary = entries
|
||||
.map(([role, count]) => `${role}: ${count}`)
|
||||
.join(', ');
|
||||
return `${total} (${roleSummary})`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="correspondents-panel column">
|
||||
<div className="column-header">
|
||||
<div className="column-header__titles">
|
||||
<h2>Correspondents</h2>
|
||||
<div className="column-subtitle">{correspondents.length} total</div>
|
||||
</div>
|
||||
<div className="header-actions correspondents-actions">
|
||||
<form className="correspondents-actions__form" onSubmit={handleCreate}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New correspondent name"
|
||||
value={createName}
|
||||
onChange={(event) => setCreateName(event.target.value)}
|
||||
disabled={creating}
|
||||
/>
|
||||
<button type="submit" disabled={creating || !createName.trim()}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={saving || creating || Boolean(deletingId)}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="column-body tags-panel__body">
|
||||
{correspondents.length === 0 ? (
|
||||
<div className="empty-state">No correspondents created yet.</div>
|
||||
) : (
|
||||
<div className="tags-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col" className="numeric">
|
||||
Usage
|
||||
</th>
|
||||
<th scope="col" className="actions">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{correspondents.map((correspondent) => {
|
||||
const isEditing = editingId === correspondent.id;
|
||||
return (
|
||||
<tr key={correspondent.id} className={isEditing ? 'editing' : ''}>
|
||||
<td className="tags-table__label">
|
||||
{isEditing ? (
|
||||
<input
|
||||
className="tags-table__label-input"
|
||||
value={draftName}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={saving}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span>{correspondent.name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="numeric">{renderUsage(correspondent.usage)}</td>
|
||||
<td className="actions">
|
||||
{isEditing ? (
|
||||
<div className="tags-table__edit-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={cancelEdit}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tags-table__row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => startEdit(correspondent)}
|
||||
disabled={deletingId === correspondent.id}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => handleDelete(correspondent)}
|
||||
disabled={deletingId === correspondent.id}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const MainLayout = ({ sidebarProps, children, className = 'app-main' }) => (
|
||||
<main className={className}>
|
||||
@@ -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 = () => {
|
||||
/>
|
||||
</MainLayout>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function CorrespondentsRoute() {
|
||||
const {
|
||||
sidebarProps,
|
||||
correspondents,
|
||||
refreshCorrespondents,
|
||||
handleCorrespondentCreate,
|
||||
handleCorrespondentUpdate,
|
||||
handleCorrespondentDelete,
|
||||
setStatusMessage,
|
||||
} = useAppShell();
|
||||
|
||||
return (
|
||||
<MainLayout sidebarProps={sidebarProps} className="tags-main">
|
||||
<CorrespondentsPanel
|
||||
correspondents={correspondents}
|
||||
onRefresh={refreshCorrespondents}
|
||||
onCreate={handleCorrespondentCreate}
|
||||
onUpdate={handleCorrespondentUpdate}
|
||||
onDelete={handleCorrespondentDelete}
|
||||
onNotify={setStatusMessage}
|
||||
/>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const AppRouter = () => (
|
||||
<Routes>
|
||||
@@ -4692,6 +5315,7 @@ const AppRouter = () => (
|
||||
element={<DocumentsRoute />}
|
||||
/>
|
||||
<Route path="/tags" element={<TagsRoute />} />
|
||||
<Route path="/correspondents" element={<CorrespondentsRoute />} />
|
||||
<Route path="*" element={<Navigate to="/folders" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -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 = ({
|
||||
<span>All tags</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section__header">
|
||||
<h3>Correspondents</h3>
|
||||
<span className="meta">{correspondents.length}</span>
|
||||
</div>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`sidebar-item${isCorrespondentsRoute ? ' active' : ''}`}
|
||||
onClick={handleShowCorrespondents}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleShowCorrespondents();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CorrespondentIcon className="sidebar-item__icon" />
|
||||
<span>All correspondents</span>
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) => (
|
||||
<IconUserFilled
|
||||
className={composeClassName('icon icon--fill', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<TablerDownload
|
||||
className={composeClassName('icon', className)}
|
||||
@@ -69,5 +79,6 @@ export default {
|
||||
EditIcon,
|
||||
FolderIcon,
|
||||
TagIcon,
|
||||
CorrespondentIcon,
|
||||
DownloadIcon,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user