ui: migrate to shadcn/ui primitives across dialogs, filters, and forms

Adopts shadcn/ui components (Dialog, Button, Input, Select, Popover,
Command, Checkbox, Switch, Toggle, Calendar, etc.) across the app,
replacing hand-rolled modals, dropdowns, and form controls. Adds a
reusable cmdk-backed MultiSelect for the Type, Tags, and Flag filters
so all multi-value filter popovers share one component and layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 09:07:20 +02:00
parent 8529771122
commit 7efac4354e
51 changed files with 3032 additions and 1660 deletions

View File

@@ -1,12 +1,32 @@
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 () => {
@@ -24,6 +44,18 @@ export function UserManagement() {
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&hellip;</div>
}
@@ -32,19 +64,16 @@ export function UserManagement() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text">Users</h3>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1 rounded bg-accent px-2 py-1 text-xs text-white hover:bg-accent/80"
>
<Plus className="h-3 w-3" />
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="mr-1 h-3 w-3" />
Add User
</button>
</Button>
</div>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300">
{error}
</div>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<table className="w-full text-xs">
@@ -87,29 +116,25 @@ export function UserManagement() {
</td>
<td className="py-1.5">
<div className="flex gap-1">
<button
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setEditingUser(u)}
className="rounded p-1 text-text-muted hover:bg-bg hover:text-text"
title="Edit user"
>
<Pencil className="h-3 w-3" />
</button>
</Button>
{u.is_active && (
<button
onClick={async () => {
if (!confirm(`Deactivate user "${u.username}"? Their photos will be preserved.`)) return
try {
await admin.deleteUser(u.id)
fetchUsers()
} catch (err: any) {
setError(err.response?.data?.detail ?? 'Failed to deactivate user.')
}
}}
className="rounded p-1 text-text-muted hover:bg-bg hover:text-red-400"
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-red-400"
onClick={() => setDeactivatingUser(u)}
title="Deactivate user"
>
<UserX className="h-3 w-3" />
</button>
</Button>
)}
</div>
</td>
@@ -118,26 +143,33 @@ export function UserManagement() {
</tbody>
</table>
{showCreate && (
<CreateUserModal
onClose={() => setShowCreate(false)}
onCreated={() => {
setShowCreate(false)
fetchUsers()
}}
/>
)}
<CreateUserModal
open={showCreate}
onClose={() => setShowCreate(false)}
onCreated={() => {
setShowCreate(false)
fetchUsers()
}}
/>
{editingUser && (
<EditUserModal
user={editingUser}
onClose={() => setEditingUser(null)}
onSaved={() => {
setEditingUser(null)
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>
)
}
@@ -145,9 +177,11 @@ export function UserManagement() {
// ── Create User Modal ──────────────────────────────────────────────────
function CreateUserModal({
open,
onClose,
onCreated,
}: {
open: boolean
onClose: () => void
onCreated: () => void
}) {
@@ -157,6 +191,16 @@ function CreateUserModal({
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)
@@ -171,56 +215,61 @@ function CreateUserModal({
}
return (
<ModalOverlay onClose={onClose} title="Add User">
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300">
{error}
<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>
)}
<div className="space-y-3">
<Field label="Username">
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
autoFocus
/>
</Field>
<Field label="Password">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
/>
</Field>
<Field label="Role">
<select
value={role}
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</Field>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1 text-xs text-text hover:bg-bg"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="rounded bg-accent px-3 py-1 text-xs text-white hover:bg-accent/80 disabled:opacity-50"
>
{loading ? 'Creating\u2026' : 'Create'}
</button>
</div>
</ModalOverlay>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? 'Creating\u2026' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -231,16 +280,26 @@ function EditUserModal({
onClose,
onSaved,
}: {
user: AdminUser
user: AdminUser | null
onClose: () => void
onSaved: () => void
}) {
const [role, setRole] = useState(user.role)
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 {
@@ -259,78 +318,54 @@ function EditUserModal({
}
return (
<ModalOverlay onClose={onClose} title={`Edit: ${user.username}`}>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300">
{error}
<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>
)}
<div className="space-y-3">
<Field label="Role">
<select
value={role}
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</Field>
<Field label="New Password (leave blank to keep current)">
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
placeholder="Unchanged"
/>
</Field>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1 text-xs text-text hover:bg-bg"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="rounded bg-accent px-3 py-1 text-xs text-white hover:bg-accent/80 disabled:opacity-50"
>
{loading ? 'Saving\u2026' : 'Save'}
</button>
</div>
</ModalOverlay>
)
}
// ── Shared helpers ─────────────────────────────────────────────────────
function ModalOverlay({
onClose: _onClose,
title,
children,
}: {
onClose: () => void
title: string
children: React.ReactNode
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={_onClose}>
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h4 className="mb-3 text-sm font-semibold text-text">{title}</h4>
{children}
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<label className="block text-[11px] text-text-muted">{label}</label>
{children}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? 'Saving\u2026' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}