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
This commit is contained in:
2026-09-17 11:35:10 -05:00
parent 1086beb974
commit a3d1063946
+94 -42
View File
@@ -2,9 +2,16 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
CheckCircle2, CheckCircle2,
UploadCloud, UploadCloud,
Video,
X, X,
} from "lucide-react"; } 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 { Link } from "react-router-dom";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui"; import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
@@ -36,6 +43,7 @@ interface UploadEntry {
file: File; file: File;
progress: number; progress: number;
status: UploadStatus; status: UploadStatus;
previewUrl: string;
error?: string; error?: string;
} }
@@ -540,6 +548,9 @@ export default function UploadPage() {
const user = useAuth((state) => state.user); const user = useAuth((state) => state.user);
const credentials = useE621((state) => state.credentials); const credentials = useE621((state) => state.credentials);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const queueRef = useRef<UploadEntry[]>([]);
const runningRef = useRef(false);
const objectUrls = useRef<string[]>([]);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [entries, setEntries] = useState<UploadEntry[]>([]); const [entries, setEntries] = useState<UploadEntry[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -547,6 +558,13 @@ export default function UploadPage() {
const [checkingIds, setCheckingIds] = useState<string[]>([]); const [checkingIds, setCheckingIds] = useState<string[]>([]);
const [modalFor, setModalFor] = useState<TempUpload | null>(null); const [modalFor, setModalFor] = useState<TempUpload | null>(null);
useEffect(() => {
const urls = objectUrls.current;
return () => {
for (const url of urls) URL.revokeObjectURL(url);
};
}, []);
const uploadsQuery = useQuery({ const uploadsQuery = useQuery({
queryKey: ["uploads"], queryKey: ["uploads"],
queryFn: () => api<Paginated<TempUpload>>("/api/uploads/"), queryFn: () => api<Paginated<TempUpload>>("/api/uploads/"),
@@ -560,21 +578,25 @@ export default function UploadPage() {
(temp) => temp.status === "completed" || temp.status === "error", (temp) => temp.status === "completed" || temp.status === "error",
); );
const pendingCount = entries.filter(
(entry) => entry.status === "queued" || entry.status === "error",
).length;
function addFiles(files: FileList | null) { function addFiles(files: FileList | null) {
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
const next: UploadEntry[] = Array.from(files).map((file) => ({ 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() id: `${file.name}-${file.size}-${file.lastModified}-${Math.random()
.toString(36) .toString(36)
.slice(2, 8)}`, .slice(2, 8)}`,
file, file,
progress: 0, progress: 0,
status: "queued", status: "queued",
})); previewUrl,
};
});
setEntries((current) => [...current, ...next]); setEntries((current) => [...current, ...next]);
queueRef.current.push(...next);
void drainQueue();
} }
function handleDrop(event: DragEvent<HTMLDivElement>) { function handleDrop(event: DragEvent<HTMLDivElement>) {
@@ -678,15 +700,14 @@ export default function UploadPage() {
void checkSimilarity(stillPending); void checkSimilarity(stillPending);
} }
async function startUploads() { async function drainQueue() {
if (busy) return; if (runningRef.current) return;
runningRef.current = true;
setBusy(true); setBusy(true);
setProcessing(true);
const queue = entries.filter(
(entry) => entry.status === "queued" || entry.status === "error",
);
const created: TempUpload[] = []; const created: TempUpload[] = [];
for (const entry of queue) { while (queueRef.current.length > 0) {
const entry = queueRef.current.shift();
if (!entry) break;
updateEntry(entry.id, { updateEntry(entry.id, {
status: "uploading", status: "uploading",
progress: 0, progress: 0,
@@ -705,10 +726,14 @@ export default function UploadPage() {
}); });
} }
} }
runningRef.current = false;
setBusy(false); setBusy(false);
invalidateUploads(); invalidateUploads();
if (created.length > 0) {
setProcessing(true);
void autoMatchAndCheck(created).finally(() => setProcessing(false)); void autoMatchAndCheck(created).finally(() => setProcessing(false));
} }
}
function dismiss(temp: TempUpload) { function dismiss(temp: TempUpload) {
void api(`/api/uploads/${temp.temp_id}/`, { method: "DELETE" }).then(() => void api(`/api/uploads/${temp.temp_id}/`, { method: "DELETE" }).then(() =>
@@ -726,7 +751,13 @@ export default function UploadPage() {
} }
function clearFinished() { 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) { if (user && !user.can_upload) {
@@ -790,9 +821,10 @@ export default function UploadPage() {
then moved into the library once resolved. then moved into the library once resolved.
</p> </p>
</div> </div>
{processing ? ( {busy || processing ? (
<p className="flex items-center gap-2 text-xs text-ctp-overlay0"> <p className="flex items-center gap-2 text-xs text-ctp-overlay0">
<Spinner className="h-3.5 w-3.5" /> Processing uploads… <Spinner className="h-3.5 w-3.5" />
{busy ? "Uploading…" : "Processing uploads…"}
</p> </p>
) : null} ) : null}
</header> </header>
@@ -824,7 +856,10 @@ export default function UploadPage() {
Drag &amp; drop files here Drag &amp; drop files here
</p> </p>
<p className="text-xs text-ctp-overlay0"> <p className="text-xs text-ctp-overlay0">
or click to select — PNG, JPG, GIF, APNG, WEBP, MP4, WEBM or click to select — uploads start automatically
</p>
<p className="font-mono text-[10px] text-ctp-overlay0">
PNG · JPG · GIF · APNG · WEBP · MP4 · WEBM
</p> </p>
<input <input
ref={inputRef} ref={inputRef}
@@ -840,20 +875,40 @@ export default function UploadPage() {
{entries.length > 0 ? ( {entries.length > 0 ? (
<section className="flex flex-col gap-2"> <section className="flex flex-col gap-2">
<header className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Uploads
<span className="ml-2 font-mono text-[11px] text-ctp-overlay0">
{entries.length}
</span>
</h2>
<button
type="button"
onClick={clearFinished}
className="rounded-md px-1.5 py-0.5 font-mono text-[10px] text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
clear finished
</button>
</header>
<div className="grid max-h-[46rem] grid-cols-2 gap-3 overflow-y-auto pr-1 sm:grid-cols-3 lg:grid-cols-5">
{entries.map((entry) => ( {entries.map((entry) => (
<div <div
key={entry.id} key={entry.id}
className="rounded-lg border border-ctp-surface0 bg-ctp-base p-3" className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base"
> >
<div className="flex items-center justify-between gap-3 text-sm"> <div className="relative aspect-square overflow-hidden bg-ctp-mantle">
<span className="truncate font-mono text-xs text-ctp-subtext1"> {entry.previewUrl ? (
{entry.file.name} <img
</span> src={entry.previewUrl}
<span className="shrink-0 font-mono text-xs text-ctp-overlay0"> alt={entry.file.name}
{formatBytes(entry.file.size)} className="h-full w-full object-cover"
</span> />
) : (
<div className="flex h-full w-full items-center justify-center">
<Video className="h-5 w-5 text-ctp-overlay0" />
</div> </div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-ctp-surface0"> )}
<div className="absolute inset-x-0 bottom-0 h-1 bg-ctp-surface2/60">
<div <div
className={cn( className={cn(
"h-full transition-[width] duration-150", "h-full transition-[width] duration-150",
@@ -868,29 +923,26 @@ export default function UploadPage() {
}} }}
/> />
</div> </div>
<div className="mt-1.5 text-xs text-ctp-overlay0"> </div>
<div className="px-2 py-1.5">
<p
className="truncate font-mono text-[10px] text-ctp-subtext1"
title={entry.file.name}
>
{entry.file.name}
</p>
<p className="truncate font-mono text-[10px] text-ctp-overlay0">
{entry.status === "queued" {entry.status === "queued"
? "Queued" ? "Queued"
: entry.status === "uploading" : entry.status === "uploading"
? `Uploading… ${entry.progress}%` ? `${entry.progress}%`
: entry.status === "done" : entry.status === "done"
? "Uploaded — processing" ? "Uploaded"
: (entry.error ?? "Failed")} : (entry.error ?? "Failed")}
</p>
</div> </div>
</div> </div>
))} ))}
<div className="flex items-center gap-3">
<Button
onClick={() => void startUploads()}
disabled={busy || pendingCount === 0}
>
{busy
? "Uploading…"
: `Upload ${pendingCount || ""} file${pendingCount === 1 ? "" : "s"}`.trim()}
</Button>
<Button variant="ghost" onClick={clearFinished} disabled={busy}>
Clear finished
</Button>
</div> </div>
</section> </section>
) : null} ) : null}