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
+21 -4
View File
@@ -60,7 +60,16 @@ export function AppShell() {
const navItems = [
{ to: "/", label: "Library" },
{ to: "/online", label: "Online" },
...(user?.can_upload ? [{ to: "/upload", label: "Upload" }] : []),
...(user?.can_upload
? [
{ to: "/upload", label: "Upload" },
{ to: "/duplicates", label: "Duplicates" },
{ to: "/delete", label: "Delete" },
]
: []),
...(user && (user.is_staff || user.is_superuser || user.role === "staff")
? [{ to: "/users", label: "Users" }]
: []),
];
const navigationType = useNavigationType();
@@ -120,9 +129,17 @@ export function AppShell() {
title="Account settings"
className="flex items-center gap-2 transition hover:opacity-80"
>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-ctp-surface1 font-mono text-xs font-semibold text-ctp-lavender">
{user.username.slice(0, 1).toUpperCase()}
</span>
{user.avatar_url ? (
<img
src={user.avatar_url}
alt={user.username}
className="h-7 w-7 rounded-full border border-ctp-surface1 object-cover"
/>
) : (
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-ctp-surface1 font-mono text-xs font-semibold text-ctp-lavender">
{user.username.slice(0, 1).toUpperCase()}
</span>
)}
<span className="hidden text-sm text-ctp-subtext1 sm:block">
{user.username}
</span>
@@ -1,4 +1,5 @@
import {
Copy,
FolderOpen,
Globe,
LogIn,
@@ -6,7 +7,9 @@ import {
Menu,
Search,
Settings,
Trash2,
Upload,
Users,
type LucideIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
@@ -59,6 +62,18 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
icon: Upload,
run: () => navigate("/upload"),
},
{
id: "duplicates",
label: "Find duplicates",
icon: Copy,
run: () => navigate("/duplicates"),
},
{
id: "delete",
label: "Delete files & storage",
icon: Trash2,
run: () => navigate("/delete"),
},
]
: []),
{
@@ -94,6 +109,14 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
icon: Settings,
run: () => navigate("/account"),
});
if (user.is_staff || user.is_superuser || user.role === "staff") {
list.push({
id: "users",
label: "Manage users",
icon: Users,
run: () => navigate("/users"),
});
}
list.push({
id: "logout",
label: `Log out (${user.username})`,