upload queue

This commit is contained in:
2025-11-10 02:05:17 +01:00
parent ac24297a7b
commit b4a1821a55
7 changed files with 597 additions and 37 deletions
+5
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { Navigate, Outlet } from 'react-router-dom'; import { Navigate, Outlet } from 'react-router-dom';
import { AppShellContext } from '../appShellContext'; import { AppShellContext } from '../appShellContext';
import DropOverlay from './DropOverlay'; import DropOverlay from './DropOverlay';
import UploadQueueOverlay from './UploadQueueOverlay';
import useDocumentsWorkspace from '../hooks/documents/useDocumentsWorkspace'; import useDocumentsWorkspace from '../hooks/documents/useDocumentsWorkspace';
import { useDocumentsPreferences } from './useDocumentsPreferences'; import { useDocumentsPreferences } from './useDocumentsPreferences';
@@ -54,6 +55,10 @@ const AppLayout = () => {
active={dropOverlayState.active} active={dropOverlayState.active}
folderName={dropOverlayState.folderName} folderName={dropOverlayState.folderName}
/> />
<UploadQueueOverlay
queue={contextValue.uploadQueue || []}
onClearQueue={contextValue.clearUploadQueue}
/>
<Outlet /> <Outlet />
{managementModals} {managementModals}
</div> </div>
+205
View File
@@ -0,0 +1,205 @@
import React, { useMemo, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
CloseIcon,
LoaderIcon,
CheckIcon,
InfoIcon,
WarningIcon,
BottombarCollapseIcon,
BottombarExpandIcon,
ClearAllIcon,
} from '../ui/icons';
import PanelHeader from '../ui/PanelHeader';
const STATUS_META = {
pending: {
label: 'Queued',
tone: 'muted',
icon: <LoaderIcon className="icon icon--spin" size={16} />,
},
uploading: {
label: 'Uploading',
tone: 'accent',
icon: <LoaderIcon className="icon icon--spin" size={16} />,
},
success: {
label: 'Uploaded',
tone: 'success',
icon: <CheckIcon size={16} />,
},
duplicate: {
label: 'Duplicate',
tone: 'info',
icon: <InfoIcon size={16} />,
},
error: {
label: 'Failed',
tone: 'danger',
icon: <WarningIcon size={16} />,
},
};
const UploadQueueOverlay = ({ queue = [], onClearQueue }) => {
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
if (queue.length > 0) {
setDismissed(false);
}
}, [queue.length]);
const summary = useMemo(() => {
if (!queue.length) {
return 'No uploads';
}
const uploadingCount = queue.filter((item) => item.status === 'uploading').length;
const pendingCount = queue.filter((item) => item.status === 'pending').length;
const errorCount = queue.filter((item) => item.status === 'error').length;
if (uploadingCount > 0 || pendingCount > 0) {
return `${uploadingCount} uploading · ${pendingCount} queued`;
}
if (errorCount > 0) {
return `${errorCount} failed · ${queue.length} total`;
}
return `${queue.length} completed`;
}, [queue]);
const hasActiveUploads = queue.some((item) => item.status === 'uploading' || item.status === 'pending');
const handleClearQueue = () => {
if (!onClearQueue || hasActiveUploads) {
return;
}
onClearQueue();
};
if (!queue.length || dismissed) {
return null;
}
return (
<div className={`upload-queue-overlay${collapsed ? ' upload-queue-overlay--collapsed' : ''}`}>
<PanelHeader
className="upload-queue-overlay__header"
title={(
<span className="upload-queue-overlay__title">
<span>Uploads</span>
<span className="upload-queue-overlay__summary">{summary}</span>
</span>
)}
actions={(
<div className="upload-queue-overlay__controls">
<button
type="button"
className="icon-button ghost"
onClick={() => setCollapsed((value) => !value)}
aria-label={collapsed ? 'Expand upload queue' : 'Collapse upload queue'}
>
{collapsed ? <BottombarExpandIcon size={16} /> : <BottombarCollapseIcon size={16} />}
</button>
<button
type="button"
className="icon-button ghost"
onClick={handleClearQueue}
disabled={hasActiveUploads || !queue.length}
aria-label="Clear completed uploads"
>
<ClearAllIcon size={16} />
</button>
<button
type="button"
className="icon-button"
onClick={() => setDismissed(true)}
aria-label="Hide upload queue"
>
<CloseIcon size={16} />
</button>
</div>
)}
/>
{!collapsed ? (
<ul className="upload-queue-overlay__list">
{[...queue]
.slice()
.reverse()
.map((item) => {
const meta = STATUS_META[item.status] || STATUS_META.pending;
const fileLabel = item.name;
const duplicateLabel = item.status === 'duplicate' ? item.document?.title || null : null;
const documentId = item.document?.id || item.conflictDocumentId || null;
const hasLink = Boolean(documentId);
const handleNavigate = () => {
if (!documentId) {
return;
}
navigate(`/documents/${documentId}`);
};
return (
<li key={item.id} className={`upload-queue-overlay__item upload-queue-overlay__item--${item.status}`}>
<span className={`upload-queue-overlay__status upload-queue-overlay__status--${meta.tone}`}>
{meta.icon}
</span>
<div className="upload-queue-overlay__details">
{item.status === 'success' && hasLink ? (
<button
type="button"
className="upload-queue-overlay__name-link"
onClick={handleNavigate}
>
{fileLabel}
</button>
) : (
<div className="upload-queue-overlay__name">
{fileLabel}
</div>
)}
<div className="upload-queue-overlay__meta-line">
{item.status === 'duplicate' && duplicateLabel ? (
<span className="upload-queue-overlay__meta-duplicate">
Duplicate of{' '}
<button
type="button"
className="upload-queue-overlay__meta-link"
onClick={handleNavigate}
>
{duplicateLabel}
</button>
</span>
) : item.status === 'error' && item.error ? (
<span className="upload-queue-overlay__meta-error" title={item.error}>
{item.error}
</span>
) : (
<>
<span>{meta.label}</span>
{documentId ? (
<span className="upload-queue-overlay__meta-id">
(
<button
type="button"
className="upload-queue-overlay__meta-link"
onClick={handleNavigate}
disabled={!hasLink}
>
{documentId}
</button>
)
</span>
) : null}
</>
)}
</div>
</div>
</li>
);
})}
</ul>
) : null}
</div>
);
};
export default UploadQueueOverlay;
@@ -29,8 +29,6 @@ const useDocumentUploads = ({
currentFolderName, currentFolderName,
ensureFolderData, ensureFolderData,
refreshCurrentFolder, refreshCurrentFolder,
notifyApiError,
setStatusMessage,
setLoading, setLoading,
shellRef, shellRef,
}) => { }) => {
@@ -40,12 +38,13 @@ const useDocumentUploads = ({
}); });
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
const folderPathCacheRef = useRef(new Map()); const folderPathCacheRef = useRef(new Map());
const queueIdRef = useRef(0);
const [uploadQueue, setUploadQueue] = useState([]);
const uploadFile = useCallback( const uploadFile = useCallback(
async (file, targetFolderId) => { async (file, targetFolderId) => {
if (!file || file.size === 0) { if (!file || file.size === 0) {
setStatusMessage('Skipped empty file.', 'error'); return { document: null, duplicate: false, statusCode: null, conflictDocumentId: null };
return null;
} }
const formData = new FormData(); const formData = new FormData();
@@ -54,29 +53,74 @@ const useDocumentUploads = ({
formData.append('folder_id', targetFolderId); formData.append('folder_id', targetFolderId);
} }
try { try {
const { data, status } = await apiClient.post('/documents', formData); const { data, status } = await apiClient.post('/documents', formData);
const duplicate = data?.reused || status === 200; const duplicate = data?.reused || status === 200;
setStatusMessage( const document = data?.document ?? data ?? null;
duplicate return {
? `${file.name} already exists; reused existing document.` document,
: `Uploaded ${file.name}`, duplicate,
duplicate ? 'info' : 'success', statusCode: status ?? (duplicate ? 200 : 201),
); conflictDocumentId: null,
return data; };
} catch (error) { } catch (error) {
if (error.response?.status === 409) { if (error.response?.status === 409) {
setStatusMessage(`${file.name} already exists on the server.`, 'info'); const conflictId = error.response?.data?.details?.conflict_document_id ?? null;
return null; let conflictDocument = null;
if (conflictId) {
try {
const { data } = await apiClient.get(`/documents/${conflictId}`);
conflictDocument = data?.document ?? data ?? null;
} catch (fetchError) {
console.warn('[Uploads] failed to fetch conflict document', fetchError);
}
}
return {
document: conflictDocument,
duplicate: true,
statusCode: 409,
conflictDocumentId: conflictId,
};
}
const message = error.response?.data?.error || `Failed to upload ${file.name}.`;
const wrapped = Object.assign(new Error(message), { response: error.response });
throw wrapped;
} }
const message = error.response?.data?.error || `Failed to upload ${file.name}.`; },
notifyApiError(error, message); [apiClient],
throw error;
}
},
[apiClient, notifyApiError, setStatusMessage],
); );
const appendQueueItems = useCallback((entries, targetFolderId) => {
const baseId = Date.now();
const items = entries.map(({ file }) => {
queueIdRef.current += 1;
return {
id: `upload-${baseId}-${queueIdRef.current}`,
name: file?.name || 'Unnamed file',
size: file?.size ?? null,
folderId: targetFolderId ?? selectedFolder ?? 'root',
status: 'pending',
error: null,
code: null,
document: null,
conflictDocumentId: null,
};
});
if (items.length) {
setUploadQueue((current) => [...current, ...items]);
}
return items;
}, [selectedFolder]);
const updateQueueItem = useCallback((id, patch) => {
if (!id) {
return;
}
setUploadQueue((current) =>
current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
);
}, []);
const ensureFolderPathOnServer = useCallback( const ensureFolderPathOnServer = useCallback(
async (baseFolderId, segments) => { async (baseFolderId, segments) => {
const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean); const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean);
@@ -226,13 +270,23 @@ const useDocumentUploads = ({
const uploadFileEntries = useCallback( const uploadFileEntries = useCallback(
async (entries, targetFolderId) => { async (entries, targetFolderId) => {
if (!token) { if (!entries || !entries.length) {
setStatusMessage('Please log in before uploading.', 'error'); console.warn('[Uploads] No files to upload.');
return; return;
} }
if (!entries || !entries.length) { const queueItems = appendQueueItems(entries, targetFolderId);
setStatusMessage('No files to upload.', 'info');
if (!token) {
queueItems.forEach((item) => {
const patch = {
status: 'error',
error: 'Please log in before uploading.',
code: null,
};
updateQueueItem(item.id, patch);
Object.assign(item, patch);
});
return; return;
} }
@@ -244,7 +298,14 @@ const useDocumentUploads = ({
const baseFolderId = const baseFolderId =
targetFolderId && targetFolderId !== 'root' ? targetFolderId : null; targetFolderId && targetFolderId !== 'root' ? targetFolderId : null;
for (const { file, segments } of entries) { for (let index = 0; index < entries.length; index += 1) {
const { file, segments } = entries[index];
const queueItem = queueItems[index];
if (queueItem) {
const patch = { status: 'uploading', error: null, code: null };
updateQueueItem(queueItem.id, patch);
Object.assign(queueItem, patch);
}
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const destinationId = segments.length const destinationId = segments.length
? await ensureFolderPathOnServer(baseFolderId, segments) ? await ensureFolderPathOnServer(baseFolderId, segments)
@@ -254,8 +315,34 @@ const useDocumentUploads = ({
destinationId ?? destinationId ??
(targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root'); (targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root');
// eslint-disable-next-line no-await-in-loop try {
await uploadFile(file, uploadTarget); // eslint-disable-next-line no-await-in-loop
const { duplicate, statusCode, document, conflictDocumentId } = await uploadFile(
file,
uploadTarget,
);
if (queueItem) {
const patch = {
status: duplicate ? 'duplicate' : 'success',
code: statusCode ?? null,
document: document || queueItem.document,
conflictDocumentId: conflictDocumentId ?? queueItem.conflictDocumentId,
};
updateQueueItem(queueItem.id, patch);
Object.assign(queueItem, patch);
}
} catch (error) {
if (queueItem) {
const patch = {
status: 'error',
error: error.response?.data?.error || error.message || 'Upload failed.',
code: error.response?.status ?? null,
};
updateQueueItem(queueItem.id, patch);
Object.assign(queueItem, patch);
}
continue;
}
} }
await refreshCurrentFolder(); await refreshCurrentFolder();
@@ -268,7 +355,20 @@ const useDocumentUploads = ({
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 }); await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
} }
} catch (error) { } catch (error) {
notifyApiError(error, 'Failed to upload files.'); const message = error.message || 'Failed to upload files.';
queueItems.forEach((item) => {
if (item.status === 'success' || item.status === 'duplicate' || item.status === 'error') {
return;
}
const patch = {
status: 'error',
error: message,
code: error.response?.status ?? null,
};
updateQueueItem(item.id, patch);
Object.assign(item, patch);
});
console.error('[Uploads] batch failed', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -280,9 +380,9 @@ const useDocumentUploads = ({
refreshCurrentFolder, refreshCurrentFolder,
selectedFolder, selectedFolder,
ensureFolderData, ensureFolderData,
notifyApiError,
setStatusMessage,
setLoading, setLoading,
appendQueueItems,
updateQueueItem,
], ],
); );
@@ -292,14 +392,13 @@ const useDocumentUploads = ({
try { try {
extracted = await extractFilesFromDataTransfer(dataTransfer); extracted = await extractFilesFromDataTransfer(dataTransfer);
} catch (error) { } catch (error) {
const message = error.message || 'Failed to process dropped files.'; console.error('[Uploads] Failed to process dropped files.', error);
notifyApiError(error, message);
return; return;
} }
await uploadFileEntries(extracted, targetFolderId); await uploadFileEntries(extracted, targetFolderId);
}, },
[extractFilesFromDataTransfer, uploadFileEntries, notifyApiError], [extractFilesFromDataTransfer, uploadFileEntries],
); );
const handleFileSelection = useCallback( const handleFileSelection = useCallback(
@@ -326,6 +425,11 @@ const useDocumentUploads = ({
const resetUploadsState = useCallback(() => { const resetUploadsState = useCallback(() => {
dragCounterRef.current = 0; dragCounterRef.current = 0;
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME }); setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
setUploadQueue([]);
}, []);
const clearUploadQueue = useCallback(() => {
setUploadQueue([]);
}, []); }, []);
return { return {
@@ -337,6 +441,8 @@ const useDocumentUploads = ({
uploadFile, uploadFile,
extractFilesFromDataTransfer, extractFilesFromDataTransfer,
resetUploadsState, resetUploadsState,
uploadQueue,
clearUploadQueue,
}; };
}; };
@@ -534,6 +534,8 @@ const useDocumentsWorkspace = ({
dropOverlayState, dropOverlayState,
handleFileDrop, handleFileDrop,
handleFileSelection, handleFileSelection,
uploadQueue,
clearUploadQueue,
resetUploadsState, resetUploadsState,
} = useDocumentUploads({ } = useDocumentUploads({
apiClient: api, apiClient: api,
@@ -624,6 +626,7 @@ const useDocumentsWorkspace = ({
assetManager.reset(); assetManager.reset();
resetPreviewState(); resetPreviewState();
resetUploadsState(); resetUploadsState();
clearUploadQueue();
breadcrumbFetchRef.current = new Set(); breadcrumbFetchRef.current = new Set();
detailFolderFetchRef.current = new Set(); detailFolderFetchRef.current = new Set();
bootstrapInitializedRef.current = false; bootstrapInitializedRef.current = false;
@@ -654,6 +657,7 @@ const useDocumentsWorkspace = ({
setActivePreviewId, setActivePreviewId,
resetPreviewState, resetPreviewState,
resetUploadsState, resetUploadsState,
clearUploadQueue,
]); ]);
useEffect(() => { useEffect(() => {
@@ -1373,6 +1377,7 @@ const useDocumentsWorkspace = ({
handleTenantSelect, handleTenantSelect,
openSettings, openSettings,
handleFileSelection, handleFileSelection,
uploadQueue,
}); });
@@ -1478,6 +1483,8 @@ const useDocumentsWorkspace = ({
openSettings, openSettings,
detailPanelOpen, detailPanelOpen,
openDetailPanel, openDetailPanel,
uploadQueue,
clearUploadQueue,
}), }),
[ [
token, token,
@@ -1528,6 +1535,8 @@ const useDocumentsWorkspace = ({
openSettings, openSettings,
detailPanelOpen, detailPanelOpen,
openDetailPanel, openDetailPanel,
uploadQueue,
clearUploadQueue,
], ],
); );
+3
View File
@@ -36,6 +36,7 @@ const useSidebarProps = ({
handleTenantSelect, handleTenantSelect,
openSettings, openSettings,
handleFileSelection, handleFileSelection,
uploadQueue,
}) => }) =>
useMemo( useMemo(
() => ({ () => ({
@@ -87,9 +88,11 @@ const useSidebarProps = ({
const folderId = targetFolderId ?? selectedFolder ?? 'root'; const folderId = targetFolderId ?? selectedFolder ?? 'root';
handleFileSelection(files, folderId); handleFileSelection(files, folderId);
}, },
uploadQueue,
}), }),
[ [
handleFileSelection, handleFileSelection,
uploadQueue,
activeCorrespondentFilters, activeCorrespondentFilters,
activeTagFilters, activeTagFilters,
appStatus, appStatus,
+192
View File
@@ -4271,3 +4271,195 @@ fieldset.settings-form__field legend {
width: 1.41rem; width: 1.41rem;
height: 1.41rem; height: 1.41rem;
} }
.upload-queue-overlay {
position: fixed;
bottom: 24px;
right: 24px;
width: min(360px, calc(100vw - 32px));
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 18px 45px var(--shadow-pop);
z-index: 400;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.9rem;
color: var(--fg);
}
.upload-queue-overlay__header {
padding: 0;
border: none;
}
.upload-queue-overlay__title {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
font-size: 0.95rem;
min-width: 0;
}
.upload-queue-overlay__summary {
font-size: 0.8rem;
color: var(--muted);
white-space: nowrap;
}
.upload-queue-overlay__controls {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.upload-queue-overlay__list {
list-style: none;
margin: 0;
padding: 0;
max-height: 260px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.upload-queue-overlay--collapsed .upload-queue-overlay__list {
display: none;
}
.upload-queue-overlay__item {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.6rem;
padding: 0.15rem 0;
}
.upload-queue-overlay__status {
width: 28px;
height: 28px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--surface-subtle);
color: var(--muted);
}
.upload-queue-overlay__status--muted {
background: var(--surface-subtle);
color: var(--muted);
}
.upload-queue-overlay__status--accent {
background: var(--accent-soft);
color: var(--accent);
}
.upload-queue-overlay__status--success {
background: var(--success-subtle);
color: var(--success);
}
.upload-queue-overlay__status--info {
background: var(--selection-soft);
color: var(--accent);
}
.upload-queue-overlay__status--danger {
background: var(--danger-subtle);
color: var(--danger);
}
.upload-queue-overlay__details {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.upload-queue-overlay__name {
font-size: 0.88rem;
font-weight: 500;
color: var(--fg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upload-queue-overlay__name-link {
font-size: 0.88rem;
font-weight: 500;
color: inherit;
background: none;
border: none;
padding: 0;
text-align: left;
cursor: pointer;
text-decoration: none;
}
.upload-queue-overlay__name-link:hover {
color: var(--accent);
text-decoration: underline;
}
.upload-queue-overlay__meta-line {
font-size: 0.72rem;
color: var(--muted);
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.upload-queue-overlay__meta-id,
.upload-queue-overlay__meta-secondary {
color: inherit;
}
.upload-queue-overlay__meta-link {
border: none;
background: none;
font: inherit;
cursor: pointer;
padding: 0;
color: inherit;
text-decoration: none;
}
.upload-queue-overlay__meta-link:hover {
color: var(--accent);
text-decoration: underline;
}
.upload-queue-overlay__meta-link:disabled {
cursor: default;
opacity: 0.5;
}
.upload-queue-overlay__meta-error {
color: var(--danger);
}
.upload-queue-overlay__item--error .upload-queue-overlay__name {
color: var(--danger);
}
.text-button {
background: none;
border: none;
color: var(--accent);
font-weight: 500;
font-size: 0.8rem;
cursor: pointer;
padding: 0.1rem 0.25rem;
}
.text-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
+40
View File
@@ -33,6 +33,9 @@ import {
IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand, IconLayoutSidebarLeftExpand,
IconLayoutSidebarRightCollapse, IconLayoutSidebarRightCollapse,
IconLayoutBottombarCollapse,
IconLayoutBottombarExpand,
IconClearAll,
IconInfoCircle, IconInfoCircle,
IconCircleDashedCheck, IconCircleDashedCheck,
IconFile, IconFile,
@@ -40,6 +43,7 @@ import {
IconSortAscendingLetters, IconSortAscendingLetters,
IconSortDescendingLetters, IconSortDescendingLetters,
IconFileInfo, IconFileInfo,
IconAlertTriangle,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import FolderSvg from '../assets/folder.svg'; import FolderSvg from '../assets/folder.svg';
import composeClassName from './classNames'; import composeClassName from './classNames';
@@ -204,6 +208,33 @@ export const DetailPanelCollapseIcon = ({ className, size = '1em', stroke = 1.6,
/> />
); );
export const BottombarCollapseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconLayoutBottombarCollapse
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const BottombarExpandIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconLayoutBottombarExpand
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ClearAllIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconClearAll
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const FolderPlusIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( export const FolderPlusIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconFolderPlus <IconFolderPlus
className={composeClassName('icon', className)} className={composeClassName('icon', className)}
@@ -438,6 +469,15 @@ export const LoaderIcon = ({ className, size = '1em', stroke = 1.8, ...rest }) =
/> />
); );
export const WarningIcon = ({ className, size = '1em', stroke = 1.8, ...rest }) => (
<IconAlertTriangle
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconWindowMaximize <IconWindowMaximize
className={composeClassName('icon', className)} className={composeClassName('icon', className)}