feat: upload queue with queued status and cancel button
Multi-file drops now show queued/uploading distinction. Jobs start as 'queued' and transition to 'uploading' when the HTTP request begins. Queued items show a cancel button in the upload panel. Cancelled jobs are skipped in the sequential upload loop.
This commit is contained in:
@@ -242,9 +242,18 @@ export function setupDragDrop(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeholder = createPlaceholder(viewport, cursorX, cursorY);
|
|
||||||
const jobId = uploads?.addJob(file, boardId);
|
const jobId = uploads?.addJob(file, boardId);
|
||||||
|
|
||||||
|
// Check if job was cancelled while queued (user clicked cancel)
|
||||||
|
if (jobId && uploads?.isCancelled(jobId)) {
|
||||||
|
col++;
|
||||||
|
if (col >= cols) { col = 0; row++; cursorY += rowMaxH + GAP; rowMaxH = 0; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholder = createPlaceholder(viewport, cursorX, cursorY);
|
||||||
|
if (jobId) uploads?.startUpload(jobId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await uploadImage(boardId, file, (p) => {
|
const res = await uploadImage(boardId, file, (p) => {
|
||||||
if (jobId) uploads?.setProgress(jobId, p);
|
if (jobId) uploads?.setProgress(jobId, p);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ function formatSize(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
queued: 'Queued',
|
||||||
uploading: 'Uploading',
|
uploading: 'Uploading',
|
||||||
processing: 'Processing',
|
processing: 'Processing',
|
||||||
done: 'Done',
|
done: 'Done',
|
||||||
@@ -19,13 +20,14 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
queued: '#888',
|
||||||
uploading: '#4a9eff',
|
uploading: '#4a9eff',
|
||||||
processing: '#ffa94d',
|
processing: '#ffa94d',
|
||||||
done: '#4ade80',
|
done: '#4ade80',
|
||||||
failed: '#f87171',
|
failed: '#f87171',
|
||||||
};
|
};
|
||||||
|
|
||||||
function JobRow({ job, onDismiss }: { job: UploadJob; onDismiss: () => void }) {
|
function JobRow({ job, onDismiss, onCancel }: { job: UploadJob; onDismiss: () => void; onCancel?: () => void }) {
|
||||||
const pct = Math.round(job.progress * 100);
|
const pct = Math.round(job.progress * 100);
|
||||||
const color = STATUS_COLORS[job.status];
|
const color = STATUS_COLORS[job.status];
|
||||||
|
|
||||||
@@ -48,6 +50,12 @@ function JobRow({ job, onDismiss }: { job: UploadJob; onDismiss: () => void }) {
|
|||||||
<span style={{ fontSize: '10px', color: '#555', flexShrink: 0 }}>
|
<span style={{ fontSize: '10px', color: '#555', flexShrink: 0 }}>
|
||||||
{formatSize(job.fileSize)}
|
{formatSize(job.fileSize)}
|
||||||
</span>
|
</span>
|
||||||
|
{job.status === 'queued' && onCancel && (
|
||||||
|
<button onClick={onCancel} style={{
|
||||||
|
background: 'none', border: 'none', color: '#f87171', cursor: 'pointer',
|
||||||
|
fontSize: '10px', padding: '0 2px', flexShrink: 0,
|
||||||
|
}}>cancel</button>
|
||||||
|
)}
|
||||||
{(job.status === 'done' || job.status === 'failed') && (
|
{(job.status === 'done' || job.status === 'failed') && (
|
||||||
<button onClick={onDismiss} style={{
|
<button onClick={onDismiss} style={{
|
||||||
background: 'none', border: 'none', color: '#444', cursor: 'pointer',
|
background: 'none', border: 'none', color: '#444', cursor: 'pointer',
|
||||||
@@ -116,7 +124,12 @@ export default function UploadPanel({ uploadManager }: UploadPanelProps) {
|
|||||||
{/* Job list — max 4 visible, scroll */}
|
{/* Job list — max 4 visible, scroll */}
|
||||||
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
|
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
|
||||||
{jobs.map((job) => (
|
{jobs.map((job) => (
|
||||||
<JobRow key={job.id} job={job} onDismiss={() => uploadManager.dismiss(job.id)} />
|
<JobRow
|
||||||
|
key={job.id}
|
||||||
|
job={job}
|
||||||
|
onDismiss={() => uploadManager.dismiss(job.id)}
|
||||||
|
onCancel={job.status === 'queued' ? () => uploadManager.cancel(job.id) : undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type UploadStatus = 'uploading' | 'processing' | 'done' | 'failed';
|
export type UploadStatus = 'queued' | 'uploading' | 'processing' | 'done' | 'failed';
|
||||||
|
|
||||||
export interface UploadJob {
|
export interface UploadJob {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,7 +41,7 @@ export class UploadManager {
|
|||||||
fileName: file.name || (isVideo ? 'video' : 'image'),
|
fileName: file.name || (isVideo ? 'video' : 'image'),
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
mediaType: isVideo ? 'video' : 'image',
|
mediaType: isVideo ? 'video' : 'image',
|
||||||
status: 'uploading',
|
status: 'queued',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
file,
|
file,
|
||||||
boardId,
|
boardId,
|
||||||
@@ -51,6 +51,29 @@ export class UploadManager {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mark a queued job as actively uploading. */
|
||||||
|
startUpload(jobId: string) {
|
||||||
|
const job = this.jobs.get(jobId);
|
||||||
|
if (!job || job.status !== 'queued') return;
|
||||||
|
job.status = 'uploading';
|
||||||
|
this._notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel a queued job (before upload starts). Returns true if cancelled. */
|
||||||
|
cancel(jobId: string): boolean {
|
||||||
|
const job = this.jobs.get(jobId);
|
||||||
|
if (!job || job.status !== 'queued') return false;
|
||||||
|
this._clearDismissTimer(jobId);
|
||||||
|
this.jobs.delete(jobId);
|
||||||
|
this._notify();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a job has been cancelled (removed from map). */
|
||||||
|
isCancelled(jobId: string): boolean {
|
||||||
|
return !this.jobs.has(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Create a job for a URL-based import (no File object, unknown size). */
|
/** Create a job for a URL-based import (no File object, unknown size). */
|
||||||
addUrlJob(fileName: string, mediaType: 'image' | 'video'): string {
|
addUrlJob(fileName: string, mediaType: 'image' | 'video'): string {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
@@ -167,11 +190,11 @@ export class UploadManager {
|
|||||||
this._notify();
|
this._notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get active (uploading or processing) job count. */
|
/** Get active (queued, uploading, or processing) job count. */
|
||||||
get activeCount(): number {
|
get activeCount(): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const job of this.jobs.values()) {
|
for (const job of this.jobs.values()) {
|
||||||
if (job.status === 'uploading' || job.status === 'processing') count++;
|
if (job.status === 'queued' || job.status === 'uploading' || job.status === 'processing') count++;
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user