diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index ff1d38f..6c8ae4c 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -2,6 +2,7 @@ import React from 'react'; import { Navigate, Outlet } from 'react-router-dom'; import { AppShellContext } from '../appShellContext'; import DropOverlay from './DropOverlay'; +import UploadQueueOverlay from './UploadQueueOverlay'; import useDocumentsWorkspace from '../hooks/documents/useDocumentsWorkspace'; import { useDocumentsPreferences } from './useDocumentsPreferences'; @@ -54,6 +55,10 @@ const AppLayout = () => { active={dropOverlayState.active} folderName={dropOverlayState.folderName} /> + {managementModals} diff --git a/frontend/src/app/UploadQueueOverlay.jsx b/frontend/src/app/UploadQueueOverlay.jsx new file mode 100644 index 0000000..40af83c --- /dev/null +++ b/frontend/src/app/UploadQueueOverlay.jsx @@ -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: , + }, + uploading: { + label: 'Uploading', + tone: 'accent', + icon: , + }, + success: { + label: 'Uploaded', + tone: 'success', + icon: , + }, + duplicate: { + label: 'Duplicate', + tone: 'info', + icon: , + }, + error: { + label: 'Failed', + tone: 'danger', + icon: , + }, +}; + +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 ( +
+ + Uploads + {summary} + + )} + actions={( +
+ + + +
+ )} + /> + {!collapsed ? ( +
    + {[...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 ( +
  • + + {meta.icon} + +
    + {item.status === 'success' && hasLink ? ( + + ) : ( +
    + {fileLabel} +
    + )} +
    + {item.status === 'duplicate' && duplicateLabel ? ( + + Duplicate of{' '} + + + ) : item.status === 'error' && item.error ? ( + + {item.error} + + ) : ( + <> + {meta.label} + {documentId ? ( + + ( + + ) + + ) : null} + + )} +
    +
    +
  • + ); + })} +
+ ) : null} +
+ ); +}; + +export default UploadQueueOverlay; diff --git a/frontend/src/hooks/documents/useDocumentUploads.js b/frontend/src/hooks/documents/useDocumentUploads.js index 373e40f..50b6d2d 100644 --- a/frontend/src/hooks/documents/useDocumentUploads.js +++ b/frontend/src/hooks/documents/useDocumentUploads.js @@ -29,8 +29,6 @@ const useDocumentUploads = ({ currentFolderName, ensureFolderData, refreshCurrentFolder, - notifyApiError, - setStatusMessage, setLoading, shellRef, }) => { @@ -40,12 +38,13 @@ const useDocumentUploads = ({ }); const dragCounterRef = useRef(0); const folderPathCacheRef = useRef(new Map()); + const queueIdRef = useRef(0); + const [uploadQueue, setUploadQueue] = useState([]); const uploadFile = useCallback( async (file, targetFolderId) => { if (!file || file.size === 0) { - setStatusMessage('Skipped empty file.', 'error'); - return null; + return { document: null, duplicate: false, statusCode: null, conflictDocumentId: null }; } const formData = new FormData(); @@ -54,29 +53,74 @@ const useDocumentUploads = ({ formData.append('folder_id', targetFolderId); } - try { - const { data, status } = await apiClient.post('/documents', formData); - const duplicate = data?.reused || status === 200; - setStatusMessage( - duplicate - ? `${file.name} already exists; reused existing document.` - : `Uploaded ${file.name}`, - duplicate ? 'info' : 'success', - ); - return data; - } catch (error) { - if (error.response?.status === 409) { - setStatusMessage(`${file.name} already exists on the server.`, 'info'); - return null; + try { + const { data, status } = await apiClient.post('/documents', formData); + const duplicate = data?.reused || status === 200; + const document = data?.document ?? data ?? null; + return { + document, + duplicate, + statusCode: status ?? (duplicate ? 200 : 201), + conflictDocumentId: null, + }; + } catch (error) { + if (error.response?.status === 409) { + const conflictId = error.response?.data?.details?.conflict_document_id ?? 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); - throw error; - } - }, - [apiClient, notifyApiError, setStatusMessage], + }, + [apiClient], ); + 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( async (baseFolderId, segments) => { const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean); @@ -226,13 +270,23 @@ const useDocumentUploads = ({ const uploadFileEntries = useCallback( async (entries, targetFolderId) => { - if (!token) { - setStatusMessage('Please log in before uploading.', 'error'); + if (!entries || !entries.length) { + console.warn('[Uploads] No files to upload.'); return; } - if (!entries || !entries.length) { - setStatusMessage('No files to upload.', 'info'); + const queueItems = appendQueueItems(entries, targetFolderId); + + 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; } @@ -244,7 +298,14 @@ const useDocumentUploads = ({ const baseFolderId = 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 const destinationId = segments.length ? await ensureFolderPathOnServer(baseFolderId, segments) @@ -254,8 +315,34 @@ const useDocumentUploads = ({ destinationId ?? (targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root'); - // eslint-disable-next-line no-await-in-loop - await uploadFile(file, uploadTarget); + try { + // 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(); @@ -268,7 +355,20 @@ const useDocumentUploads = ({ await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 }); } } 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 { setLoading(false); } @@ -280,9 +380,9 @@ const useDocumentUploads = ({ refreshCurrentFolder, selectedFolder, ensureFolderData, - notifyApiError, - setStatusMessage, setLoading, + appendQueueItems, + updateQueueItem, ], ); @@ -292,14 +392,13 @@ const useDocumentUploads = ({ try { extracted = await extractFilesFromDataTransfer(dataTransfer); } catch (error) { - const message = error.message || 'Failed to process dropped files.'; - notifyApiError(error, message); + console.error('[Uploads] Failed to process dropped files.', error); return; } await uploadFileEntries(extracted, targetFolderId); }, - [extractFilesFromDataTransfer, uploadFileEntries, notifyApiError], + [extractFilesFromDataTransfer, uploadFileEntries], ); const handleFileSelection = useCallback( @@ -326,6 +425,11 @@ const useDocumentUploads = ({ const resetUploadsState = useCallback(() => { dragCounterRef.current = 0; setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME }); + setUploadQueue([]); + }, []); + + const clearUploadQueue = useCallback(() => { + setUploadQueue([]); }, []); return { @@ -337,6 +441,8 @@ const useDocumentUploads = ({ uploadFile, extractFilesFromDataTransfer, resetUploadsState, + uploadQueue, + clearUploadQueue, }; }; diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.js b/frontend/src/hooks/documents/useDocumentsWorkspace.js index 1c7878e..aeac0f8 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.js +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.js @@ -534,6 +534,8 @@ const useDocumentsWorkspace = ({ dropOverlayState, handleFileDrop, handleFileSelection, + uploadQueue, + clearUploadQueue, resetUploadsState, } = useDocumentUploads({ apiClient: api, @@ -624,6 +626,7 @@ const useDocumentsWorkspace = ({ assetManager.reset(); resetPreviewState(); resetUploadsState(); + clearUploadQueue(); breadcrumbFetchRef.current = new Set(); detailFolderFetchRef.current = new Set(); bootstrapInitializedRef.current = false; @@ -654,6 +657,7 @@ const useDocumentsWorkspace = ({ setActivePreviewId, resetPreviewState, resetUploadsState, + clearUploadQueue, ]); useEffect(() => { @@ -1373,6 +1377,7 @@ const useDocumentsWorkspace = ({ handleTenantSelect, openSettings, handleFileSelection, + uploadQueue, }); @@ -1478,6 +1483,8 @@ const useDocumentsWorkspace = ({ openSettings, detailPanelOpen, openDetailPanel, + uploadQueue, + clearUploadQueue, }), [ token, @@ -1528,6 +1535,8 @@ const useDocumentsWorkspace = ({ openSettings, detailPanelOpen, openDetailPanel, + uploadQueue, + clearUploadQueue, ], ); diff --git a/frontend/src/sidebar/useSidebarProps.js b/frontend/src/sidebar/useSidebarProps.js index 86cb84e..d305b18 100644 --- a/frontend/src/sidebar/useSidebarProps.js +++ b/frontend/src/sidebar/useSidebarProps.js @@ -36,6 +36,7 @@ const useSidebarProps = ({ handleTenantSelect, openSettings, handleFileSelection, + uploadQueue, }) => useMemo( () => ({ @@ -87,9 +88,11 @@ const useSidebarProps = ({ const folderId = targetFolderId ?? selectedFolder ?? 'root'; handleFileSelection(files, folderId); }, + uploadQueue, }), [ handleFileSelection, + uploadQueue, activeCorrespondentFilters, activeTagFilters, appStatus, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 5687227..edfed4a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -4271,3 +4271,195 @@ fieldset.settings-form__field legend { width: 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; +} diff --git a/frontend/src/ui/icons.js b/frontend/src/ui/icons.js index 2e7cba8..d8a8dfc 100644 --- a/frontend/src/ui/icons.js +++ b/frontend/src/ui/icons.js @@ -33,6 +33,9 @@ import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand, IconLayoutSidebarRightCollapse, + IconLayoutBottombarCollapse, + IconLayoutBottombarExpand, + IconClearAll, IconInfoCircle, IconCircleDashedCheck, IconFile, @@ -40,6 +43,7 @@ import { IconSortAscendingLetters, IconSortDescendingLetters, IconFileInfo, + IconAlertTriangle, } from '@tabler/icons-react'; import FolderSvg from '../assets/folder.svg'; 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 }) => ( + +); + +export const BottombarExpandIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + +export const ClearAllIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + export const FolderPlusIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( ); +export const WarningIcon = ({ className, size = '1em', stroke = 1.8, ...rest }) => ( + +); + export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (