Duplicates, delete & storage, users page with J-ID avatars
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
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user