correspondents
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 14m8s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 14m7s

This commit is contained in:
2025-10-15 00:20:54 +02:00
parent 3db221cce4
commit 5c8cd98aac
4 changed files with 742 additions and 7 deletions
+630 -6
View File
@@ -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>
+33 -1
View File
@@ -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>
);
};
+68
View File
@@ -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;
+11
View File
@@ -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,
};