Show live progress while the bulk rating tool moves files

A 200-file bulk move is one long server-side copy+index chain per file, so
the old single request sat on a spinner the whole time (and got uncomfortably
close to the 120s proxy timeout). The modal now resolves the selection in
chunks of 8:

- footer switches to a progress bar with 'n moved / done / total / percent'
  while running, and the header explains that files are being indexed;
- the rating pills, selection actions, grid and close button are locked
  while it runs so progress can't be lost by accident;
- failures are collected with their filenames, the modal stays open for a
  summary, and 'Retry failed' re-selects only the files that are still
  pending; a clean run still auto-closes with a toast.
This commit is contained in:
2026-09-18 23:09:42 -05:00
parent a761def65e
commit b076903ecd
+177 -42
View File
@@ -1,4 +1,4 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Check, Video, X } from "lucide-react"; import { Check, Video, X } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@@ -26,15 +26,31 @@ const RATING_OPTIONS = [
}, },
]; ];
/**
* How many files one request resolves. Copying and hashing is serial per
* file on the server, so a 200-file batch in one request risks the proxy
* timeout and gives no feedback; small chunks keep every request short and
* let the bar advance as they land.
*/
const CHUNK_SIZE = 8;
interface BulkResult { interface BulkResult {
resolved: string[]; resolved: string[];
errors: { temp_id: string; error: string }[]; errors: { temp_id: string; error: string }[];
} }
interface Failure {
id: string;
name: string;
error: string;
}
type Phase = "select" | "running" | "done";
/** /**
* Bulk-rating tool for the Pending & Unmatched column: pick a rating, tick * 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 * every thumbnail that belongs to it, and they all move into the library,
* custom entries in one request. * reporting progress chunk by chunk.
*/ */
export function BulkRateModal({ export function BulkRateModal({
uploads, uploads,
@@ -46,28 +62,73 @@ export function BulkRateModal({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [rating, setRating] = useState<string | null>(null); const [rating, setRating] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [phase, setPhase] = useState<Phase>("select");
const [progress, setProgress] = useState({ done: 0, total: 0, moved: 0 });
const [failures, setFailures] = useState<Failure[]>([]);
const mutation = useMutation({ const names = new Map(uploads.map((temp) => [temp.temp_id, temp.original_filename]));
mutationFn: (payload: { temp_ids: string[]; rating: string }) => const busy = phase === "running";
api<BulkResult>("/api/uploads/resolve-bulk/", { const percent =
progress.total > 0
? Math.min(100, Math.round((progress.done / progress.total) * 100))
: 0;
const retryable = failures.filter((failure) => names.has(failure.id));
async function run() {
if (!rating || selected.size === 0 || busy) return;
const ids = [...selected];
setPhase("running");
setFailures([]);
setProgress({ done: 0, total: ids.length, moved: 0 });
let moved = 0;
const problems: Failure[] = [];
for (let index = 0; index < ids.length; index += CHUNK_SIZE) {
const chunk = ids.slice(index, index + CHUNK_SIZE);
try {
const result = await api<BulkResult>("/api/uploads/resolve-bulk/", {
method: "POST", method: "POST",
json: payload, json: { temp_ids: chunk, rating },
}), });
onSuccess: (result) => { moved += result.resolved.length;
for (const problem of result.errors) {
problems.push({
id: problem.temp_id,
name: names.get(problem.temp_id) ?? problem.temp_id,
error: problem.error,
});
}
} catch (error) {
for (const id of chunk) {
problems.push({
id,
name: names.get(id) ?? id,
error: errorMessage(error),
});
}
}
setProgress({
done: Math.min(index + CHUNK_SIZE, ids.length),
total: ids.length,
moved,
});
}
void queryClient.invalidateQueries({ queryKey: ["uploads"] }); void queryClient.invalidateQueries({ queryKey: ["uploads"] });
void queryClient.invalidateQueries({ queryKey: ["files"] }); void queryClient.invalidateQueries({ queryKey: ["files"] });
if (result.resolved.length > 0) { setFailures(problems);
toast.ok(`Moved ${result.resolved.length} upload(s) into the library.`); setPhase("done");
}
if (result.errors.length > 0) { if (problems.length === 0) {
toast.error( toast.ok(`Moved ${moved} upload(s) into the library.`);
`${result.errors.length} could not be resolved: ${result.errors[0].error}`,
);
}
onClose(); onClose();
}, } else if (moved > 0) {
onError: (error) => toast.error(errorMessage(error)), toast.error(`Moved ${moved}, ${problems.length} failed.`);
}); } else {
toast.error(`Nothing could be moved (${problems.length} failed).`);
}
}
function toggle(id: string) { function toggle(id: string) {
setSelected((current) => { setSelected((current) => {
@@ -78,10 +139,20 @@ export function BulkRateModal({
}); });
} }
function requestClose() {
if (!busy) onClose();
}
function retryFailed() {
setSelected(new Set(retryable.map((failure) => failure.id)));
setFailures([]);
setPhase("select");
}
return ( return (
<div <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" 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} onClick={requestClose}
> >
<div <div
className="flex w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-ctp-surface1 bg-ctp-base" className="flex w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-ctp-surface1 bg-ctp-base"
@@ -93,15 +164,19 @@ export function BulkRateModal({
Rate unmatched uploads Rate unmatched uploads
</h2> </h2>
<p className="mt-0.5 text-xs text-ctp-overlay0"> <p className="mt-0.5 text-xs text-ctp-overlay0">
Pick a rating, tick every file that belongs to it, and they move {busy
into the library in one go ({uploads.length} pending). ? "Moving files into the library — this can take a moment, keep this open."
: "Pick a rating, tick every file that belongs to it, and they move into the library in one go (" +
uploads.length +
" pending)."}
</p> </p>
</div> </div>
<button <button
type="button" type="button"
onClick={onClose} onClick={requestClose}
title="Close" disabled={busy}
className="rounded-md p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text" title={busy ? "Wait for the move to finish" : "Close"}
className="rounded-md p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text disabled:pointer-events-none disabled:opacity-40"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
@@ -112,9 +187,10 @@ export function BulkRateModal({
<button <button
key={option.value} key={option.value}
type="button" type="button"
disabled={busy}
onClick={() => setRating(option.value)} onClick={() => setRating(option.value)}
className={cn( className={cn(
"rounded-full border px-3 py-1 text-xs font-medium transition", "rounded-full border px-3 py-1 text-xs font-medium transition disabled:pointer-events-none disabled:opacity-40",
rating === option.value rating === option.value
? option.active ? option.active
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text", : "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
@@ -127,6 +203,7 @@ export function BulkRateModal({
<Button <Button
variant="secondary" variant="secondary"
className="px-2 py-1 text-xs" className="px-2 py-1 text-xs"
disabled={busy}
onClick={() => onClick={() =>
setSelected(new Set(uploads.map((temp) => temp.temp_id))) setSelected(new Set(uploads.map((temp) => temp.temp_id)))
} }
@@ -136,7 +213,7 @@ export function BulkRateModal({
<Button <Button
variant="ghost" variant="ghost"
className="px-2 py-1 text-xs" className="px-2 py-1 text-xs"
disabled={selected.size === 0} disabled={busy || selected.size === 0}
onClick={() => setSelected(new Set())} onClick={() => setSelected(new Set())}
> >
Clear Clear
@@ -150,7 +227,12 @@ export function BulkRateModal({
Nothing pending. Nothing pending.
</p> </p>
) : ( ) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(130px,1fr))] content-start gap-2"> <div
className={cn(
"grid grid-cols-[repeat(auto-fill,minmax(130px,1fr))] content-start gap-2 transition",
busy && "pointer-events-none opacity-60",
)}
>
{uploads.map((temp) => { {uploads.map((temp) => {
const isSelected = selected.has(temp.temp_id); const isSelected = selected.has(temp.temp_id);
const preview = temp.preview_url ?? temp.file_url; const preview = temp.preview_url ?? temp.file_url;
@@ -158,10 +240,11 @@ export function BulkRateModal({
<button <button
key={temp.temp_id} key={temp.temp_id}
type="button" type="button"
disabled={phase !== "select"}
onClick={() => toggle(temp.temp_id)} onClick={() => toggle(temp.temp_id)}
title={temp.original_filename} title={temp.original_filename}
className={cn( className={cn(
"relative overflow-hidden rounded-md border bg-ctp-mantle text-left transition", "relative overflow-hidden rounded-md border bg-ctp-mantle text-left transition disabled:cursor-default",
isSelected isSelected
? "border-ctp-mauve ring-2 ring-ctp-mauve/40" ? "border-ctp-mauve ring-2 ring-ctp-mauve/40"
: "border-ctp-surface0 hover:border-ctp-surface2", : "border-ctp-surface0 hover:border-ctp-surface2",
@@ -197,6 +280,65 @@ export function BulkRateModal({
</div> </div>
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-ctp-surface0 px-4 py-3"> <footer className="flex flex-wrap items-center justify-between gap-3 border-t border-ctp-surface0 px-4 py-3">
{busy ? (
<div className="flex w-full flex-col gap-1.5">
<div className="flex items-center justify-between gap-3 font-mono text-[11px] text-ctp-overlay0">
<span className="flex items-center gap-1.5">
<Spinner className="h-3 w-3" />
Moving {progress.total} upload(s) into the library…
</span>
<span>
{progress.moved} moved · {progress.done}/{progress.total} ·{" "}
{percent}%
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-ctp-surface0">
<div
className="h-full bg-ctp-mauve transition-[width] duration-200"
style={{ width: `${percent}%` }}
/>
</div>
</div>
) : phase === "done" ? (
<div className="flex w-full flex-col gap-2">
<div className="max-h-32 overflow-y-auto overscroll-contain rounded-md border border-ctp-surface0 bg-ctp-mantle">
{failures.map((failure) => (
<div
key={failure.id}
className="flex items-start justify-between gap-3 border-b border-ctp-surface0/60 px-2 py-1 text-[11px] last:border-0"
>
<span
className="truncate font-mono text-ctp-subtext1"
title={failure.name}
>
{failure.name}
</span>
<span className="shrink-0 text-ctp-red">
{failure.error}
</span>
</div>
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="font-mono text-[11px] text-ctp-overlay0">
{progress.moved} moved · {failures.length} failed
</span>
<span className="flex items-center gap-2">
<Button variant="ghost" onClick={onClose}>
Close
</Button>
<Button
variant="secondary"
disabled={retryable.length === 0}
onClick={retryFailed}
>
Retry failed ({retryable.length})
</Button>
</span>
</div>
</div>
) : (
<>
<span className="font-mono text-[11px] text-ctp-overlay0"> <span className="font-mono text-[11px] text-ctp-overlay0">
{selected.size} selected {selected.size} selected
{rating ? "" : " · pick a rating"} {rating ? "" : " · pick a rating"}
@@ -206,21 +348,14 @@ export function BulkRateModal({
Cancel Cancel
</Button> </Button>
<Button <Button
disabled={selected.size === 0 || !rating || mutation.isPending} disabled={selected.size === 0 || !rating}
onClick={() => { onClick={() => void run()}
if (!rating) return;
mutation.mutate({ temp_ids: [...selected], rating });
}}
> >
{mutation.isPending ? ( Move {selected.size} into the library
<>
<Spinner className="h-3.5 w-3.5" /> Moving…
</>
) : (
`Move ${selected.size} into the library`
)}
</Button> </Button>
</div> </div>
</>
)}
</footer> </footer>
</div> </div>
</div> </div>