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:
@@ -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) => ({
|
||||
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,10 +726,14 @@ export default function UploadPage() {
|
||||
});
|
||||
}
|
||||
}
|
||||
runningRef.current = false;
|
||||
setBusy(false);
|
||||
invalidateUploads();
|
||||
if (created.length > 0) {
|
||||
setProcessing(true);
|
||||
void autoMatchAndCheck(created).finally(() => setProcessing(false));
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss(temp: TempUpload) {
|
||||
void api(`/api/uploads/${temp.temp_id}/`, { method: "DELETE" }).then(() =>
|
||||
@@ -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 & 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,20 +875,40 @@ export default function UploadPage() {
|
||||
|
||||
{entries.length > 0 ? (
|
||||
<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) => (
|
||||
<div
|
||||
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">
|
||||
<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 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>
|
||||
<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
|
||||
className={cn(
|
||||
"h-full transition-[width] duration-150",
|
||||
@@ -868,29 +923,26 @@ export default function UploadPage() {
|
||||
}}
|
||||
/>
|
||||
</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"
|
||||
? "Queued"
|
||||
: entry.status === "uploading"
|
||||
? `Uploading… ${entry.progress}%`
|
||||
? `${entry.progress}%`
|
||||
: entry.status === "done"
|
||||
? "Uploaded — processing"
|
||||
? "Uploaded"
|
||||
: (entry.error ?? "Failed")}
|
||||
</p>
|
||||
</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}
|
||||
|
||||
Reference in New Issue
Block a user