Items flagged hidden_from_guests (blacklisted tags) returned 404 for <img> requests since tags cannot send the auth header. The API now exposes signed raw_url/thumbnail_url fields (mirroring upload previews and avatars), and the SPA uses them in the gallery, detail view, duplicates and delete screens, and upload visual matches.
272 lines
10 KiB
TypeScript
272 lines
10 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, errorMessage } from "@/lib/api";
|
|
import { cn } from "@/lib/cn";
|
|
import { formatBytes } from "@/lib/format";
|
|
import type { MediaItem, Paginated, StorageInfo } from "@/lib/types";
|
|
|
|
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 [status, setStatus] = useState<string | null>(null);
|
|
|
|
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([]);
|
|
setStatus(
|
|
`Deleted ${result.deleted.length} item(s)${
|
|
result.errors.length > 0 ? ` · ${result.errors.length} failed` : ""
|
|
}.`,
|
|
);
|
|
void queryClient.invalidateQueries({ queryKey: ["storage"] });
|
|
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
|
void queryClient.invalidateQueries({ queryKey: ["duplicates-md5"] });
|
|
},
|
|
onError: (error) => setStatus(errorMessage(error)),
|
|
});
|
|
|
|
const tempMutation = useMutation({
|
|
mutationFn: () =>
|
|
api<{ removed: number }>("/api/temp/clear/", { method: "POST" }),
|
|
onSuccess: (result) => {
|
|
setStatus(`Removed ${result.removed} temp file(s).`);
|
|
void queryClient.invalidateQueries({ queryKey: ["storage"] });
|
|
},
|
|
onError: (error) => setStatus(errorMessage(error)),
|
|
});
|
|
|
|
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>
|
|
|
|
{status ? (
|
|
<p className="rounded-md border border-ctp-surface1 bg-ctp-base px-3 py-2 text-xs text-ctp-subtext0">
|
|
{status}
|
|
</p>
|
|
) : null}
|
|
|
|
<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={() => tempMutation.mutate()}
|
|
>
|
|
{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={() => {
|
|
setStatus(null);
|
|
setSelected([]);
|
|
setLoadedIds(parseJIds(jIdsInput));
|
|
}}
|
|
>
|
|
Load files
|
|
</Button>
|
|
{selected.length > 0 ? (
|
|
<Button
|
|
variant="danger"
|
|
disabled={deleteMutation.isPending}
|
|
onClick={() => deleteMutation.mutate(selected)}
|
|
>
|
|
{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={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>
|
|
);
|
|
}
|