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
+125 -73
View File
@@ -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<HTMLInputElement>(null);
const queueRef = useRef<UploadEntry[]>([]);
const runningRef = useRef(false);
const objectUrls = useRef<string[]>([]);
const [dragging, setDragging] = useState(false);
const [entries, setEntries] = useState<UploadEntry[]>([]);
const [busy, setBusy] = useState(false);
@@ -547,6 +558,13 @@ export default function UploadPage() {
const [checkingIds, setCheckingIds] = useState<string[]>([]);
const [modalFor, setModalFor] = useState<TempUpload | null>(null);
useEffect(() => {
const urls = objectUrls.current;
return () => {
for (const url of urls) URL.revokeObjectURL(url);
};
}, []);
const uploadsQuery = useQuery({
queryKey: ["uploads"],
queryFn: () => api<Paginated<TempUpload>>("/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<HTMLDivElement>) {
@@ -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.
</p>
</div>
{processing ? (
{busy || processing ? (
<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>
) : null}
</header>
@@ -824,7 +856,10 @@ export default function UploadPage() {
Drag &amp; drop files here
</p>
<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>
<input
ref={inputRef}
@@ -840,57 +875,74 @@ export default function UploadPage() {
{entries.length > 0 ? (
<section className="flex flex-col gap-2">
{entries.map((entry) => (
<div
key={entry.id}
className="rounded-lg border border-ctp-surface0 bg-ctp-base p-3"
<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"
>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="truncate font-mono text-xs text-ctp-subtext1">
{entry.file.name}
</span>
<span className="shrink-0 font-mono text-xs text-ctp-overlay0">
{formatBytes(entry.file.size)}
</span>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-ctp-surface0">
<div
className={cn(
"h-full transition-[width] duration-150",
entry.status === "error"
? "bg-ctp-red"
: entry.status === "done"
? "bg-ctp-green"
: "bg-ctp-mauve",
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) => (
<div
key={entry.id}
className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base"
>
<div className="relative aspect-square overflow-hidden bg-ctp-mantle">
{entry.previewUrl ? (
<img
src={entry.previewUrl}
alt={entry.file.name}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Video className="h-5 w-5 text-ctp-overlay0" />
</div>
)}
style={{
width: `${entry.status === "done" ? 100 : entry.progress}%`,
}}
/>
<div className="absolute inset-x-0 bottom-0 h-1 bg-ctp-surface2/60">
<div
className={cn(
"h-full transition-[width] duration-150",
entry.status === "error"
? "bg-ctp-red"
: entry.status === "done"
? "bg-ctp-green"
: "bg-ctp-mauve",
)}
style={{
width: `${entry.status === "done" ? 100 : entry.progress}%`,
}}
/>
</div>
</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"
? "Queued"
: entry.status === "uploading"
? `${entry.progress}%`
: entry.status === "done"
? "Uploaded"
: (entry.error ?? "Failed")}
</p>
</div>
</div>
<div className="mt-1.5 text-xs text-ctp-overlay0">
{entry.status === "queued"
? "Queued"
: entry.status === "uploading"
? `Uploading… ${entry.progress}%`
: entry.status === "done"
? "Uploaded — processing"
: (entry.error ?? "Failed")}
</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>
</section>
) : null}