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.
288 lines
11 KiB
TypeScript
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 & 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 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>
|
|
);
|
|
}
|