Files
J621/frontend/src/features/library/DeletePage.tsx
T
JakeBreath b96c311235 Fix cross-origin staging URLs, e621 UA format and IQDB queue stalls
The dev Vite proxy rewrites the request Host to 127.0.0.1:8000, so the
backend's absolute signed file URLs pointed at a different origin than the
SPA (localhost:5173). Images tolerated it, but the auth'd fetch that reads
the staging blob for IQDB was blocked ('Cross-Origin Request Blocked') and
every similarity check died before reaching e621.

apiUrl() now keeps API-built absolute URLs on the page's origin whenever the
SPA is in same-origin mode (dev proxy, deploy nginx) and leaves them
absolute when an explicit backend URL is configured. All consumers use it:
staging previews and the bulk modal, library cards, optimizer (range sniff +
worker), IQDB card, delete page, similar page.

e621 identification now follows the documented 'App/version (developer)'
form: server-side requests send 'J621/<hash> (JakeBreath)' and the browser
_client gets the same string, with the hash baked into the frontend image
(GIT_HASH build arg; guarded at runtime so the dev server still works).

IQDB stalls: requests now time out after 20s (a hung fetch used to block the
serialized e621 queue forever), and all checks run through one serial drain
so repeated 'Check similarity' clicks can no longer start overlapping runs
that re-download the same staging blobs. Auth/rate-limit/timeout/network
failures stop the queue with the reason and a retry button instead of
grinding through the rest.

Verified live: staged file URL is same-origin through the proxy and fetches
200 through it.
2026-09-19 00:30:42 -05:00

288 lines
11 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Link } from "react-router-dom";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { api, apiUrl, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/format";
import type { MediaItem, Paginated, StorageInfo } from "@/lib/types";
import { confirmAction } from "@/store/confirm";
import { toast } from "@/store/toasts";
function parseJIds(text: string): string[] {
return text
.split(/[\s,]+/)
.map((value) => value.trim().toUpperCase())
.filter((value) => /^J-\d+$/.test(value));
}
export default function DeletePage() {
const queryClient = useQueryClient();
const [jIdsInput, setJIdsInput] = useState("");
const [loadedIds, setLoadedIds] = useState<string[]>([]);
const [selected, setSelected] = useState<string[]>([]);
const storageQuery = useQuery({
queryKey: ["storage"],
queryFn: () => api<StorageInfo>("/api/storage/"),
});
const filesQuery = useQuery({
queryKey: ["delete-files", loadedIds],
queryFn: () =>
api<Paginated<MediaItem>>(`/api/files/?j_ids=${loadedIds.join(",")}`),
enabled: loadedIds.length > 0,
});
const deleteMutation = useMutation({
mutationFn: (jIds: string[]) =>
api<{ deleted: string[]; errors: { j_id?: string; error: string }[] }>(
"/api/delete/",
{ method: "POST", json: { j_ids: jIds } },
),
onSuccess: (result) => {
setSelected([]);
setLoadedIds([]);
toast.ok(`Deleted ${result.deleted.length} item(s).`);
if (result.errors.length > 0) {
toast.error(
`${result.errors.length} item(s) could not be deleted: ` +
result.errors.map((entry) => entry.error).join(", "),
);
}
void queryClient.invalidateQueries({ queryKey: ["storage"] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
void queryClient.invalidateQueries({ queryKey: ["duplicates-md5"] });
},
onError: (error) => toast.error(errorMessage(error)),
});
const tempMutation = useMutation({
mutationFn: () =>
api<{ removed: number }>("/api/temp/clear/", { method: "POST" }),
onSuccess: (result) => {
toast.ok(`Removed ${result.removed} temp file(s).`);
void queryClient.invalidateQueries({ queryKey: ["storage"] });
},
onError: (error) => toast.error(errorMessage(error)),
});
async function handleDeleteSelected() {
const confirmed = await confirmAction({
title: `Delete ${selected.length} item(s)?`,
description: "They are removed from the library along with all of their copies.",
confirmLabel: "Delete",
danger: true,
});
if (confirmed) deleteMutation.mutate(selected);
}
async function handleClearTemp() {
const confirmed = await confirmAction({
title: "Clear the temp folder?",
description: "Staged uploads still sitting in it will be removed.",
confirmLabel: "Clear",
danger: true,
});
if (confirmed) tempMutation.mutate();
}
function toggleSelected(jId: string) {
setSelected((current) =>
current.includes(jId)
? current.filter((value) => value !== jId)
: [...current, jId],
);
}
const storage = storageQuery.data;
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<header>
<h1 className="text-lg font-semibold">Delete &amp; storage</h1>
<p className="mt-1 text-sm text-ctp-overlay0">
Inspect disk usage and remove files from the library.
</p>
</header>
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{storageQuery.isPending ? (
<Spinner className="h-5 w-5" />
) : storage ? (
<>
<div className="rounded-lg border border-ctp-surface0 bg-ctp-base p-3">
<p className="text-[11px] uppercase tracking-wide text-ctp-overlay0">
Library size
</p>
<p className="mt-1 font-mono text-sm text-ctp-subtext1">
{formatBytes(storage.watched_folder.library_size)}
</p>
<p className="mt-1 truncate font-mono text-[10px] text-ctp-overlay0" title={storage.watched_folder.path}>
{storage.watched_folder.path}
</p>
<p className="font-mono text-[10px] text-ctp-overlay0">
{storage.library_items} items
</p>
</div>
<div className="rounded-lg border border-ctp-surface0 bg-ctp-base p-3">
<p className="text-[11px] uppercase tracking-wide text-ctp-overlay0">
Disk
</p>
<p className="mt-1 font-mono text-sm text-ctp-subtext1">
{formatBytes(storage.watched_folder.used)} /{" "}
{formatBytes(storage.watched_folder.total)}
</p>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-ctp-surface0">
<div
className={cn(
"h-full",
storage.watched_folder.percent_used >= 95
? "bg-ctp-red"
: storage.watched_folder.percent_used >= 80
? "bg-ctp-peach"
: "bg-ctp-blue",
)}
style={{ width: `${storage.watched_folder.percent_used}%` }}
/>
</div>
<p className="mt-1 font-mono text-[10px] text-ctp-overlay0">
{storage.watched_folder.percent_used}% used ·{" "}
{formatBytes(storage.watched_folder.free)} free
</p>
</div>
<div className="rounded-lg border border-ctp-surface0 bg-ctp-base p-3">
<p className="text-[11px] uppercase tracking-wide text-ctp-overlay0">
Media folder
</p>
<p className="mt-1 font-mono text-sm text-ctp-subtext1">
{formatBytes(storage.media.size)}
</p>
<p className="mt-1 truncate font-mono text-[10px] text-ctp-overlay0" title={storage.media.path}>
{storage.media.path}
</p>
</div>
<div className="rounded-lg border border-ctp-surface0 bg-ctp-base p-3">
<p className="text-[11px] uppercase tracking-wide text-ctp-overlay0">
Temp uploads
</p>
<p className="mt-1 font-mono text-sm text-ctp-subtext1">
{formatBytes(storage.temp.size)} · {storage.temp.files} file(s)
</p>
<Button
variant="secondary"
className="mt-2 px-2 py-1 text-xs"
disabled={tempMutation.isPending || storage.temp.files === 0}
onClick={() => void handleClearTemp()}
>
{tempMutation.isPending ? "Clearing…" : "Clear temp folder"}
</Button>
</div>
</>
) : (
<EmptyState title="Could not load storage info" />
)}
</section>
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Delete files by J-ID
</h2>
<textarea
className={cn(inputClass, "min-h-20 resize-y font-mono")}
placeholder="J-12, J-34 J-56&#10;comma, space or newline separated"
value={jIdsInput}
onChange={(event) => setJIdsInput(event.target.value)}
/>
<div className="flex items-center gap-3">
<Button
variant="secondary"
onClick={() => {
setSelected([]);
setLoadedIds(parseJIds(jIdsInput));
}}
>
Load files
</Button>
{selected.length > 0 ? (
<Button
variant="danger"
disabled={deleteMutation.isPending}
onClick={() => void handleDeleteSelected()}
>
{deleteMutation.isPending
? "Deleting…"
: `Delete ${selected.length} selected`}
</Button>
) : null}
</div>
{loadedIds.length === 0 ? (
<p className="text-xs text-ctp-overlay0">
Enter one or more J-IDs to preview them here.
</p>
) : filesQuery.isPending ? (
<Spinner className="h-5 w-5" />
) : (filesQuery.data?.results.length ?? 0) === 0 ? (
<EmptyState title="No matching library items" />
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{filesQuery.data?.results.map((item) => {
const isSelected = selected.includes(item.j_id);
return (
<div
key={item.j_id}
className={cn(
"overflow-hidden rounded-lg border bg-ctp-base",
isSelected ? "border-ctp-mauve" : "border-ctp-surface0",
)}
>
<button
type="button"
onClick={() => toggleSelected(item.j_id)}
className="block w-full text-left"
>
<div className="relative aspect-square overflow-hidden bg-ctp-mantle">
<img
src={apiUrl(item.thumbnail_url)}
alt={item.filename}
loading="lazy"
className="h-full w-full object-cover"
/>
<span
className={cn(
"absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded border text-[11px]",
isSelected
? "border-ctp-mauve bg-ctp-mauve text-ctp-crust"
: "border-ctp-surface1 bg-ctp-crust/70 text-transparent",
)}
>
✓
</span>
</div>
</button>
<div className="px-2 py-1.5">
<Link
to={`/detail/${item.j_id}`}
className="font-mono text-[10px] text-ctp-blue hover:underline"
>
{item.j_id}
</Link>
<p className="truncate font-mono text-[11px] text-ctp-subtext1" title={item.filename}>
{item.filename}
</p>
<p className="font-mono text-[10px] text-ctp-overlay0">
{formatBytes(item.size)}
</p>
</div>
</div>
);
})}
</div>
)}
</section>
</div>
);
}