Files
papercrate/frontend/src/app/UploadQueueOverlay.tsx
T
2025-11-18 13:16:35 +01:00

209 lines
6.7 KiB
TypeScript

import { useMemo, useState, useEffect } from 'react';
import type { JSX } from 'react';
import { useNavigate } from 'react-router-dom';
import {
CloseIcon,
LoaderIcon,
CheckIcon,
InfoIcon,
WarningIcon,
BottombarCollapseIcon,
BottombarExpandIcon,
} from '../ui/icons';
import PanelHeader from '../ui/PanelHeader';
type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error' | (string & {});
interface UploadQueueItem {
id: string | number;
name: string;
status: UploadStatus;
error?: string | null;
document?: { id?: string | number; title?: string };
conflictDocumentId?: string | number;
}
interface UploadQueueOverlayProps {
queue?: UploadQueueItem[];
onClearQueue?: () => void;
}
const STATUS_META: Record<string, { label: string; tone: string; icon: JSX.Element }> = {
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 }: UploadQueueOverlayProps): JSX.Element | null => {
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 handleDismissOverlay = () => {
if (!queue.length) {
setDismissed(true);
return;
}
if (hasActiveUploads) {
const confirmed = window.confirm('Uploads are still running. Clear the queue and hide the overlay?');
if (!confirmed) {
return;
}
}
onClearQueue?.();
setDismissed(true);
};
if (!queue.length || dismissed) {
return null;
}
return (
<div className={`upload-queue-overlay${collapsed ? ' upload-queue-overlay--collapsed' : ''}`}>
<PanelHeader
title={(
<span>
<span>Uploads</span>
<span className="panel-header__subtitle">{summary}</span>
</span>
)}
actions={(
<div className="upload-queue-overlay__controls">
<button
type="button"
className="icon-button ghost"
onClick={() => setCollapsed(!collapsed)}
aria-label={collapsed ? 'Expand upload queue' : 'Collapse upload queue'}
>
{collapsed ? <BottombarExpandIcon size={16} /> : <BottombarCollapseIcon size={16} />}
</button>
<button
type="button"
className="icon-button"
onClick={handleDismissOverlay}
aria-label="Clear uploads and hide overlay"
>
<CloseIcon size={16} />
</button>
</div>
)}
/>
{!collapsed ? (
<div className="upload-queue-overlay__body">
<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 documentTitle = item.document?.title || null;
const duplicateLabel = item.status === 'duplicate' ? documentTitle : 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>
)}
</div>
</div>
</li>
);
})}
</ul>
</div>
) : null}
</div>
);
};
export default UploadQueueOverlay;