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 {
|
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 & drop files here
|
Drag & 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}
|
||||||
|
|||||||
Reference in New Issue
Block a user