From a3d106394601e87ef01a0425ad91714ced29c3a0 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 11:35:10 -0500 Subject: [PATCH] Upload page: auto-upload and a 5x3 progress grid - Files upload as soon as they are dropped or selected; a sequential queue processes them and picks up files added while uploading - The progress list is now a grid: 5 columns with about three rows visible (scrolls beyond) showing thumbnails, per-file progress bars and status; 'clear finished' remains, object URLs are revoked - Videos show an icon instead of a thumbnail --- frontend/src/features/upload/UploadPage.tsx | 198 ++++++++++++-------- 1 file changed, 125 insertions(+), 73 deletions(-) diff --git a/frontend/src/features/upload/UploadPage.tsx b/frontend/src/features/upload/UploadPage.tsx index 3b9b71b..7e508a8 100644 --- a/frontend/src/features/upload/UploadPage.tsx +++ b/frontend/src/features/upload/UploadPage.tsx @@ -2,9 +2,16 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckCircle2, UploadCloud, + Video, X, } from "lucide-react"; -import { useRef, useState, type DragEvent, type ReactNode } from "react"; +import { + useEffect, + useRef, + useState, + type DragEvent, + type ReactNode, +} from "react"; import { Link } from "react-router-dom"; import { Button, EmptyState, Spinner, inputClass } from "@/components/ui"; @@ -36,6 +43,7 @@ interface UploadEntry { file: File; progress: number; status: UploadStatus; + previewUrl: string; error?: string; } @@ -540,6 +548,9 @@ export default function UploadPage() { const user = useAuth((state) => state.user); const credentials = useE621((state) => state.credentials); const inputRef = useRef(null); + const queueRef = useRef([]); + const runningRef = useRef(false); + const objectUrls = useRef([]); const [dragging, setDragging] = useState(false); const [entries, setEntries] = useState([]); const [busy, setBusy] = useState(false); @@ -547,6 +558,13 @@ export default function UploadPage() { const [checkingIds, setCheckingIds] = useState([]); const [modalFor, setModalFor] = useState(null); + useEffect(() => { + const urls = objectUrls.current; + return () => { + for (const url of urls) URL.revokeObjectURL(url); + }; + }, []); + const uploadsQuery = useQuery({ queryKey: ["uploads"], queryFn: () => api>("/api/uploads/"), @@ -560,21 +578,25 @@ export default function UploadPage() { (temp) => temp.status === "completed" || temp.status === "error", ); - const pendingCount = entries.filter( - (entry) => entry.status === "queued" || entry.status === "error", - ).length; - function addFiles(files: FileList | null) { if (!files || files.length === 0) return; - const next: UploadEntry[] = Array.from(files).map((file) => ({ - id: `${file.name}-${file.size}-${file.lastModified}-${Math.random() - .toString(36) - .slice(2, 8)}`, - file, - progress: 0, - status: "queued", - })); + const next: UploadEntry[] = Array.from(files).map((file) => { + const isVideo = /\.(mp4|webm)$/i.test(file.name); + const previewUrl = isVideo ? "" : URL.createObjectURL(file); + if (previewUrl) objectUrls.current.push(previewUrl); + return { + id: `${file.name}-${file.size}-${file.lastModified}-${Math.random() + .toString(36) + .slice(2, 8)}`, + file, + progress: 0, + status: "queued", + previewUrl, + }; + }); setEntries((current) => [...current, ...next]); + queueRef.current.push(...next); + void drainQueue(); } function handleDrop(event: DragEvent) { @@ -678,15 +700,14 @@ export default function UploadPage() { void checkSimilarity(stillPending); } - async function startUploads() { - if (busy) return; + async function drainQueue() { + if (runningRef.current) return; + runningRef.current = true; setBusy(true); - setProcessing(true); - const queue = entries.filter( - (entry) => entry.status === "queued" || entry.status === "error", - ); const created: TempUpload[] = []; - for (const entry of queue) { + while (queueRef.current.length > 0) { + const entry = queueRef.current.shift(); + if (!entry) break; updateEntry(entry.id, { status: "uploading", progress: 0, @@ -705,9 +726,13 @@ export default function UploadPage() { }); } } + runningRef.current = false; setBusy(false); invalidateUploads(); - void autoMatchAndCheck(created).finally(() => setProcessing(false)); + if (created.length > 0) { + setProcessing(true); + void autoMatchAndCheck(created).finally(() => setProcessing(false)); + } } function dismiss(temp: TempUpload) { @@ -726,7 +751,13 @@ export default function UploadPage() { } function clearFinished() { - setEntries((current) => current.filter((entry) => entry.status !== "done")); + const finished = entries.filter((entry) => entry.status === "done"); + for (const entry of finished) { + if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl); + } + setEntries((current) => + current.filter((entry) => entry.status !== "done"), + ); } if (user && !user.can_upload) { @@ -790,9 +821,10 @@ export default function UploadPage() { then moved into the library once resolved.

- {processing ? ( + {busy || processing ? (

- Processing uploads… + + {busy ? "Uploading…" : "Processing uploads…"}

) : null} @@ -824,7 +856,10 @@ export default function UploadPage() { Drag & drop files here

- or click to select — PNG, JPG, GIF, APNG, WEBP, MP4, WEBM + or click to select — uploads start automatically +

+

+ PNG · JPG · GIF · APNG · WEBP · MP4 · WEBM

0 ? (
- {entries.map((entry) => ( -
+

+ Uploads + + {entries.length} + +

+ - + ))}
) : null}