Perf / a11y (high-impact review items) - Timeline arrow-key handler binds once per (viewMode, currentSection) and reads fresh state via navStateRef instead of an 8-element dep array of new-each-render values. - usePhotosQuery collapses 14 individual Zustand selectors into one useShallow selector returning the params object. - PhotoThumbnail no longer subscribes to the search query directly; Timeline subscribes once and passes it down as a prop. - PhotoThumbnail gains role="button", tabIndex, aria-label, aria-pressed, Enter/Space key handlers and a focus-visible ring. Timeline marked role="grid"; RightSidebar marked role="region". Style consistency - Swap clsx for cn (tailwind-merge aware) across 17 files so conflicting utility classes collapse correctly. - New Badge primitive (ui/badge.tsx) with default/neutral/overlay/ outline variants; adopted in ColorsView, RatedView, TagsView for the repeated count overlay pill. - Fix palette drift: text-amber-400 -> text-star, text-green-* -> text-pick, text-red-* -> text-reject (5 files). - Button gains an xs size (h-6 px-1.5 text-[11px]) for the repeated compact-button pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
368 lines
11 KiB
TypeScript
368 lines
11 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
|
import { Plus, Pencil, UserX, Shield, User as UserIcon } from 'lucide-react'
|
|
import { admin, type AdminUser } from '../../services/api'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
|
|
|
|
export function UserManagement() {
|
|
const [users, setUsers] = useState<AdminUser[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
|
|
const [deactivatingUser, setDeactivatingUser] = useState<AdminUser | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const fetchUsers = useCallback(async () => {
|
|
try {
|
|
const data = await admin.listUsers()
|
|
setUsers(data.users)
|
|
} catch {
|
|
setError('Failed to load users.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
fetchUsers()
|
|
}, [fetchUsers])
|
|
|
|
const handleDeactivate = async () => {
|
|
if (!deactivatingUser) return
|
|
try {
|
|
await admin.deleteUser(deactivatingUser.id)
|
|
setDeactivatingUser(null)
|
|
fetchUsers()
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.detail ?? 'Failed to deactivate user.')
|
|
setDeactivatingUser(null)
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return <div className="p-4 text-sm text-text-muted">Loading users…</div>
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold text-text">Users</h3>
|
|
<Button size="sm" onClick={() => setShowCreate(true)}>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
Add User
|
|
</Button>
|
|
</div>
|
|
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="border-b border-border text-left text-text-muted">
|
|
<th className="pb-1 pr-4">Username</th>
|
|
<th className="pb-1 pr-4">Role</th>
|
|
<th className="pb-1 pr-4">Photos</th>
|
|
<th className="pb-1 pr-4">Status</th>
|
|
<th className="pb-1">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map((u) => (
|
|
<tr key={u.id} className="border-b border-border/50">
|
|
<td className="py-1.5 pr-4">
|
|
<div className="flex items-center gap-1.5">
|
|
{u.role === 'admin' ? (
|
|
<Shield className="h-3 w-3 text-accent" />
|
|
) : (
|
|
<UserIcon className="h-3 w-3 text-text-muted" />
|
|
)}
|
|
<span className="text-text">{u.username}</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-1.5 pr-4 text-text-muted">{u.role}</td>
|
|
<td className="py-1.5 pr-4 text-text-muted">
|
|
{u.photo_count.toLocaleString()}
|
|
</td>
|
|
<td className="py-1.5 pr-4">
|
|
<span
|
|
className={u.is_active ? 'text-pick' : 'text-reject'}
|
|
>
|
|
{u.is_active ? 'Active' : 'Inactive'}
|
|
</span>
|
|
</td>
|
|
<td className="py-1.5">
|
|
<div className="flex gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
onClick={() => setEditingUser(u)}
|
|
title="Edit user"
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</Button>
|
|
{u.is_active && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 hover:text-reject"
|
|
onClick={() => setDeactivatingUser(u)}
|
|
title="Deactivate user"
|
|
>
|
|
<UserX className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
<CreateUserModal
|
|
open={showCreate}
|
|
onClose={() => setShowCreate(false)}
|
|
onCreated={() => {
|
|
setShowCreate(false)
|
|
fetchUsers()
|
|
}}
|
|
/>
|
|
|
|
<EditUserModal
|
|
user={editingUser}
|
|
onClose={() => setEditingUser(null)}
|
|
onSaved={() => {
|
|
setEditingUser(null)
|
|
fetchUsers()
|
|
}}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
isOpen={!!deactivatingUser}
|
|
title={`Deactivate "${deactivatingUser?.username}"?`}
|
|
message="Their photos will be preserved."
|
|
confirmLabel="Deactivate"
|
|
destructive
|
|
onConfirm={handleDeactivate}
|
|
onClose={() => setDeactivatingUser(null)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Create User Modal ──────────────────────────────────────────────────
|
|
|
|
function CreateUserModal({
|
|
open,
|
|
onClose,
|
|
onCreated,
|
|
}: {
|
|
open: boolean
|
|
onClose: () => void
|
|
onCreated: () => void
|
|
}) {
|
|
const [username, setUsername] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [role, setRole] = useState<'user' | 'admin'>('user')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setUsername('')
|
|
setPassword('')
|
|
setRole('user')
|
|
setError(null)
|
|
setLoading(false)
|
|
}
|
|
}, [open])
|
|
|
|
const handleSubmit = async () => {
|
|
setError(null)
|
|
setLoading(true)
|
|
try {
|
|
await admin.createUser({ username: username.trim(), password, role })
|
|
onCreated()
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.detail ?? 'Failed to create user.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>Add User</DialogTitle>
|
|
</DialogHeader>
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
<div className="space-y-3">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="new-username">Username</Label>
|
|
<Input
|
|
id="new-username"
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="new-password">Password</Label>
|
|
<Input
|
|
id="new-password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Role</Label>
|
|
<Select
|
|
value={role}
|
|
onValueChange={(v) => setRole(v as 'user' | 'admin')}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="user">User</SelectItem>
|
|
<SelectItem value="admin">Admin</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleSubmit} disabled={loading}>
|
|
{loading ? 'Creating\u2026' : 'Create'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// ── Edit User Modal ────────────────────────────────────────────────────
|
|
|
|
function EditUserModal({
|
|
user,
|
|
onClose,
|
|
onSaved,
|
|
}: {
|
|
user: AdminUser | null
|
|
onClose: () => void
|
|
onSaved: () => void
|
|
}) {
|
|
const [role, setRole] = useState<'user' | 'admin'>('user')
|
|
const [newPassword, setNewPassword] = useState('')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (user) {
|
|
setRole(user.role as 'user' | 'admin')
|
|
setNewPassword('')
|
|
setError(null)
|
|
setLoading(false)
|
|
}
|
|
}, [user])
|
|
|
|
const handleSubmit = async () => {
|
|
if (!user) return
|
|
setError(null)
|
|
setLoading(true)
|
|
try {
|
|
const data: { role?: string; new_password?: string } = {}
|
|
if (role !== user.role) data.role = role
|
|
if (newPassword) data.new_password = newPassword
|
|
if (Object.keys(data).length > 0) {
|
|
await admin.updateUser(user.id, data)
|
|
}
|
|
onSaved()
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.detail ?? 'Failed to update user.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit: {user?.username}</DialogTitle>
|
|
</DialogHeader>
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
<div className="space-y-3">
|
|
<div className="space-y-1">
|
|
<Label>Role</Label>
|
|
<Select
|
|
value={role}
|
|
onValueChange={(v) => setRole(v as 'user' | 'admin')}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="user">User</SelectItem>
|
|
<SelectItem value="admin">Admin</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="edit-password">
|
|
New Password (leave blank to keep current)
|
|
</Label>
|
|
<Input
|
|
id="edit-password"
|
|
type="password"
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
placeholder="Unchanged"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleSubmit} disabled={loading}>
|
|
{loading ? 'Saving\u2026' : 'Save'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|