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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user