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
@@ -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 {
CheckCircle2,
ListChecks,
UploadCloud,
Video,
X,
} from "lucide-react";
import {
useEffect,
useMemo,
useRef,
useState,
type DragEvent,
@@ -16,6 +16,7 @@ import {
import { Link } from "react-router-dom";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { BulkRateModal } from "@/features/upload/BulkRateModal";
import { api, authHeaders, apiUrl, errorMessage, uploadFile } from "@/lib/api";
import { cn } from "@/lib/cn";
import {
@@ -39,14 +40,6 @@ 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;
@@ -639,6 +632,7 @@ export default function UploadPage() {
const [processing, setProcessing] = useState(false);
const [checkingIds, setCheckingIds] = useState<string[]>([]);
const [modalFor, setModalFor] = useState<TempUpload | null>(null);
const [bulkOpen, setBulkOpen] = useState(false);
useEffect(() => {
const urls = objectUrls.current;
@@ -661,13 +655,10 @@ 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],
// Active work stays in insertion order (the grid must not reshuffle while
// you scroll it); the in-flight files get their own strip above it.
const uploadingEntries = entries.filter(
(entry) => entry.status === "uploading",
);
const uploadedCount = entries.filter((entry) => entry.status === "done").length;
@@ -1023,8 +1014,34 @@ export default function UploadPage() {
{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) => (
{uploadingEntries.length > 0 ? (
<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
key={entry.id}
className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base"
@@ -1081,7 +1098,22 @@ export default function UploadPage() {
) : null}
<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(
"Visual Similarity Detected",
visual,
@@ -1107,6 +1139,10 @@ export default function UploadPage() {
{modalFor ? (
<MetadataModal temp={modalFor} onClose={() => setModalFor(null)} />
) : null}
{bulkOpen ? (
<BulkRateModal uploads={pending} onClose={() => setBulkOpen(false)} />
) : null}
</div>
);
}