upload queue
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user