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}
+
+
+