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:
@@ -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 {
|
||||
// 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: items.map((temp) => temp.temp_id) },
|
||||
json: { temp_ids: ids.slice(start, start + chunkSize) },
|
||||
});
|
||||
const verb = kind === "discard" ? "Discarded" : "Dismissed";
|
||||
if (result.discarded.length > 0) {
|
||||
toast.ok(`${verb} ${result.discarded.length} record(s).`);
|
||||
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 ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user