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,
} 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({
</div>
) : 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
type="button"
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.
*
@@ -816,6 +889,10 @@ export default function UploadPage() {
const [discarding, setDiscarding] = useState(false);
const [modalId, setModalId] = useState<string | null>(null);
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(() => {
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<UploadStagingResult>(
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 = <T,>(
title: string,
items: TempUpload[],
items: T[],
emptyText: string,
render: (item: T) => ReactNode,
action?: ReactNode,
) => (
<section className="flex min-w-0 flex-col gap-3">
@@ -1148,17 +1262,7 @@ export default function UploadPage() {
{emptyText}
</p>
) : (
<div className="flex flex-col gap-3">
{items.map((temp) => (
<TempCard
key={temp.temp_id}
temp={temp}
onOpen={() => setModalId(temp.temp_id)}
onRetry={() => retry(temp, "iqdb")}
onDismiss={() => dismiss(temp)}
/>
))}
</div>
<div className="flex flex-col gap-3">{items.map(render)}</div>
)}
</section>
);
@@ -1387,11 +1491,20 @@ export default function UploadPage() {
</section>
) : null}
<div className="grid gap-6 lg:grid-cols-3">
<div className="grid gap-6 lg:grid-cols-4">
{column(
"Pending & Unmatched",
pending,
"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 ? (
<span className="flex items-center gap-1.5">
<Button
@@ -1407,7 +1520,7 @@ export default function UploadPage() {
variant="danger"
className="px-2 py-0.5 text-[11px]"
disabled={discarding}
onClick={() => void discardAll(pending, "discard")}
onClick={() => void discardAll(pending)}
title="Delete every staged file in this column"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -1420,12 +1533,21 @@ export default function UploadPage() {
"Visual Similarity Detected",
visual,
"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 ? (
<Button
variant="danger"
className="px-2 py-0.5 text-[11px]"
disabled={discarding}
onClick={() => void discardAll(visual, "discard")}
onClick={() => void discardAll(visual)}
title="Delete every staged file in this column"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -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) => (
<CompletionCard key={completion.id} completion={completion} />
),
completions.length > 0 ? (
<button
type="button"
disabled={discarding}
onClick={() => 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
</button>
) : 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>
{modalId ? (
+24
View File
@@ -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;
}