Files
J621/frontend/src/features/upload/BulkRateModal.tsx
T
JakeBreath b96c311235 Fix cross-origin staging URLs, e621 UA format and IQDB queue stalls
The dev Vite proxy rewrites the request Host to 127.0.0.1:8000, so the
backend's absolute signed file URLs pointed at a different origin than the
SPA (localhost:5173). Images tolerated it, but the auth'd fetch that reads
the staging blob for IQDB was blocked ('Cross-Origin Request Blocked') and
every similarity check died before reaching e621.

apiUrl() now keeps API-built absolute URLs on the page's origin whenever the
SPA is in same-origin mode (dev proxy, deploy nginx) and leaves them
absolute when an explicit backend URL is configured. All consumers use it:
staging previews and the bulk modal, library cards, optimizer (range sniff +
worker), IQDB card, delete page, similar page.

e621 identification now follows the documented 'App/version (developer)'
form: server-side requests send 'J621/<hash> (JakeBreath)' and the browser
_client gets the same string, with the hash baked into the frontend image
(GIT_HASH build arg; guarded at runtime so the dev server still works).

IQDB stalls: requests now time out after 20s (a hung fetch used to block the
serialized e621 queue forever), and all checks run through one serial drain
so repeated 'Check similarity' clicks can no longer start overlapping runs
that re-download the same staging blobs. Auth/rate-limit/timeout/network
failures stop the queue with the reason and a retry button instead of
grinding through the rest.

Verified live: staged file URL is same-origin through the proxy and fetches
200 through it.
2026-09-19 00:30:42 -05:00

366 lines
13 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { Check, Video, X } from "lucide-react";
import { useState } from "react";
import { Button, Spinner } from "@/components/ui";
import { api, apiUrl, 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",
},
];
/**
* 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 {
resolved: 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
* every thumbnail that belongs to it, and they all move into the library,
* reporting progress chunk by chunk.
*/
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 [phase, setPhase] = useState<Phase>("select");
const [progress, setProgress] = useState({ done: 0, total: 0, moved: 0 });
const [failures, setFailures] = useState<Failure[]>([]);
const names = new Map(uploads.map((temp) => [temp.temp_id, temp.original_filename]));
const busy = phase === "running";
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",
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();
} else if (moved > 0) {
toast.error(`Moved ${moved}, ${problems.length} failed.`);
} else {
toast.error(`Nothing could be moved (${problems.length} failed).`);
}
}
function toggle(id: string) {
setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function requestClose() {
if (!busy) onClose();
}
function retryFailed() {
setSelected(new Set(retryable.map((failure) => failure.id)));
setFailures([]);
setPhase("select");
}
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={requestClose}
>
<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">
{busy
? "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>
</div>
<button
type="button"
onClick={requestClose}
disabled={busy}
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" />
</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"
disabled={busy}
onClick={() => setRating(option.value)}
className={cn(
"rounded-full border px-3 py-1 text-xs font-medium transition disabled:pointer-events-none disabled:opacity-40",
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"
disabled={busy}
onClick={() =>
setSelected(new Set(uploads.map((temp) => temp.temp_id)))
}
>
Select all
</Button>
<Button
variant="ghost"
className="px-2 py-1 text-xs"
disabled={busy || 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={cn(
"grid grid-cols-[repeat(auto-fill,minmax(130px,1fr))] content-start gap-2 transition",
busy && "pointer-events-none opacity-60",
)}
>
{uploads.map((temp) => {
const isSelected = selected.has(temp.temp_id);
const preview = apiUrl(
temp.preview_url ?? temp.file_url ?? "",
);
return (
<button
key={temp.temp_id}
type="button"
disabled={phase !== "select"}
onClick={() => toggle(temp.temp_id)}
title={temp.original_filename}
className={cn(
"relative overflow-hidden rounded-md border bg-ctp-mantle text-left transition disabled:cursor-default",
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">
{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">
{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>
</div>
</div>
);
}