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