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:
@@ -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 =
|
||||||
method: "POST",
|
progress.total > 0
|
||||||
json: payload,
|
? Math.min(100, Math.round((progress.done / progress.total) * 100))
|
||||||
}),
|
: 0;
|
||||||
onSuccess: (result) => {
|
const retryable = failures.filter((failure) => names.has(failure.id));
|
||||||
void queryClient.invalidateQueries({ queryKey: ["uploads"] });
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
async function run() {
|
||||||
if (result.resolved.length > 0) {
|
if (!rating || selected.size === 0 || busy) return;
|
||||||
toast.ok(`Moved ${result.resolved.length} upload(s) into the library.`);
|
const ids = [...selected];
|
||||||
}
|
|
||||||
if (result.errors.length > 0) {
|
setPhase("running");
|
||||||
toast.error(
|
setFailures([]);
|
||||||
`${result.errors.length} could not be resolved: ${result.errors[0].error}`,
|
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",
|
||||||
|
json: { temp_ids: chunk, rating },
|
||||||
|
});
|
||||||
|
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: ["files"] });
|
||||||
|
setFailures(problems);
|
||||||
|
setPhase("done");
|
||||||
|
|
||||||
|
if (problems.length === 0) {
|
||||||
|
toast.ok(`Moved ${moved} upload(s) into the library.`);
|
||||||
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,30 +280,82 @@ 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">
|
||||||
<span className="font-mono text-[11px] text-ctp-overlay0">
|
{busy ? (
|
||||||
{selected.size} selected
|
<div className="flex w-full flex-col gap-1.5">
|
||||||
{rating ? "" : " · pick a rating"}
|
<div className="flex items-center justify-between gap-3 font-mono text-[11px] text-ctp-overlay0">
|
||||||
</span>
|
<span className="flex items-center gap-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<Spinner className="h-3 w-3" />
|
||||||
<Button variant="ghost" onClick={onClose}>
|
Moving {progress.total} upload(s) into the library…
|
||||||
Cancel
|
</span>
|
||||||
</Button>
|
<span>
|
||||||
<Button
|
{progress.moved} moved · {progress.done}/{progress.total} ·{" "}
|
||||||
disabled={selected.size === 0 || !rating || mutation.isPending}
|
{percent}%
|
||||||
onClick={() => {
|
</span>
|
||||||
if (!rating) return;
|
</div>
|
||||||
mutation.mutate({ temp_ids: [...selected], rating });
|
<div className="h-1.5 overflow-hidden rounded-full bg-ctp-surface0">
|
||||||
}}
|
<div
|
||||||
>
|
className="h-full bg-ctp-mauve transition-[width] duration-200"
|
||||||
{mutation.isPending ? (
|
style={{ width: `${percent}%` }}
|
||||||
<>
|
/>
|
||||||
<Spinner className="h-3.5 w-3.5" /> Moving…
|
</div>
|
||||||
</>
|
</div>
|
||||||
) : (
|
) : phase === "done" ? (
|
||||||
`Move ${selected.size} into the library`
|
<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">
|
||||||
</Button>
|
{failures.map((failure) => (
|
||||||
</div>
|
<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">
|
||||||
|
{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}
|
||||||
|
onClick={() => void run()}
|
||||||
|
>
|
||||||
|
Move {selected.size} into the library
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user