Show indexed uploads as session notifications, add a Failed column

The board rendered every persisted completed row with a dismiss button and a
dismiss-all that sent the whole column to the 1000-id bulk endpoint. Now that
the backend deletes completed rows and reports them through the status feed:

- the Auto-uploaded & Indexed column renders the live feed only; a reload or
  leaving the page forgets them, and there is nothing to dismiss (just a
  client-side clear)
- duplicates complete during staging and get their card immediately from the
  upload response
- failures get their own persisted column with per-card retry and discard
- bulk discard chunks requests at 500 ids, so backlogs over the server's
  1000-id cap are still removable in one action
This commit is contained in:
2026-09-23 18:28:35 -05:00
parent 9ababb8b48
commit 59f397a96c
2 changed files with 228 additions and 54 deletions
+204 -54
View File
@@ -28,7 +28,13 @@ import {
RATING_LABELS, RATING_LABELS,
} from "@/lib/e621"; } from "@/lib/e621";
import { formatBytes } from "@/lib/format"; import { formatBytes } from "@/lib/format";
import type { E621IqdbCandidate, Rating, TempUpload } from "@/lib/types"; import type {
E621IqdbCandidate,
Rating,
TempUpload,
UploadCompletion,
UploadStagingResult,
} from "@/lib/types";
import { import {
postProcessUploads, postProcessUploads,
uploadStatusQueryKey, uploadStatusQueryKey,
@@ -272,6 +278,21 @@ function TempCard({
</div> </div>
) : null} ) : null}
{temp.status === "error" ? (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Button className="px-2 py-1 text-xs" onClick={onRetry}>
Retry
</Button>
<Button
variant="secondary"
className="px-2 py-1 text-xs"
onClick={onOpen}
>
Set metadata
</Button>
</div>
) : null}
<button <button
type="button" type="button"
onClick={onDismiss} onClick={onDismiss}
@@ -289,6 +310,58 @@ function TempCard({
); );
} }
/**
* A "J-x uploaded" notification.
*
* Completions are not stored on the board: the pipeline deletes the staged
* record and announces it through the status feed, so these cards only exist
* while the page is open.
*/
function CompletionCard({ completion }: { completion: UploadCompletion }) {
const preview = apiUrl(completion.thumbnail_url ?? "");
return (
<div className="rounded-lg border border-ctp-surface0 bg-ctp-base p-2.5">
<div className="relative aspect-video overflow-hidden rounded-md bg-ctp-mantle">
{preview ? (
<img
src={preview}
alt={completion.filename}
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-ctp-overlay0">
No preview
</div>
)}
<span className="absolute right-2 top-2 flex items-center gap-1 rounded-full bg-ctp-green/90 px-1.5 py-0.5 font-mono text-[10px] font-medium text-ctp-crust">
<CheckCircle2 className="h-2.5 w-2.5" />
indexed
</span>
</div>
<p
className="mt-2 truncate font-mono text-xs text-ctp-subtext1"
title={completion.filename}
>
{completion.filename}
</p>
<p className="mt-1 flex items-center gap-1.5 text-xs text-ctp-green">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
<Link
to={`/detail/${completion.j_id}`}
className="font-mono hover:underline"
>
{completion.j_id}
</Link>
<span className="truncate text-ctp-overlay0">
{RESOLUTION_LABELS[completion.resolution] ?? completion.resolution}
</span>
</p>
</div>
);
}
/** /**
* Full metadata editor for one staged upload. * Full metadata editor for one staged upload.
* *
@@ -816,6 +889,10 @@ export default function UploadPage() {
const [discarding, setDiscarding] = useState(false); const [discarding, setDiscarding] = useState(false);
const [modalId, setModalId] = useState<string | null>(null); const [modalId, setModalId] = useState<string | null>(null);
const [bulkOpen, setBulkOpen] = useState(false); const [bulkOpen, setBulkOpen] = useState(false);
// Indexed uploads shown while this page is open (never persisted).
const [completions, setCompletions] = useState<UploadCompletion[]>([]);
const completionsSeeded = useRef(false);
const seenCompletions = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
const urls = objectUrls.current; const urls = objectUrls.current;
@@ -836,6 +913,27 @@ export default function UploadPage() {
}); });
const uploads = uploadsQuery.data ?? []; const uploads = uploadsQuery.data ?? [];
// Completions are session notifications. Seed what the server already has
// when the page opens (so a reload does not resurrect old ones) and append
// only what happens while the page is watching.
useEffect(() => {
const feed = status?.recent_completions;
if (!feed) return;
if (!completionsSeeded.current) {
completionsSeeded.current = true;
// Merge: a staging duplicate may have been added before the first poll.
seenCompletions.current = new Set([
...seenCompletions.current,
...feed.map((entry) => entry.id),
]);
return;
}
const fresh = feed.filter((entry) => !seenCompletions.current.has(entry.id));
if (fresh.length === 0) return;
for (const entry of fresh) seenCompletions.current.add(entry.id);
setCompletions((current) => [...fresh, ...current].slice(0, 200));
}, [status]);
function cacheStatus(data: unknown) { function cacheStatus(data: unknown) {
queryClient.setQueryData(uploadStatusQueryKey, data); queryClient.setQueryData(uploadStatusQueryKey, data);
} }
@@ -892,9 +990,7 @@ export default function UploadPage() {
const pending = uploads.filter((temp) => temp.status === "pending"); const pending = uploads.filter((temp) => temp.status === "pending");
const visual = uploads.filter((temp) => temp.status === "visual_match"); const visual = uploads.filter((temp) => temp.status === "visual_match");
const completed = uploads.filter( const failed = uploads.filter((temp) => temp.status === "error");
(temp) => temp.status === "completed" || temp.status === "error",
);
// Active work stays in insertion order (the grid must not reshuffle while // 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. // you scroll it); the in-flight files get their own strip above it.
@@ -1001,10 +1097,26 @@ export default function UploadPage() {
error: undefined, error: undefined,
}); });
try { try {
await uploadFile(entry.file, {}, (percent) => const result = await uploadFile<UploadStagingResult>(
reportProgress(entry.id, percent), entry.file,
{},
(percent) => reportProgress(entry.id, percent),
); );
finishEntry(entry); finishEntry(entry);
// An exact duplicate completes during staging: show its card now.
if (result?.status === "completed" && result.library_j_id) {
const completion: UploadCompletion = {
id: result.temp_id,
filename: result.original_filename,
j_id: result.library_j_id,
resolution: result.resolution,
post_id: result.e621_post_id ?? null,
thumbnail_url: result.preview_url ?? null,
at: new Date().toISOString(),
};
seenCompletions.current.add(completion.id);
setCompletions((current) => [completion, ...current].slice(0, 200));
}
// Processing starts while the rest of the drop is still uploading. // Processing starts while the rest of the drop is still uploading.
kickPipeline(); kickPipeline();
} catch (error) { } catch (error) {
@@ -1060,39 +1172,40 @@ export default function UploadPage() {
.catch((error) => toast.error(errorMessage(error))); .catch((error) => toast.error(errorMessage(error)));
} }
async function discardAll( async function discardAll(items: TempUpload[]) {
items: TempUpload[],
kind: "discard" | "dismiss",
) {
if (items.length === 0 || discarding) return; if (items.length === 0 || discarding) return;
const noun = kind === "discard" ? "Discard" : "Dismiss";
const confirmed = await confirmAction({ const confirmed = await confirmAction({
title: `${noun} ${items.length} record${items.length === 1 ? "" : "s"}?`, title: `Discard ${items.length} record${items.length === 1 ? "" : "s"}?`,
description: description:
kind === "discard" "The staged files are deleted from the staging folder and never enter the library. Files the pipeline is importing right now are skipped.",
? "The staged files are deleted from the staging folder and never enter the library. Files the pipeline is importing right now are skipped." confirmLabel: "Discard all",
: "This only removes the board records; library files are untouched.", danger: true,
confirmLabel: `${noun} all`,
danger: kind === "discard",
}); });
if (!confirmed) return; if (!confirmed) return;
setDiscarding(true); setDiscarding(true);
try { try {
const result = await api<{ // The endpoint caps one request at 1000 ids; large staging backlogs
discarded: string[]; // must still be discardable in one go.
errors: { temp_id: string; error: string }[]; const ids = items.map((temp) => temp.temp_id);
}>("/api/uploads/discard-bulk/", { const chunkSize = 500;
method: "POST", const discarded: string[] = [];
json: { temp_ids: items.map((temp) => temp.temp_id) }, const errors: { temp_id: string; error: string }[] = [];
}); for (let start = 0; start < ids.length; start += chunkSize) {
const verb = kind === "discard" ? "Discarded" : "Dismissed"; const result = await api<{
if (result.discarded.length > 0) { discarded: string[];
toast.ok(`${verb} ${result.discarded.length} record(s).`); errors: { temp_id: string; error: string }[];
}>("/api/uploads/discard-bulk/", {
method: "POST",
json: { temp_ids: ids.slice(start, start + chunkSize) },
});
discarded.push(...result.discarded);
errors.push(...result.errors);
} }
if (result.errors.length > 0) { if (discarded.length > 0) {
toast.error( toast.ok(`Discarded ${discarded.length} record(s).`);
`${result.errors.length} record(s) could not be removed.`, }
); if (errors.length > 0) {
toast.error(`${errors.length} record(s) could not be removed.`);
} }
void queryClient.invalidateQueries({ queryKey: ["uploads"] }); void queryClient.invalidateQueries({ queryKey: ["uploads"] });
void queryClient.invalidateQueries({ queryKey: ["upload-status"] }); void queryClient.invalidateQueries({ queryKey: ["upload-status"] });
@@ -1127,10 +1240,11 @@ export default function UploadPage() {
); );
} }
const column = ( const column = <T,>(
title: string, title: string,
items: TempUpload[], items: T[],
emptyText: string, emptyText: string,
render: (item: T) => ReactNode,
action?: ReactNode, action?: ReactNode,
) => ( ) => (
<section className="flex min-w-0 flex-col gap-3"> <section className="flex min-w-0 flex-col gap-3">
@@ -1148,17 +1262,7 @@ export default function UploadPage() {
{emptyText} {emptyText}
</p> </p>
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">{items.map(render)}</div>
{items.map((temp) => (
<TempCard
key={temp.temp_id}
temp={temp}
onOpen={() => setModalId(temp.temp_id)}
onRetry={() => retry(temp, "iqdb")}
onDismiss={() => dismiss(temp)}
/>
))}
</div>
)} )}
</section> </section>
); );
@@ -1387,11 +1491,20 @@ export default function UploadPage() {
</section> </section>
) : null} ) : null}
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-4">
{column( {column(
"Pending & Unmatched", "Pending & Unmatched",
pending, pending,
"Nothing waiting.", "Nothing waiting.",
(temp) => (
<TempCard
key={temp.temp_id}
temp={temp}
onOpen={() => setModalId(temp.temp_id)}
onRetry={() => retry(temp, "iqdb")}
onDismiss={() => dismiss(temp)}
/>
),
pending.length > 0 ? ( pending.length > 0 ? (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Button <Button
@@ -1407,7 +1520,7 @@ export default function UploadPage() {
variant="danger" variant="danger"
className="px-2 py-0.5 text-[11px]" className="px-2 py-0.5 text-[11px]"
disabled={discarding} disabled={discarding}
onClick={() => void discardAll(pending, "discard")} onClick={() => void discardAll(pending)}
title="Delete every staged file in this column" title="Delete every staged file in this column"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -1420,12 +1533,21 @@ export default function UploadPage() {
"Visual Similarity Detected", "Visual Similarity Detected",
visual, visual,
"No IQDB matches right now.", "No IQDB matches right now.",
(temp) => (
<TempCard
key={temp.temp_id}
temp={temp}
onOpen={() => setModalId(temp.temp_id)}
onRetry={() => retry(temp, "iqdb")}
onDismiss={() => dismiss(temp)}
/>
),
visual.length > 0 ? ( visual.length > 0 ? (
<Button <Button
variant="danger" variant="danger"
className="px-2 py-0.5 text-[11px]" className="px-2 py-0.5 text-[11px]"
disabled={discarding} disabled={discarding}
onClick={() => void discardAll(visual, "discard")} onClick={() => void discardAll(visual)}
title="Delete every staged file in this column" title="Delete every staged file in this column"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -1435,20 +1557,48 @@ export default function UploadPage() {
)} )}
{column( {column(
"Auto-uploaded & Indexed", "Auto-uploaded & Indexed",
completed, completions,
"Nothing indexed yet.", "Nothing indexed while you were watching.",
completed.length > 0 ? ( (completion) => (
<CompletionCard key={completion.id} completion={completion} />
),
completions.length > 0 ? (
<button <button
type="button" type="button"
disabled={discarding} onClick={() => setCompletions([])}
onClick={() => void discardAll(completed, "dismiss")} title="Clear these notifications (nothing is stored)"
title="Dismiss every indexed record"
className="rounded-md px-1.5 py-0.5 font-mono text-[10px] text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text" className="rounded-md px-1.5 py-0.5 font-mono text-[10px] text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
> >
dismiss all clear
</button> </button>
) : null, ) : null,
)} )}
{column(
"Failed",
failed,
"No failed uploads.",
(temp) => (
<TempCard
key={temp.temp_id}
temp={temp}
onOpen={() => setModalId(temp.temp_id)}
onRetry={() => retry(temp)}
onDismiss={() => dismiss(temp)}
/>
),
failed.length > 0 ? (
<Button
variant="danger"
className="px-2 py-0.5 text-[11px]"
disabled={discarding}
onClick={() => void discardAll(failed)}
title="Delete every failed staged file in this column"
>
<Trash2 className="h-3.5 w-3.5" />
discard all
</Button>
) : null,
)}
</div> </div>
{modalId ? ( {modalId ? (
+24
View File
@@ -388,6 +388,28 @@ export interface TempUpload {
updated_at: string; updated_at: string;
} }
/** One upload the pipeline indexed while the page was watching. */
export interface UploadCompletion {
id: string;
filename: string;
j_id: string;
resolution: string;
post_id: number | null;
thumbnail_url: string | null;
at: string;
}
/** Response of POST /api/uploads/ — a pending row or an instant completion. */
export interface UploadStagingResult {
temp_id: string;
original_filename: string;
status: "pending" | "visual_match" | "completed" | "error";
resolution: string;
library_j_id: string | null;
preview_url: string | null;
e621_post_id?: number | null;
}
/** Progress of the server-side staged-upload pipeline. */ /** Progress of the server-side staged-upload pipeline. */
export interface UploadStatus { export interface UploadStatus {
status: "idle" | "running" | "paused" | "error"; status: "idle" | "running" | "paused" | "error";
@@ -400,6 +422,8 @@ export interface UploadStatus {
error: string; error: string;
outstanding: number; outstanding: number;
waiting: { md5: number; visual: number; iqdb: number }; waiting: { md5: number; visual: number; iqdb: number };
/** Session notification feed: indexed uploads, newest first. */
recent_completions: UploadCompletion[];
updated_at: string | null; updated_at: string | null;
} }