Scrollable upload grid and bulk rating for the pending backlog

Upload board:
- the tile grid no longer re-sorts itself as files finish (that reshuffled
  the list under the cursor); it keeps insertion order, uses auto-fill tiles
  of ~150px so they hold a readable size, scrolls inside a 60vh area and no
  longer chains the page scroll (overscroll-contain);
- the files currently in flight are pinned in a small live strip above the
  grid (name, percent, bar) so progress stays visible while the grid is
  scrolled with hundreds of tiles.

Bulk rating: a 'bulk rate' button in the Pending & Unmatched header opens a
large modal with Safe/Questionable/Explicit pills, a tickable thumbnail grid
(Select all / Clear) and one confirm that moves every selected upload into
the library with that rating. Backed by POST /api/uploads/resolve-bulk/
(temp_ids + rating, own rows only): each staged file is resolved as a custom
entry (keeps its staged tags/notes), and already-completed or foreign ids are
reported per entry instead of failing the whole batch. Built for the
358-file backlog.

Tests: 4 bulk-resolve tests (resolution with the rating, input validation,
foreign ids untouched, mixed completed+pending) — full backend suite 53
green. Verified live end to end: staged a file, bulk-resolved it as 'q', saw
J-96 created with that rating, then removed the item, temp row and test
token.
This commit is contained in:
2026-09-18 21:50:22 -05:00
parent 39307cb141
commit a761def65e
4 changed files with 472 additions and 27 deletions
+130 -8
View File
@@ -1,21 +1,31 @@
"""The staged-upload list is a complete set, not a page. """Staged uploads: complete-set listing and the bulk rating tool."""
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 base64
import hashlib import hashlib
import json
import shutil
import tempfile
from pathlib import Path
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import Client, TestCase from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase, override_settings
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from apps.library.models import TempUpload from apps.library.models import MediaItem, TempUpload
User = get_user_model() User = get_user_model()
# 1x1 transparent PNG so indexing/hashing has a real image to chew on.
TINY_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
def jpost(client, path, body=None):
return client.post(path, data=json.dumps(body or {}), content_type="application/json")
class TempUploadListTests(TestCase): class TempUploadListTests(TestCase):
def setUp(self): def setUp(self):
@@ -73,3 +83,115 @@ class TempUploadListTests(TestCase):
def test_anonymous_cannot_list(self): def test_anonymous_cannot_list(self):
self.assertEqual(Client().get("/api/uploads/").status_code, 401) self.assertEqual(Client().get("/api/uploads/").status_code, 401)
class BulkResolveTests(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._tmp = tempfile.mkdtemp(prefix="j621-bulk-")
cls._watched = Path(cls._tmp) / "library"
cls._watched.mkdir(parents=True, exist_ok=True)
cls._settings = override_settings(
MEDIA_ROOT=cls._tmp, WATCHED_FOLDER=str(cls._watched)
)
cls._settings.enable()
@classmethod
def tearDownClass(cls):
cls._settings.disable()
shutil.rmtree(cls._tmp, ignore_errors=True)
super().tearDownClass()
def setUp(self):
self.uploader = User.objects.create_user(
username="bulk-uploader", password="bulk-pass-123456"
)
self.uploader.role = "uploader"
self.uploader.save(update_fields=["role"])
self.other = User.objects.create_user(
username="bulk-other", password="bulk-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 make_temp(self, user, label, status=TempUpload.STATUS_PENDING):
return TempUpload.objects.create(
user=user,
file=SimpleUploadedFile(f"{label}.png", TINY_PNG, content_type="image/png"),
original_filename=f"{label}.png",
md5=hashlib.md5(label.encode()).hexdigest(),
size=len(TINY_PNG),
status=status,
)
def resolve_bulk(self, client, ids, rating):
return jpost(
client,
"/api/uploads/resolve-bulk/",
{"temp_ids": [str(value) for value in ids], "rating": rating},
)
def test_resolves_selected_uploads_with_the_rating(self):
client = self.api_client(self.uploader)
first = self.make_temp(self.uploader, "one")
second = self.make_temp(self.uploader, "two")
untouched = self.make_temp(self.uploader, "three")
response = self.resolve_bulk(client, [first.id, second.id], "q")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(len(body["resolved"]), 2)
self.assertEqual(body["errors"], [])
for temp in (first, second):
temp.refresh_from_db()
self.assertEqual(temp.status, TempUpload.STATUS_COMPLETED)
self.assertIsNotNone(temp.library_item_id)
self.assertEqual(temp.library_item.rating, "q")
self.assertEqual(temp.library_item.uploaded_by_id, self.uploader.id)
untouched.refresh_from_db()
self.assertEqual(untouched.status, TempUpload.STATUS_PENDING)
def test_rejects_bad_input(self):
client = self.api_client(self.uploader)
temp = self.make_temp(self.uploader, "input")
self.assertEqual(self.resolve_bulk(client, [], "s").status_code, 400)
self.assertEqual(self.resolve_bulk(client, [temp.id], "").status_code, 400)
self.assertEqual(self.resolve_bulk(client, [temp.id], "x").status_code, 400)
self.assertEqual(
jpost(
client,
"/api/uploads/resolve-bulk/",
{"temp_ids": ["not-a-uuid"], "rating": "s"},
).status_code,
400,
)
def test_other_users_uploads_are_left_alone(self):
client = self.api_client(self.uploader)
theirs = self.make_temp(self.other, "theirs")
response = self.resolve_bulk(client, [theirs.id], "s")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["resolved"], [])
self.assertEqual(body["errors"][0]["error"], "not found")
theirs.refresh_from_db()
self.assertEqual(theirs.status, TempUpload.STATUS_PENDING)
def test_completed_uploads_report_an_error_but_others_resolve(self):
client = self.api_client(self.uploader)
done = self.make_temp(self.uploader, "done", status=TempUpload.STATUS_COMPLETED)
pending = self.make_temp(self.uploader, "pending")
response = self.resolve_bulk(client, [done.id, pending.id], "e")
body = response.json()
self.assertEqual(len(body["resolved"]), 1)
self.assertEqual(body["errors"][0]["error"], "already in the library")
self.assertEqual(MediaItem.objects.count(), 1)
+59
View File
@@ -17,6 +17,7 @@ from urllib.parse import urlparse
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core import signing from django.core import signing
from django.core.exceptions import ValidationError
from django.http import Http404 from django.http import Http404
from django.utils import timezone from django.utils import timezone
from rest_framework import mixins, status, viewsets from rest_framework import mixins, status, viewsets
@@ -328,6 +329,64 @@ class TempUploadViewSet(
temp.refresh_from_db() temp.refresh_from_db()
return Response(self.get_serializer(temp).data) return Response(self.get_serializer(temp).data)
@action(detail=False, methods=["post"], url_path="resolve-bulk")
def resolve_bulk(self, request):
"""Move many staged uploads into the library with one rating.
The upload board's bulk tool: pick a rating, tick pending files, and
they are all resolved as custom entries (their staged tags/notes are
kept). Own rows only, like the rest of the viewset.
"""
ids = request.data.get("temp_ids")
if not isinstance(ids, list) or not ids:
return Response(
{"detail": "temp_ids must be a non-empty list."},
status=status.HTTP_400_BAD_REQUEST,
)
rating = str(request.data.get("rating") or "").strip()
if rating not in {"s", "q", "e"}:
return Response(
{"detail": "rating must be one of s, q or e."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
queryset = self.get_queryset().filter(
pk__in=[str(value) for value in ids]
)
except (ValidationError, ValueError):
return Response(
{"detail": "One or more ids are not valid upload ids."},
status=status.HTTP_400_BAD_REQUEST,
)
resolved: list[str] = []
errors: list[dict[str, str]] = []
for temp in queryset:
if temp.status == TempUpload.STATUS_COMPLETED:
errors.append(
{"temp_id": str(temp.id), "error": "already in the library"}
)
continue
temp.custom_rating = rating
temp.resolution = TempUpload.RESOLUTION_CUSTOM
temp.save(update_fields=["custom_rating", "resolution", "updated_at"])
try:
complete_temp_upload(temp)
except Exception as exc: # noqa: BLE001 - report per-file failures
logger.exception("Could not complete staged upload %s", temp.id)
temp.status = TempUpload.STATUS_ERROR
temp.save(update_fields=["status", "updated_at"])
errors.append({"temp_id": str(temp.id), "error": str(exc)})
continue
resolved.append(str(temp.id))
found = {str(temp.id) for temp in queryset}
for value in sorted({str(value) for value in ids} - found):
errors.append({"temp_id": value, "error": "not found"})
return Response({"resolved": resolved, "errors": errors})
def perform_destroy(self, instance): def perform_destroy(self, instance):
if instance.file: if instance.file:
instance.file.delete(save=False) instance.file.delete(save=False)
@@ -0,0 +1,228 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Check, Video, X } from "lucide-react";
import { useState } from "react";
import { Button, Spinner } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import type { TempUpload } from "@/lib/types";
import { toast } from "@/store/toasts";
const RATING_OPTIONS = [
{
value: "s",
label: "Safe",
active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green",
},
{
value: "q",
label: "Questionable",
active: "border-ctp-peach/40 bg-ctp-peach/15 text-ctp-peach",
},
{
value: "e",
label: "Explicit",
active: "border-ctp-red/40 bg-ctp-red/15 text-ctp-red",
},
];
interface BulkResult {
resolved: string[];
errors: { temp_id: string; error: string }[];
}
/**
* Bulk-rating tool for the Pending & Unmatched column: pick a rating, tick
* every thumbnail that belongs to it, and they all move into the library as
* custom entries in one request.
*/
export function BulkRateModal({
uploads,
onClose,
}: {
uploads: TempUpload[];
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [rating, setRating] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const mutation = useMutation({
mutationFn: (payload: { temp_ids: string[]; rating: string }) =>
api<BulkResult>("/api/uploads/resolve-bulk/", {
method: "POST",
json: payload,
}),
onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: ["uploads"] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
if (result.resolved.length > 0) {
toast.ok(`Moved ${result.resolved.length} upload(s) into the library.`);
}
if (result.errors.length > 0) {
toast.error(
`${result.errors.length} could not be resolved: ${result.errors[0].error}`,
);
}
onClose();
},
onError: (error) => toast.error(errorMessage(error)),
});
function toggle(id: string) {
setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<div
className="fixed inset-0 z-[70] flex items-start justify-center overflow-y-auto bg-ctp-crust/70 px-4 py-8 backdrop-blur-sm"
onClick={onClose}
>
<div
className="flex w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-ctp-surface1 bg-ctp-base"
onClick={(event) => event.stopPropagation()}
>
<header className="flex items-start justify-between gap-3 border-b border-ctp-surface0 px-4 py-3">
<div>
<h2 className="text-sm font-semibold text-ctp-text">
Rate unmatched uploads
</h2>
<p className="mt-0.5 text-xs text-ctp-overlay0">
Pick a rating, tick every file that belongs to it, and they move
into the library in one go ({uploads.length} pending).
</p>
</div>
<button
type="button"
onClick={onClose}
title="Close"
className="rounded-md p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
<X className="h-4 w-4" />
</button>
</header>
<div className="flex flex-wrap items-center gap-2 border-b border-ctp-surface0 px-4 py-2.5">
{RATING_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRating(option.value)}
className={cn(
"rounded-full border px-3 py-1 text-xs font-medium transition",
rating === option.value
? option.active
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
)}
>
{option.label}
</button>
))}
<span className="ml-auto flex items-center gap-2">
<Button
variant="secondary"
className="px-2 py-1 text-xs"
onClick={() =>
setSelected(new Set(uploads.map((temp) => temp.temp_id)))
}
>
Select all
</Button>
<Button
variant="ghost"
className="px-2 py-1 text-xs"
disabled={selected.size === 0}
onClick={() => setSelected(new Set())}
>
Clear
</Button>
</span>
</div>
<div className="max-h-[60vh] overflow-y-auto overscroll-contain p-4">
{uploads.length === 0 ? (
<p className="py-8 text-center text-sm text-ctp-overlay0">
Nothing pending.
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(130px,1fr))] content-start gap-2">
{uploads.map((temp) => {
const isSelected = selected.has(temp.temp_id);
const preview = temp.preview_url ?? temp.file_url;
return (
<button
key={temp.temp_id}
type="button"
onClick={() => toggle(temp.temp_id)}
title={temp.original_filename}
className={cn(
"relative overflow-hidden rounded-md border bg-ctp-mantle text-left transition",
isSelected
? "border-ctp-mauve ring-2 ring-ctp-mauve/40"
: "border-ctp-surface0 hover:border-ctp-surface2",
)}
>
<span className="block aspect-square overflow-hidden">
{preview ? (
<img
src={preview}
alt={temp.original_filename}
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center">
<Video className="h-5 w-5 text-ctp-overlay0" />
</span>
)}
</span>
{isSelected ? (
<span className="absolute left-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-ctp-mauve text-ctp-crust">
<Check className="h-3 w-3" strokeWidth={3} />
</span>
) : null}
<span className="block truncate px-1 py-0.5 font-mono text-[10px] text-ctp-overlay0">
{temp.original_filename}
</span>
</button>
);
})}
</div>
)}
</div>
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-ctp-surface0 px-4 py-3">
<span className="font-mono text-[11px] text-ctp-overlay0">
{selected.size} selected
{rating ? "" : " · pick a rating"}
</span>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
disabled={selected.size === 0 || !rating || mutation.isPending}
onClick={() => {
if (!rating) return;
mutation.mutate({ temp_ids: [...selected], rating });
}}
>
{mutation.isPending ? (
<>
<Spinner className="h-3.5 w-3.5" /> Moving…
</>
) : (
`Move ${selected.size} into the library`
)}
</Button>
</div>
</footer>
</div>
</div>
);
}
+55 -19
View File
@@ -1,13 +1,13 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
CheckCircle2, CheckCircle2,
ListChecks,
UploadCloud, UploadCloud,
Video, Video,
X, X,
} from "lucide-react"; } from "lucide-react";
import { import {
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
type DragEvent, type DragEvent,
@@ -16,6 +16,7 @@ import {
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";
import { BulkRateModal } from "@/features/upload/BulkRateModal";
import { api, authHeaders, apiUrl, errorMessage, uploadFile } from "@/lib/api"; import { api, authHeaders, apiUrl, errorMessage, uploadFile } from "@/lib/api";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { import {
@@ -39,14 +40,6 @@ type UploadStatus = "queued" | "uploading" | "done" | "error";
/** How many files upload at once; big batches were painfully serial. */ /** How many files upload at once; big batches were painfully serial. */
const UPLOAD_CONCURRENCY = 3; 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;
@@ -639,6 +632,7 @@ export default function UploadPage() {
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [checkingIds, setCheckingIds] = useState<string[]>([]); const [checkingIds, setCheckingIds] = useState<string[]>([]);
const [modalFor, setModalFor] = useState<TempUpload | null>(null); const [modalFor, setModalFor] = useState<TempUpload | null>(null);
const [bulkOpen, setBulkOpen] = useState(false);
useEffect(() => { useEffect(() => {
const urls = objectUrls.current; const urls = objectUrls.current;
@@ -661,13 +655,10 @@ 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. // Active work stays in insertion order (the grid must not reshuffle while
const orderedEntries = useMemo( // you scroll it); the in-flight files get their own strip above it.
() => const uploadingEntries = entries.filter(
[...entries].sort( (entry) => entry.status === "uploading",
(a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status],
),
[entries],
); );
const uploadedCount = entries.filter((entry) => entry.status === "done").length; const uploadedCount = entries.filter((entry) => entry.status === "done").length;
@@ -1023,8 +1014,34 @@ export default function UploadPage() {
{batchPercent}% {batchPercent}%
</span> </span>
</div> </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"> {uploadingEntries.length > 0 ? (
{orderedEntries.map((entry) => ( <div className="flex flex-col gap-1.5 rounded-lg border border-ctp-surface0 bg-ctp-base p-2">
{uploadingEntries.map((entry) => (
<div
key={entry.id}
className="flex items-center gap-2 text-[11px]"
>
<span
className="w-40 shrink-0 truncate font-mono text-ctp-subtext1 sm:w-56"
title={entry.file.name}
>
{entry.file.name}
</span>
<div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-ctp-surface0">
<div
className="h-full bg-ctp-mauve transition-[width] duration-150"
style={{ width: `${entry.progress}%` }}
/>
</div>
<span className="w-9 shrink-0 text-right font-mono text-ctp-overlay0">
{entry.progress}%
</span>
</div>
))}
</div>
) : null}
<div className="grid max-h-[60vh] grid-cols-[repeat(auto-fill,minmax(150px,1fr))] content-start gap-3 overflow-y-auto overscroll-contain pr-1">
{entries.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"
@@ -1081,7 +1098,22 @@ export default function UploadPage() {
) : null} ) : null}
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
{column("Pending & Unmatched", pending, "Nothing waiting.")} {column(
"Pending & Unmatched",
pending,
"Nothing waiting.",
pending.length > 0 ? (
<Button
variant="secondary"
className="px-2 py-0.5 text-[11px]"
onClick={() => setBulkOpen(true)}
title="Pick a rating and move a bunch of pending uploads into the library"
>
<ListChecks className="h-3.5 w-3.5" />
bulk rate
</Button>
) : null,
)}
{column( {column(
"Visual Similarity Detected", "Visual Similarity Detected",
visual, visual,
@@ -1107,6 +1139,10 @@ export default function UploadPage() {
{modalFor ? ( {modalFor ? (
<MetadataModal temp={modalFor} onClose={() => setModalFor(null)} /> <MetadataModal temp={modalFor} onClose={() => setModalFor(null)} />
) : null} ) : null}
{bulkOpen ? (
<BulkRateModal uploads={pending} onClose={() => setBulkOpen(false)} />
) : null}
</div> </div>
); );
} }