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 { 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}
/>
<UploadQueueOverlay
queue={contextValue.uploadQueue || []}
onClearQueue={contextValue.clearUploadQueue}
/>
<Outlet />
{managementModals}
</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,
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,
};
};
@@ -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,
],
);
+3
View File
@@ -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,
+192
View File
@@ -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;
}
+40
View File
@@ -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 }) => (
<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 }) => (
<IconFolderPlus
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 }) => (
<IconWindowMaximize
className={composeClassName('icon', className)}