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