Unpaginate staged uploads and make big upload batches visible

The upload board partitions /api/uploads/ into Pending / Visual similarity /
Auto-uploaded, but the endpoint was paginated at 48 — a 69-file batch
silently lost 21 entries, and the similarity sweep (which reads the same
list back after uploading) only ever saw the first page. The staged-upload
list is now unpaginated: it is a transient per-user set, still limited to
the caller's rows and the uploader role. The page takes a plain array.

Watching progress with dozens of files was also poor:
- the queue uploads three files at a time instead of strictly one at a time;
- the Uploads section now shows a batch bar and 'n/m uploaded · x%' next to
  the count, so the overall progress never scrolls out of sight;
- entries are ordered active-first (uploading, queued, failed, done) so the
  file being uploaded is always at the top of the grid;
- tiles are larger (4 columns at lg instead of 5);
- the header reads 'Uploading n/m…' and 'Checking n file(s) against IQDB…'
  instead of a bare spinner.

Tests: staged-upload list unpaginated past 48, per-user, uploader-only
(3 new; full suite 49 green). Live-checked the bare-array response.
This commit is contained in:
2026-09-18 19:58:17 -05:00
parent 1adb761c8d
commit 39307cb141
3 changed files with 168 additions and 32 deletions
+90 -32
View File
@@ -7,6 +7,7 @@ import {
} from "lucide-react";
import {
useEffect,
useMemo,
useRef,
useState,
type DragEvent,
@@ -27,12 +28,7 @@ import {
RATING_LABELS,
} from "@/lib/e621";
import { formatBytes } from "@/lib/format";
import type {
E621IqdbCandidate,
Paginated,
Rating,
TempUpload,
} from "@/lib/types";
import type { E621IqdbCandidate, Rating, TempUpload } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { confirmAction } from "@/store/confirm";
import { useE621 } from "@/store/e621";
@@ -40,6 +36,17 @@ import { toast } from "@/store/toasts";
type UploadStatus = "queued" | "uploading" | "done" | "error";
/** How many files upload at once; big batches were painfully serial. */
const UPLOAD_CONCURRENCY = 3;
/** Display order: the file being uploaded stays at the top of the grid. */
const STATUS_ORDER: Record<UploadStatus, number> = {
uploading: 0,
queued: 1,
error: 2,
done: 3,
};
interface UploadEntry {
id: string;
file: File;
@@ -642,10 +649,11 @@ export default function UploadPage() {
const uploadsQuery = useQuery({
queryKey: ["uploads"],
queryFn: () => api<Paginated<TempUpload>>("/api/uploads/"),
// Unpaginated: the board partitions the complete staging list.
queryFn: () => api<TempUpload[]>("/api/uploads/"),
enabled: Boolean(user?.can_upload),
});
const uploads = uploadsQuery.data?.results ?? [];
const uploads = uploadsQuery.data ?? [];
const pending = uploads.filter((temp) => temp.status === "pending");
const visual = uploads.filter((temp) => temp.status === "visual_match");
@@ -653,6 +661,32 @@ export default function UploadPage() {
(temp) => temp.status === "completed" || temp.status === "error",
);
// Active work first so a long batch never hides the file in progress.
const orderedEntries = useMemo(
() =>
[...entries].sort(
(a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status],
),
[entries],
);
const uploadedCount = entries.filter((entry) => entry.status === "done").length;
const failedCount = entries.filter((entry) => entry.status === "error").length;
const batchPercent = entries.length
? Math.round(
entries.reduce(
(total, entry) =>
total +
(entry.status === "done"
? 100
: entry.status === "queued"
? 0
: entry.progress),
0,
) / entries.length,
)
: 0;
function addFiles(files: FileList | null) {
if (!files || files.length === 0) return;
const next: UploadEntry[] = Array.from(files).map((file) => {
@@ -691,9 +725,9 @@ export default function UploadPage() {
}
async function refreshUploads(): Promise<TempUpload[]> {
const fresh = await api<Paginated<TempUpload>>("/api/uploads/");
const fresh = await api<TempUpload[]>("/api/uploads/");
queryClient.setQueryData(["uploads"], fresh);
return fresh.results;
return fresh;
}
async function checkSimilarity(ids: string[]) {
@@ -780,27 +814,35 @@ export default function UploadPage() {
runningRef.current = true;
setBusy(true);
const created: TempUpload[] = [];
while (queueRef.current.length > 0) {
const entry = queueRef.current.shift();
if (!entry) break;
updateEntry(entry.id, {
status: "uploading",
progress: 0,
error: undefined,
});
try {
const temp = await uploadFile(entry.file, {}, (percent) =>
updateEntry(entry.id, { progress: percent }),
);
updateEntry(entry.id, { status: "done", progress: 100 });
created.push(temp);
} catch (error) {
async function worker() {
while (queueRef.current.length > 0) {
const entry = queueRef.current.shift();
if (!entry) return;
updateEntry(entry.id, {
status: "error",
error: errorMessage(error),
status: "uploading",
progress: 0,
error: undefined,
});
try {
const temp = await uploadFile(entry.file, {}, (percent) =>
updateEntry(entry.id, { progress: percent }),
);
updateEntry(entry.id, { status: "done", progress: 100 });
created.push(temp);
} catch (error) {
updateEntry(entry.id, {
status: "error",
error: errorMessage(error),
});
}
}
}
await Promise.all(
Array.from({ length: UPLOAD_CONCURRENCY }, () => worker()),
);
runningRef.current = false;
setBusy(false);
invalidateUploads();
@@ -899,7 +941,11 @@ export default function UploadPage() {
{busy || processing ? (
<p className="flex items-center gap-2 text-xs text-ctp-overlay0">
<Spinner className="h-3.5 w-3.5" />
{busy ? "Uploading…" : "Processing uploads…"}
{busy
? `Uploading ${Math.min(uploadedCount + 1, entries.length)}/${entries.length}…`
: checkingIds.length > 0
? `Checking ${checkingIds.length} file(s) against IQDB…`
: "Processing uploads…"}
</p>
) : null}
</header>
@@ -950,11 +996,12 @@ export default function UploadPage() {
{entries.length > 0 ? (
<section className="flex flex-col gap-2">
<header className="flex items-center justify-between gap-2">
<header className="flex flex-wrap 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}
{uploadedCount}/{entries.length} uploaded
{failedCount > 0 ? ` · ${failedCount} failed` : ""}
</span>
</h2>
<button
@@ -965,8 +1012,19 @@ export default function UploadPage() {
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 className="flex items-center gap-3">
<div className="h-1.5 min-w-32 flex-1 overflow-hidden rounded-full bg-ctp-surface0">
<div
className="h-full bg-ctp-mauve transition-[width]"
style={{ width: `${batchPercent}%` }}
/>
</div>
<span className="shrink-0 font-mono text-[11px] text-ctp-overlay0">
{batchPercent}%
</span>
</div>
<div className="grid max-h-[46rem] grid-cols-2 gap-3 overflow-y-auto pr-1 sm:grid-cols-3 lg:grid-cols-4">
{orderedEntries.map((entry) => (
<div
key={entry.id}
className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base"