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
@@ -0,0 +1,75 @@
"""The staged-upload list is a complete set, not a page.
The upload board partitions it into Pending / Visual similarity / Auto-uploaded
columns; with the standard 48-item pagination a 69-file batch silently lost 21
entries from the board (and from the similarity sweep that reads it back).
"""
import hashlib
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from rest_framework.authtoken.models import Token
from apps.library.models import TempUpload
User = get_user_model()
class TempUploadListTests(TestCase):
def setUp(self):
self.uploader = User.objects.create_user(
username="upload-user", password="upload-pass-123456"
)
self.uploader.role = "uploader"
self.uploader.save(update_fields=["role"])
self.other = User.objects.create_user(
username="upload-other", password="upload-pass-123456"
)
self.other.role = "uploader"
self.other.save(update_fields=["role"])
def api_client(self, user):
client = Client()
client.defaults["HTTP_AUTHORIZATION"] = (
f"Token {Token.objects.create(user=user).key}"
)
return client
def test_list_is_not_paginated_past_48(self):
TempUpload.objects.bulk_create(
[
TempUpload(
user=self.uploader,
original_filename=f"file_{index:03d}.png",
md5=hashlib.md5(f"file-{index}".encode()).hexdigest(),
size=index,
)
for index in range(69)
]
)
response = self.api_client(self.uploader).get("/api/uploads/")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIsInstance(data, list, "the list must not be paginated")
self.assertEqual(len(data), 69)
def test_list_only_contains_the_callers_uploads(self):
TempUpload.objects.create(
user=self.uploader,
original_filename="mine.png",
md5=hashlib.md5(b"mine").hexdigest(),
size=1,
)
TempUpload.objects.create(
user=self.other,
original_filename="theirs.png",
md5=hashlib.md5(b"theirs").hexdigest(),
size=1,
)
mine = self.api_client(self.uploader).get("/api/uploads/").json()
self.assertEqual([row["original_filename"] for row in mine], ["mine.png"])
def test_anonymous_cannot_list(self):
self.assertEqual(Client().get("/api/uploads/").status_code, 401)
+3
View File
@@ -150,6 +150,9 @@ class TempUploadViewSet(
serializer_class = TempUploadSerializer serializer_class = TempUploadSerializer
permission_classes = [CanUpload] permission_classes = [CanUpload]
parser_classes = [MultiPartParser, FormParser, JSONParser] parser_classes = [MultiPartParser, FormParser, JSONParser]
# Unpaginated: the board shows every staged upload (69-file batches were
# silently cut to the API's 48-item page).
pagination_class = None
http_method_names = ["get", "post", "delete", "head", "options"] http_method_names = ["get", "post", "delete", "head", "options"]
def get_queryset(self): def get_queryset(self):
+90 -32
View File
@@ -7,6 +7,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { import {
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
type DragEvent, type DragEvent,
@@ -27,12 +28,7 @@ import {
RATING_LABELS, RATING_LABELS,
} from "@/lib/e621"; } from "@/lib/e621";
import { formatBytes } from "@/lib/format"; import { formatBytes } from "@/lib/format";
import type { import type { E621IqdbCandidate, Rating, TempUpload } from "@/lib/types";
E621IqdbCandidate,
Paginated,
Rating,
TempUpload,
} from "@/lib/types";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
import { confirmAction } from "@/store/confirm"; import { confirmAction } from "@/store/confirm";
import { useE621 } from "@/store/e621"; import { useE621 } from "@/store/e621";
@@ -40,6 +36,17 @@ import { toast } from "@/store/toasts";
type UploadStatus = "queued" | "uploading" | "done" | "error"; 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 { interface UploadEntry {
id: string; id: string;
file: File; file: File;
@@ -642,10 +649,11 @@ export default function UploadPage() {
const uploadsQuery = useQuery({ const uploadsQuery = useQuery({
queryKey: ["uploads"], 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), enabled: Boolean(user?.can_upload),
}); });
const uploads = uploadsQuery.data?.results ?? []; const uploads = uploadsQuery.data ?? [];
const pending = uploads.filter((temp) => temp.status === "pending"); const pending = uploads.filter((temp) => temp.status === "pending");
const visual = uploads.filter((temp) => temp.status === "visual_match"); const visual = uploads.filter((temp) => temp.status === "visual_match");
@@ -653,6 +661,32 @@ export default function UploadPage() {
(temp) => temp.status === "completed" || temp.status === "error", (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) { 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) => {
@@ -691,9 +725,9 @@ export default function UploadPage() {
} }
async function refreshUploads(): Promise<TempUpload[]> { async function refreshUploads(): Promise<TempUpload[]> {
const fresh = await api<Paginated<TempUpload>>("/api/uploads/"); const fresh = await api<TempUpload[]>("/api/uploads/");
queryClient.setQueryData(["uploads"], fresh); queryClient.setQueryData(["uploads"], fresh);
return fresh.results; return fresh;
} }
async function checkSimilarity(ids: string[]) { async function checkSimilarity(ids: string[]) {
@@ -780,27 +814,35 @@ export default function UploadPage() {
runningRef.current = true; runningRef.current = true;
setBusy(true); setBusy(true);
const created: TempUpload[] = []; const created: TempUpload[] = [];
while (queueRef.current.length > 0) {
const entry = queueRef.current.shift(); async function worker() {
if (!entry) break; while (queueRef.current.length > 0) {
updateEntry(entry.id, { const entry = queueRef.current.shift();
status: "uploading", if (!entry) return;
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, { updateEntry(entry.id, {
status: "error", status: "uploading",
error: errorMessage(error), 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; runningRef.current = false;
setBusy(false); setBusy(false);
invalidateUploads(); invalidateUploads();
@@ -899,7 +941,11 @@ export default function UploadPage() {
{busy || 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" /> <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> </p>
) : null} ) : null}
</header> </header>
@@ -950,11 +996,12 @@ export default function UploadPage() {
{entries.length > 0 ? ( {entries.length > 0 ? (
<section className="flex flex-col gap-2"> <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"> <h2 className="text-sm font-semibold text-ctp-subtext1">
Uploads Uploads
<span className="ml-2 font-mono text-[11px] text-ctp-overlay0"> <span className="ml-2 font-mono text-[11px] text-ctp-overlay0">
{entries.length} {uploadedCount}/{entries.length} uploaded
{failedCount > 0 ? ` · ${failedCount} failed` : ""}
</span> </span>
</h2> </h2>
<button <button
@@ -965,8 +1012,19 @@ export default function UploadPage() {
clear finished clear finished
</button> </button>
</header> </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"> <div className="flex items-center gap-3">
{entries.map((entry) => ( <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 <div
key={entry.id} key={entry.id}
className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base" className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base"