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,
|
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 {
|
||||||
|
// 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<{
|
const result = await api<{
|
||||||
discarded: string[];
|
discarded: string[];
|
||||||
errors: { temp_id: string; error: string }[];
|
errors: { temp_id: string; error: string }[];
|
||||||
}>("/api/uploads/discard-bulk/", {
|
}>("/api/uploads/discard-bulk/", {
|
||||||
method: "POST",
|
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";
|
discarded.push(...result.discarded);
|
||||||
if (result.discarded.length > 0) {
|
errors.push(...result.errors);
|
||||||
toast.ok(`${verb} ${result.discarded.length} record(s).`);
|
|
||||||
}
|
}
|
||||||
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 ? (
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user