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:
2026-09-17 12:49:10 -05:00
parent 75b7ed35eb
commit cd490b0a23
28 changed files with 1966 additions and 22 deletions
+180
View File
@@ -0,0 +1,180 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import { formatDate } from "@/lib/format";
import type { AdminUser, Paginated } from "@/lib/types";
import { useAuth } from "@/store/auth";
const ROLE_OPTIONS = [
{ value: "user", label: "User" },
{ value: "uploader", label: "Uploader" },
{ value: "staff", label: "Staff" },
];
function UserRow({ profile }: { profile: AdminUser }) {
const queryClient = useQueryClient();
const [avatarJId, setAvatarJId] = useState(profile.avatar_j_id ?? "");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: (payload: Record<string, string | null>) =>
api<AdminUser>(`/api/users/${profile.id}/`, {
method: "PATCH",
json: payload,
}),
onSuccess: (updated) => {
setMessage("Saved.");
setError(null);
setAvatarJId(updated.avatar_j_id ?? "");
void queryClient.invalidateQueries({ queryKey: ["users"] });
},
onError: (err) => {
setError(errorMessage(err));
setMessage(null);
},
});
return (
<div className="flex flex-col gap-3 rounded-lg border border-ctp-surface0 bg-ctp-base p-3 lg:flex-row lg:items-center">
<div className="flex min-w-0 flex-1 items-center gap-3">
{profile.avatar_url ? (
<img
src={profile.avatar_url}
alt={profile.username}
className="h-10 w-10 shrink-0 rounded-full border border-ctp-surface1 object-cover"
/>
) : (
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-ctp-surface1 font-mono text-sm font-semibold text-ctp-lavender">
{profile.username.slice(0, 1).toUpperCase()}
</span>
)}
<div className="min-w-0">
<p className="flex flex-wrap items-center gap-2 text-sm text-ctp-subtext1">
<span className="font-medium">{profile.username}</span>
{profile.is_superuser ? (
<span className="rounded-full border border-ctp-red/40 bg-ctp-red/10 px-2 py-0.5 font-mono text-[10px] text-ctp-red">
admin
</span>
) : null}
{profile.is_staff && !profile.is_superuser ? (
<span className="rounded-full border border-ctp-surface1 px-2 py-0.5 font-mono text-[10px] text-ctp-overlay0">
django staff
</span>
) : null}
</p>
<p className="truncate font-mono text-[11px] text-ctp-overlay0">
{profile.email || "no email"} · joined{" "}
{formatDate(profile.date_joined)} · {profile.uploads_count} upload
{profile.uploads_count === 1 ? "" : "s"}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<select
className={cn(inputClass, "w-32")}
value={profile.role}
onChange={(event) =>
mutation.mutate({ role: event.target.value })
}
disabled={mutation.isPending}
title="Role"
>
{ROLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<input
className={cn(inputClass, "w-28 font-mono")}
placeholder="J-ID"
value={avatarJId}
onChange={(event) => setAvatarJId(event.target.value)}
title="Avatar library item"
/>
<Button
variant="secondary"
disabled={mutation.isPending}
onClick={() => mutation.mutate({ avatar_j_id: avatarJId.trim() })}
>
Save avatar
</Button>
</div>
{message ? (
<p className="text-xs text-ctp-green lg:w-20">{message}</p>
) : null}
{error ? (
<p className="text-xs text-ctp-red lg:w-40">{error}</p>
) : null}
</div>
);
}
export default function UsersPage() {
const user = useAuth((state) => state.user);
const [search, setSearch] = useState("");
const isStaff = Boolean(
user &&
(user.is_staff || user.is_superuser || user.role === "staff"),
);
const query = useQuery({
queryKey: ["users", search],
queryFn: () =>
api<Paginated<AdminUser>>(
`/api/users/${search ? `?search=${encodeURIComponent(search)}` : ""}`,
),
enabled: isStaff,
});
if (!isStaff) {
return (
<EmptyState
title="Staff only"
description="This page is limited to staff accounts."
/>
);
}
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4">
<header className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">Users</h1>
<p className="mt-1 text-sm text-ctp-overlay0">
Manage roles and set profile pictures from library J-IDs.
</p>
</div>
<input
className={cn(inputClass, "w-56")}
placeholder="Search username…"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</header>
{query.isPending ? (
<div className="flex justify-center py-16">
<Spinner className="h-6 w-6" />
</div>
) : query.isError || !query.data ? (
<EmptyState title="Could not load users" />
) : query.data.results.length === 0 ? (
<EmptyState title="No users found" />
) : (
<div className="flex flex-col gap-3">
{query.data.results.map((profile) => (
<UserRow key={profile.id} profile={profile} />
))}
</div>
)}
</div>
);
}