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 { 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) => {
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random() const isVideo = /\.(mp4|webm)$/i.test(file.name);
.toString(36) const previewUrl = isVideo ? "" : URL.createObjectURL(file);
.slice(2, 8)}`, if (previewUrl) objectUrls.current.push(previewUrl);
file, return {
progress: 0, id: `${file.name}-${file.size}-${file.lastModified}-${Math.random()
status: "queued", .toString(36)
})); .slice(2, 8)}`,
file,
progress: 0,
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,9 +726,13 @@ export default function UploadPage() {
}); });
} }
} }
runningRef.current = false;
setBusy(false); setBusy(false);
invalidateUploads(); invalidateUploads();
void autoMatchAndCheck(created).finally(() => setProcessing(false)); if (created.length > 0) {
setProcessing(true);
void autoMatchAndCheck(created).finally(() => setProcessing(false));
}
} }
function dismiss(temp: TempUpload) { function dismiss(temp: TempUpload) {
@@ -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,57 +875,74 @@ export default function UploadPage() {
{entries.length > 0 ? ( {entries.length > 0 ? (
<section className="flex flex-col gap-2"> <section className="flex flex-col gap-2">
{entries.map((entry) => ( <header className="flex items-center justify-between gap-2">
<div <h2 className="text-sm font-semibold text-ctp-subtext1">
key={entry.id} Uploads
className="rounded-lg border border-ctp-surface0 bg-ctp-base p-3" <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"> clear finished
<span className="truncate font-mono text-xs text-ctp-subtext1"> </button>
{entry.file.name} </header>
</span> <div className="grid max-h-[46rem] grid-cols-2 gap-3 overflow-y-auto pr-1 sm:grid-cols-3 lg:grid-cols-5">
<span className="shrink-0 font-mono text-xs text-ctp-overlay0"> {entries.map((entry) => (
{formatBytes(entry.file.size)} <div
</span> key={entry.id}
</div> className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base"
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-ctp-surface0"> >
<div <div className="relative aspect-square overflow-hidden bg-ctp-mantle">
className={cn( {entry.previewUrl ? (
"h-full transition-[width] duration-150", <img
entry.status === "error" src={entry.previewUrl}
? "bg-ctp-red" alt={entry.file.name}
: entry.status === "done" className="h-full w-full object-cover"
? "bg-ctp-green" />
: "bg-ctp-mauve", ) : (
<div className="flex h-full w-full items-center justify-center">
<Video className="h-5 w-5 text-ctp-overlay0" />
</div>
)} )}
style={{ <div className="absolute inset-x-0 bottom-0 h-1 bg-ctp-surface2/60">
width: `${entry.status === "done" ? 100 : entry.progress}%`, <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>
<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> </div>
</section> </section>
) : null} ) : null}