From 59f397a96ca2c0c5b4adfc26d51ff7178342b8f4 Mon Sep 17 00:00:00 2001
From: JakeBreath
Date: Wed, 23 Sep 2026 18:28:35 -0500
Subject: [PATCH] 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
---
frontend/src/features/upload/UploadPage.tsx | 258 ++++++++++++++++----
frontend/src/lib/types.ts | 24 ++
2 files changed, 228 insertions(+), 54 deletions(-)
diff --git a/frontend/src/features/upload/UploadPage.tsx b/frontend/src/features/upload/UploadPage.tsx
index 6da93fe..aa208e4 100644
--- a/frontend/src/features/upload/UploadPage.tsx
+++ b/frontend/src/features/upload/UploadPage.tsx
@@ -28,7 +28,13 @@ import {
RATING_LABELS,
} from "@/lib/e621";
import { formatBytes } from "@/lib/format";
-import type { E621IqdbCandidate, Rating, TempUpload } from "@/lib/types";
+import type {
+ E621IqdbCandidate,
+ Rating,
+ TempUpload,
+ UploadCompletion,
+ UploadStagingResult,
+} from "@/lib/types";
import {
postProcessUploads,
uploadStatusQueryKey,
@@ -272,6 +278,21 @@ function TempCard({
) : null}
+ {temp.status === "error" ? (
+
+
+ Retry
+
+
+ Set metadata
+
+
+ ) : null}
+
+
+ {preview ? (
+
+ ) : (
+
+ No preview
+
+ )}
+
+
+ indexed
+
+
+
+
+ {completion.filename}
+
+
+
+
+ {completion.j_id}
+
+
+ {RESOLUTION_LABELS[completion.resolution] ?? completion.resolution}
+
+
+
+ );
+}
+
/**
* Full metadata editor for one staged upload.
*
@@ -816,6 +889,10 @@ export default function UploadPage() {
const [discarding, setDiscarding] = useState(false);
const [modalId, setModalId] = useState(null);
const [bulkOpen, setBulkOpen] = useState(false);
+ // Indexed uploads shown while this page is open (never persisted).
+ const [completions, setCompletions] = useState([]);
+ const completionsSeeded = useRef(false);
+ const seenCompletions = useRef>(new Set());
useEffect(() => {
const urls = objectUrls.current;
@@ -836,6 +913,27 @@ export default function UploadPage() {
});
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) {
queryClient.setQueryData(uploadStatusQueryKey, data);
}
@@ -892,9 +990,7 @@ export default function UploadPage() {
const pending = uploads.filter((temp) => temp.status === "pending");
const visual = uploads.filter((temp) => temp.status === "visual_match");
- const completed = uploads.filter(
- (temp) => temp.status === "completed" || temp.status === "error",
- );
+ const failed = uploads.filter((temp) => temp.status === "error");
// 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.
@@ -1001,10 +1097,26 @@ export default function UploadPage() {
error: undefined,
});
try {
- await uploadFile(entry.file, {}, (percent) =>
- reportProgress(entry.id, percent),
+ const result = await uploadFile(
+ entry.file,
+ {},
+ (percent) => reportProgress(entry.id, percent),
);
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.
kickPipeline();
} catch (error) {
@@ -1060,39 +1172,40 @@ export default function UploadPage() {
.catch((error) => toast.error(errorMessage(error)));
}
- async function discardAll(
- items: TempUpload[],
- kind: "discard" | "dismiss",
- ) {
+ async function discardAll(items: TempUpload[]) {
if (items.length === 0 || discarding) return;
- const noun = kind === "discard" ? "Discard" : "Dismiss";
const confirmed = await confirmAction({
- title: `${noun} ${items.length} record${items.length === 1 ? "" : "s"}?`,
+ title: `Discard ${items.length} record${items.length === 1 ? "" : "s"}?`,
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."
- : "This only removes the board records; library files are untouched.",
- confirmLabel: `${noun} all`,
- danger: 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.",
+ confirmLabel: "Discard all",
+ danger: true,
});
if (!confirmed) return;
setDiscarding(true);
try {
- const result = await api<{
- discarded: string[];
- errors: { temp_id: string; error: string }[];
- }>("/api/uploads/discard-bulk/", {
- method: "POST",
- json: { temp_ids: items.map((temp) => temp.temp_id) },
- });
- const verb = kind === "discard" ? "Discarded" : "Dismissed";
- if (result.discarded.length > 0) {
- toast.ok(`${verb} ${result.discarded.length} record(s).`);
+ // The endpoint caps one request at 1000 ids; large staging backlogs
+ // must still be discardable in one go.
+ const ids = items.map((temp) => temp.temp_id);
+ const chunkSize = 500;
+ const discarded: string[] = [];
+ const errors: { temp_id: string; error: string }[] = [];
+ for (let start = 0; start < ids.length; start += chunkSize) {
+ const result = await api<{
+ discarded: string[];
+ 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) {
- toast.error(
- `${result.errors.length} record(s) could not be removed.`,
- );
+ if (discarded.length > 0) {
+ toast.ok(`Discarded ${discarded.length} record(s).`);
+ }
+ if (errors.length > 0) {
+ toast.error(`${errors.length} record(s) could not be removed.`);
}
void queryClient.invalidateQueries({ queryKey: ["uploads"] });
void queryClient.invalidateQueries({ queryKey: ["upload-status"] });
@@ -1127,10 +1240,11 @@ export default function UploadPage() {
);
}
- const column = (
+ const column = (
title: string,
- items: TempUpload[],
+ items: T[],
emptyText: string,
+ render: (item: T) => ReactNode,
action?: ReactNode,
) => (
@@ -1148,17 +1262,7 @@ export default function UploadPage() {
{emptyText}
) : (
-
- {items.map((temp) => (
- setModalId(temp.temp_id)}
- onRetry={() => retry(temp, "iqdb")}
- onDismiss={() => dismiss(temp)}
- />
- ))}
-
+ {items.map(render)}
)}
);
@@ -1387,11 +1491,20 @@ export default function UploadPage() {
) : null}
-
+
{column(
"Pending & Unmatched",
pending,
"Nothing waiting.",
+ (temp) => (
+ setModalId(temp.temp_id)}
+ onRetry={() => retry(temp, "iqdb")}
+ onDismiss={() => dismiss(temp)}
+ />
+ ),
pending.length > 0 ? (
void discardAll(pending, "discard")}
+ onClick={() => void discardAll(pending)}
title="Delete every staged file in this column"
>
@@ -1420,12 +1533,21 @@ export default function UploadPage() {
"Visual Similarity Detected",
visual,
"No IQDB matches right now.",
+ (temp) => (
+ setModalId(temp.temp_id)}
+ onRetry={() => retry(temp, "iqdb")}
+ onDismiss={() => dismiss(temp)}
+ />
+ ),
visual.length > 0 ? (
void discardAll(visual, "discard")}
+ onClick={() => void discardAll(visual)}
title="Delete every staged file in this column"
>
@@ -1435,20 +1557,48 @@ export default function UploadPage() {
)}
{column(
"Auto-uploaded & Indexed",
- completed,
- "Nothing indexed yet.",
- completed.length > 0 ? (
+ completions,
+ "Nothing indexed while you were watching.",
+ (completion) => (
+
+ ),
+ completions.length > 0 ? (
void discardAll(completed, "dismiss")}
- title="Dismiss every indexed record"
+ onClick={() => setCompletions([])}
+ title="Clear these notifications (nothing is stored)"
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
) : null,
)}
+ {column(
+ "Failed",
+ failed,
+ "No failed uploads.",
+ (temp) => (
+ setModalId(temp.temp_id)}
+ onRetry={() => retry(temp)}
+ onDismiss={() => dismiss(temp)}
+ />
+ ),
+ failed.length > 0 ? (
+ void discardAll(failed)}
+ title="Delete every failed staged file in this column"
+ >
+
+ discard all
+
+ ) : null,
+ )}
{modalId ? (
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index e19c2e6..ec1bb06 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -388,6 +388,28 @@ export interface TempUpload {
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. */
export interface UploadStatus {
status: "idle" | "running" | "paused" | "error";
@@ -400,6 +422,8 @@ export interface UploadStatus {
error: string;
outstanding: number;
waiting: { md5: number; visual: number; iqdb: number };
+ /** Session notification feed: indexed uploads, newest first. */
+ recent_completions: UploadCompletion[];
updated_at: string | null;
}