Backend: - Perceptual hashes (aHash/dHash/pHash/wHash via imagehash, no imgdd) stored on items, computed on upload/download and by the new compute_visual_hashes command - Duplicates API: exact duplicates (multi-location items), visual matches for one item, union-find similarity groups with pagination - Delete API with ownership/staff checks, per-item and per-copy deletion, watched-folder path validation; storage overview and temp cleanup; file list accepts j_ids batches - Staged uploads are flagged visual_match with their library matches (threshold via VISUAL_MATCH_THRESHOLD) - Staff users API: list with upload counts, set role and avatar by J-ID; User.avatar FK with signed avatar URLs - Download threads close their DB connection and stale tasks are reaped, keeping behaviour Gunicorn-friendly Frontend: - /duplicates: exact duplicate groups with per-copy delete, visual similarity controls, search similar to a J-ID, paginated groups with selection, bulk delete and dismiss - /delete: storage cards, delete by J-ID with preview grid, temp cleanup - /users: staff directory with role selects and avatar J-ID inputs - Nav + command palette entries; top-bar avatar; upload cards and the metadata modal show library 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={`/api/files/${item.j_id}/thumbnail/`}
|
|
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>
|
|
);
|
|
}
|