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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user