feat: Add Faces Maintenance page and API for managing face items

This commit introduces a new Faces Maintenance page in the frontend, allowing users to view, sort, and delete face items based on quality and person information. The API has been updated to include endpoints for retrieving and deleting faces, enhancing the management capabilities of the application. Additionally, new data models and schemas for maintenance face items have been added to support these features. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09
2025-11-11 14:09:47 -05:00
parent 20f1a4207f
commit f7accb925d
6 changed files with 579 additions and 1 deletions
+2
View File
@@ -10,6 +10,7 @@ import Identify from './pages/Identify'
import AutoMatch from './pages/AutoMatch'
import Modify from './pages/Modify'
import Tags from './pages/Tags'
import FacesMaintenance from './pages/FacesMaintenance'
import Settings from './pages/Settings'
import Layout from './components/Layout'
@@ -41,6 +42,7 @@ function AppRoutes() {
<Route path="auto-match" element={<AutoMatch />} />
<Route path="modify" element={<Modify />} />
<Route path="tags" element={<Tags />} />
<Route path="faces-maintenance" element={<FacesMaintenance />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
+40
View File
@@ -136,6 +136,31 @@ export interface AcceptMatchesRequest {
face_ids: number[]
}
export interface MaintenanceFaceItem {
id: number
photo_id: number
photo_path: string
photo_filename: string
quality_score: number
person_id: number | null
person_name: string | null
}
export interface MaintenanceFacesResponse {
items: MaintenanceFaceItem[]
total: number
}
export interface DeleteFacesRequest {
face_ids: number[]
}
export interface DeleteFacesResponse {
deleted_face_ids: number[]
count: number
message: string
}
export const facesApi = {
/**
* Start face processing job
@@ -189,6 +214,21 @@ export const facesApi = {
const response = await apiClient.post<AutoMatchResponse>('/api/v1/faces/auto-match', request)
return response.data
},
getMaintenanceFaces: async (params: {
page?: number
page_size?: number
min_quality?: number
max_quality?: number
}): Promise<MaintenanceFacesResponse> => {
const response = await apiClient.get<MaintenanceFacesResponse>('/api/v1/faces/maintenance', {
params,
})
return response.data
},
deleteFaces: async (request: DeleteFacesRequest): Promise<DeleteFacesResponse> => {
const response = await apiClient.post<DeleteFacesResponse>('/api/v1/faces/delete', request)
return response.data
},
}
export default facesApi
+1
View File
@@ -14,6 +14,7 @@ export default function Layout() {
{ path: '/auto-match', label: 'Auto-Match', icon: '🤖' },
{ path: '/modify', label: 'Modify', icon: '✏️' },
{ path: '/tags', label: 'Tags', icon: '🏷️' },
{ path: '/faces-maintenance', label: 'Faces Maintenance', icon: '🔧' },
{ path: '/settings', label: 'Settings', icon: '⚙️' },
]
+343
View File
@@ -0,0 +1,343 @@
import { useEffect, useState, useMemo } from 'react'
import facesApi, { MaintenanceFaceItem } from '../api/faces'
import { apiClient } from '../api/client'
type SortColumn = 'person_name' | 'quality'
type SortDir = 'asc' | 'desc'
export default function FacesMaintenance() {
const [faces, setFaces] = useState<MaintenanceFaceItem[]>([])
const [total, setTotal] = useState(0)
const [pageSize, setPageSize] = useState(50)
const [minQuality, setMinQuality] = useState(0.0)
const [maxQuality, setMaxQuality] = useState(1.0)
const [selectedFaces, setSelectedFaces] = useState<Set<number>>(new Set())
const [loading, setLoading] = useState(false)
const [deleting, setDeleting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [sortColumn, setSortColumn] = useState<SortColumn | null>(null)
const [sortDir, setSortDir] = useState<SortDir>('asc')
const loadFaces = async () => {
setLoading(true)
try {
const res = await facesApi.getMaintenanceFaces({
page: 1,
page_size: pageSize,
min_quality: minQuality,
max_quality: maxQuality,
})
setFaces(res.items)
setTotal(res.total)
setSelectedFaces(new Set()) // Clear selection when reloading
} catch (error) {
console.error('Error loading faces:', error)
alert('Error loading faces. Please try again.')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadFaces()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, minQuality, maxQuality])
const toggleSelection = (faceId: number) => {
setSelectedFaces(prev => {
const newSet = new Set(prev)
if (newSet.has(faceId)) {
newSet.delete(faceId)
} else {
newSet.add(faceId)
}
return newSet
})
}
const selectAll = () => {
setSelectedFaces(new Set(sortedFaces.map(f => f.id)))
}
const unselectAll = () => {
setSelectedFaces(new Set())
}
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
} else {
setSortColumn(column)
setSortDir('asc')
}
}
const sortedFaces = useMemo(() => {
if (!sortColumn) return faces
return [...faces].sort((a, b) => {
let aVal: any
let bVal: any
switch (sortColumn) {
case 'person_name':
aVal = a.person_name || 'Unidentified'
bVal = b.person_name || 'Unidentified'
break
case 'quality':
aVal = a.quality_score
bVal = b.quality_score
break
}
if (typeof aVal === 'string') {
aVal = aVal.toLowerCase()
bVal = bVal.toLowerCase()
}
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1
return 0
})
}, [faces, sortColumn, sortDir])
const handleDelete = async () => {
if (selectedFaces.size === 0) {
alert('Please select at least one face to delete.')
return
}
setShowDeleteConfirm(true)
}
const confirmDelete = async () => {
setShowDeleteConfirm(false)
setDeleting(true)
try {
await facesApi.deleteFaces({
face_ids: Array.from(selectedFaces),
})
// Reload faces after deletion
await loadFaces()
alert(`Successfully deleted ${selectedFaces.size} face(s)`)
} catch (error) {
console.error('Error deleting faces:', error)
alert('Error deleting faces. Please try again.')
} finally {
setDeleting(false)
}
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">Faces Maintenance</h1>
{/* Controls */}
<div className="bg-white rounded-lg shadow mb-4 p-4">
<div className="grid grid-cols-3 gap-4">
{/* Quality Range Selector */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Quality Range
</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={1}
step={0.01}
value={minQuality}
onChange={(e) => setMinQuality(parseFloat(e.target.value))}
className="flex-1"
/>
<input
type="range"
min={0}
max={1}
step={0.01}
value={maxQuality}
onChange={(e) => setMaxQuality(parseFloat(e.target.value))}
className="flex-1"
/>
</div>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>Min: {(minQuality * 100).toFixed(0)}%</span>
<span>Max: {(maxQuality * 100).toFixed(0)}%</span>
</div>
</div>
{/* Batch Size */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Batch Size
</label>
<select
value={pageSize}
onChange={(e) => setPageSize(parseInt(e.target.value))}
className="block w-full border rounded px-2 py-1 text-sm"
>
{[25, 50, 100, 200, 500, 1000, 1500, 2000].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
{/* Action Buttons */}
<div className="flex items-end gap-2">
<button
onClick={selectAll}
disabled={faces.length === 0}
className="px-3 py-2 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
>
Select All
</button>
<button
onClick={unselectAll}
disabled={selectedFaces.size === 0}
className="px-3 py-2 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
>
Unselect All
</button>
<button
onClick={handleDelete}
disabled={selectedFaces.size === 0 || deleting}
className="px-3 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{deleting ? 'Deleting...' : 'Delete Selected'}
</button>
</div>
</div>
</div>
{/* Results */}
<div className="bg-white rounded-lg shadow p-4">
<div className="mb-4">
<span className="text-sm font-medium text-gray-700">
Total: {total} face(s)
</span>
{selectedFaces.size > 0 && (
<span className="ml-4 text-sm text-gray-600">
Selected: {selectedFaces.size} face(s)
</span>
)}
</div>
{loading ? (
<div className="text-center py-8 text-gray-500">Loading faces...</div>
) : sortedFaces.length === 0 ? (
<div className="text-center py-8 text-gray-500">No faces found</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left p-2 w-12"></th>
<th className="text-left p-2 w-24">Thumbnail</th>
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('person_name')}
>
Person Name {sortColumn === 'person_name' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th className="text-left p-2">File Path</th>
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('quality')}
>
Quality {sortColumn === 'quality' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
</tr>
</thead>
<tbody>
{sortedFaces.map((face) => (
<tr key={face.id} className="border-b hover:bg-gray-50">
<td className="p-2">
<input
type="checkbox"
checked={selectedFaces.has(face.id)}
onChange={() => toggleSelection(face.id)}
className="cursor-pointer"
/>
</td>
<td className="p-2">
<div
className="w-20 h-20 bg-gray-100 rounded overflow-hidden flex items-center justify-center relative group cursor-pointer"
onClick={() => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${face.photo_id}/image`
window.open(photoUrl, '_blank')
}}
title="Click to open full photo"
>
<img
src={`${apiClient.defaults.baseURL}/api/v1/faces/${face.id}/crop`}
alt={`Face ${face.id}`}
className="max-w-full max-h-full object-contain pointer-events-none"
crossOrigin="anonymous"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.error-fallback')) {
const fallback = document.createElement('div')
fallback.className = 'text-gray-400 text-xs error-fallback'
fallback.textContent = `#${face.id}`
parent.appendChild(fallback)
}
}}
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-opacity pointer-events-none" />
</div>
</td>
<td className="p-2">
{face.person_name || (
<span className="text-gray-400 italic">Unidentified</span>
)}
</td>
<td className="p-2">
<span className="text-blue-600" title={face.photo_path}>
{face.photo_path}
</span>
</td>
<td className="p-2">
{(face.quality_score * 100).toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Delete Confirmation Dialog */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h3 className="text-lg font-bold mb-4">Confirm Delete</h3>
<p className="text-gray-700 mb-6">
Are you sure you want to delete {selectedFaces.size} face(s) from
the database? This action cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={confirmDelete}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
)
}