feat: Implement Modify Identified workflow for person management
This commit introduces the Modify Identified workflow, allowing users to edit person information, view associated faces, and unmatch faces from identified people. The API has been updated with new endpoints for unmatching faces and retrieving faces for specific persons. The frontend includes a new Modify page with a user-friendly interface for managing identified persons, including search and edit functionalities. Documentation and tests have been updated to reflect these changes, ensuring reliability and usability.
This commit is contained in:
@@ -7,6 +7,7 @@ import Scan from './pages/Scan'
|
||||
import Process from './pages/Process'
|
||||
import Identify from './pages/Identify'
|
||||
import AutoMatch from './pages/AutoMatch'
|
||||
import Modify from './pages/Modify'
|
||||
import Tags from './pages/Tags'
|
||||
import Settings from './pages/Settings'
|
||||
import Layout from './components/Layout'
|
||||
@@ -37,6 +38,7 @@ function AppRoutes() {
|
||||
<Route path="search" element={<Search />} />
|
||||
<Route path="identify" element={<Identify />} />
|
||||
<Route path="auto-match" element={<AutoMatch />} />
|
||||
<Route path="modify" element={<Modify />} />
|
||||
<Route path="tags" element={<Tags />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
|
||||
@@ -59,6 +59,57 @@ export interface IdentifyFaceResponse {
|
||||
created_person: boolean
|
||||
}
|
||||
|
||||
export interface FaceUnmatchResponse {
|
||||
face_id: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface BatchUnmatchRequest {
|
||||
face_ids: number[]
|
||||
}
|
||||
|
||||
export interface BatchUnmatchResponse {
|
||||
unmatched_face_ids: number[]
|
||||
count: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface AutoMatchRequest {
|
||||
tolerance: number
|
||||
}
|
||||
|
||||
export interface AutoMatchFaceItem {
|
||||
id: number
|
||||
photo_id: number
|
||||
photo_filename: string
|
||||
location: string
|
||||
quality_score: number
|
||||
similarity: number // Confidence percentage (0-100)
|
||||
distance: number
|
||||
}
|
||||
|
||||
export interface AutoMatchPersonItem {
|
||||
person_id: number
|
||||
person_name: string
|
||||
reference_face_id: number
|
||||
reference_photo_id: number
|
||||
reference_photo_filename: string
|
||||
reference_location: string
|
||||
face_count: number
|
||||
matches: AutoMatchFaceItem[]
|
||||
total_matches: number
|
||||
}
|
||||
|
||||
export interface AutoMatchResponse {
|
||||
people: AutoMatchPersonItem[]
|
||||
total_people: number
|
||||
total_matches: number
|
||||
}
|
||||
|
||||
export interface AcceptMatchesRequest {
|
||||
face_ids: number[]
|
||||
}
|
||||
|
||||
export const facesApi = {
|
||||
/**
|
||||
* Start face processing job
|
||||
@@ -89,6 +140,18 @@ export const facesApi = {
|
||||
const response = await apiClient.post<IdentifyFaceResponse>(`/api/v1/faces/${faceId}/identify`, payload)
|
||||
return response.data
|
||||
},
|
||||
unmatch: async (faceId: number): Promise<FaceUnmatchResponse> => {
|
||||
const response = await apiClient.post<FaceUnmatchResponse>(`/api/v1/faces/${faceId}/unmatch`)
|
||||
return response.data
|
||||
},
|
||||
batchUnmatch: async (payload: BatchUnmatchRequest): Promise<BatchUnmatchResponse> => {
|
||||
const response = await apiClient.post<BatchUnmatchResponse>('/api/v1/faces/batch-unmatch', payload)
|
||||
return response.data
|
||||
},
|
||||
autoMatch: async (request: AutoMatchRequest): Promise<AutoMatchResponse> => {
|
||||
const response = await apiClient.post<AutoMatchResponse>('/api/v1/faces/auto-match', request)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default facesApi
|
||||
|
||||
@@ -14,6 +14,15 @@ export interface PeopleListResponse {
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface PersonWithFaces extends Person {
|
||||
face_count: number
|
||||
}
|
||||
|
||||
export interface PeopleWithFacesListResponse {
|
||||
items: PersonWithFaces[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface PersonCreateRequest {
|
||||
first_name: string
|
||||
last_name: string
|
||||
@@ -22,15 +31,65 @@ export interface PersonCreateRequest {
|
||||
date_of_birth: string
|
||||
}
|
||||
|
||||
export interface PersonUpdateRequest {
|
||||
first_name: string
|
||||
last_name: string
|
||||
middle_name?: string
|
||||
maiden_name?: string
|
||||
date_of_birth?: string | null
|
||||
}
|
||||
|
||||
export const peopleApi = {
|
||||
list: async (): Promise<PeopleListResponse> => {
|
||||
const res = await apiClient.get<PeopleListResponse>('/api/v1/people')
|
||||
list: async (lastName?: string): Promise<PeopleListResponse> => {
|
||||
const params = lastName ? { last_name: lastName } : {}
|
||||
const res = await apiClient.get<PeopleListResponse>('/api/v1/people', { params })
|
||||
return res.data
|
||||
},
|
||||
listWithFaces: async (lastName?: string): Promise<PeopleWithFacesListResponse> => {
|
||||
const params = lastName ? { last_name: lastName } : {}
|
||||
const res = await apiClient.get<PeopleWithFacesListResponse>('/api/v1/people/with-faces', { params })
|
||||
return res.data
|
||||
},
|
||||
create: async (payload: PersonCreateRequest): Promise<Person> => {
|
||||
const res = await apiClient.post<Person>('/api/v1/people', payload)
|
||||
return res.data
|
||||
},
|
||||
update: async (personId: number, payload: PersonUpdateRequest): Promise<Person> => {
|
||||
const res = await apiClient.put<Person>(`/api/v1/people/${personId}`, payload)
|
||||
return res.data
|
||||
},
|
||||
getFaces: async (personId: number): Promise<PersonFacesResponse> => {
|
||||
const res = await apiClient.get<PersonFacesResponse>(`/api/v1/people/${personId}/faces`)
|
||||
return res.data
|
||||
},
|
||||
acceptMatches: async (personId: number, faceIds: number[]): Promise<IdentifyFaceResponse> => {
|
||||
const res = await apiClient.post<IdentifyFaceResponse>(`/api/v1/people/${personId}/accept-matches`, { face_ids: faceIds })
|
||||
return res.data
|
||||
},
|
||||
}
|
||||
|
||||
export interface IdentifyFaceResponse {
|
||||
identified_face_ids: number[]
|
||||
person_id: number
|
||||
created_person: boolean
|
||||
}
|
||||
|
||||
export interface PersonFaceItem {
|
||||
id: number
|
||||
photo_id: number
|
||||
photo_path: string
|
||||
photo_filename: string
|
||||
location: string
|
||||
face_confidence: number
|
||||
quality_score: number
|
||||
detector_backend: string
|
||||
model_name: string
|
||||
}
|
||||
|
||||
export interface PersonFacesResponse {
|
||||
person_id: number
|
||||
items: PersonFaceItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export default peopleApi
|
||||
|
||||
@@ -12,6 +12,7 @@ export default function Layout() {
|
||||
{ path: '/search', label: 'Search', icon: '🔍' },
|
||||
{ path: '/identify', label: 'Identify', icon: '👤' },
|
||||
{ path: '/auto-match', label: 'Auto-Match', icon: '🤖' },
|
||||
{ path: '/modify', label: 'Modify', icon: '✏️' },
|
||||
{ path: '/tags', label: 'Tags', icon: '🏷️' },
|
||||
{ path: '/settings', label: 'Settings', icon: '⚙️' },
|
||||
]
|
||||
|
||||
@@ -1,11 +1,386 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import facesApi, { AutoMatchResponse, AutoMatchPersonItem, AutoMatchFaceItem } from '../api/faces'
|
||||
import peopleApi from '../api/people'
|
||||
import { apiClient } from '../api/client'
|
||||
|
||||
const DEFAULT_TOLERANCE = 0.6
|
||||
|
||||
export default function AutoMatch() {
|
||||
const [tolerance, setTolerance] = useState(DEFAULT_TOLERANCE)
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const [people, setPeople] = useState<AutoMatchPersonItem[]>([])
|
||||
const [filteredPeople, setFilteredPeople] = useState<AutoMatchPersonItem[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedFaces, setSelectedFaces] = useState<Record<number, boolean>>({})
|
||||
const [originalSelectedFaces, setOriginalSelectedFaces] = useState<Record<number, boolean>>({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const currentPerson = useMemo(() => {
|
||||
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
|
||||
return activePeople[currentIndex]
|
||||
}, [filteredPeople, people, currentIndex])
|
||||
|
||||
const currentMatches = useMemo(() => {
|
||||
return currentPerson?.matches || []
|
||||
}, [currentPerson])
|
||||
|
||||
// Apply search filter
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setFilteredPeople([])
|
||||
return
|
||||
}
|
||||
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
const filtered = people.filter(person => {
|
||||
// Extract last name from person name (matching desktop logic)
|
||||
let lastName = ''
|
||||
if (person.person_name.includes(',')) {
|
||||
lastName = person.person_name.split(',')[0].trim().toLowerCase()
|
||||
} else {
|
||||
const nameParts = person.person_name.trim().split(' ')
|
||||
if (nameParts.length > 0) {
|
||||
lastName = nameParts[nameParts.length - 1].toLowerCase()
|
||||
}
|
||||
}
|
||||
return lastName.includes(query)
|
||||
})
|
||||
|
||||
setFilteredPeople(filtered)
|
||||
setCurrentIndex(0)
|
||||
}, [searchQuery, people])
|
||||
|
||||
const startAutoMatch = async () => {
|
||||
if (tolerance < 0 || tolerance > 1) {
|
||||
alert('Please enter a valid tolerance value between 0.0 and 1.0.')
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
try {
|
||||
const response = await facesApi.autoMatch({ tolerance })
|
||||
|
||||
if (response.people.length === 0) {
|
||||
alert('🔍 No similar faces found for auto-identification')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
|
||||
setPeople(response.people)
|
||||
setFilteredPeople([])
|
||||
setCurrentIndex(0)
|
||||
setSelectedFaces({})
|
||||
setOriginalSelectedFaces({})
|
||||
setIsActive(true)
|
||||
} catch (error) {
|
||||
console.error('Auto-match failed:', error)
|
||||
alert('Failed to start auto-match. Please try again.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFaceToggle = (faceId: number) => {
|
||||
setSelectedFaces(prev => ({
|
||||
...prev,
|
||||
[faceId]: !prev[faceId],
|
||||
}))
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
const newSelected: Record<number, boolean> = {}
|
||||
currentMatches.forEach(match => {
|
||||
newSelected[match.id] = true
|
||||
})
|
||||
setSelectedFaces(newSelected)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
const newSelected: Record<number, boolean> = {}
|
||||
currentMatches.forEach(match => {
|
||||
newSelected[match.id] = false
|
||||
})
|
||||
setSelectedFaces(newSelected)
|
||||
}
|
||||
|
||||
const saveChanges = async () => {
|
||||
if (!currentPerson) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const faceIds = currentMatches
|
||||
.filter(match => selectedFaces[match.id] === true)
|
||||
.map(match => match.id)
|
||||
|
||||
await peopleApi.acceptMatches(currentPerson.person_id, faceIds)
|
||||
|
||||
// Update original selected faces to current state
|
||||
const newOriginal: Record<number, boolean> = {}
|
||||
currentMatches.forEach(match => {
|
||||
newOriginal[match.id] = selectedFaces[match.id] || false
|
||||
})
|
||||
setOriginalSelectedFaces(prev => ({ ...prev, ...newOriginal }))
|
||||
|
||||
alert(`✅ Saved ${faceIds.length} change(s)`)
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error)
|
||||
alert('Failed to save changes. Please try again.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore selected faces when navigating to a different person
|
||||
useEffect(() => {
|
||||
if (currentPerson) {
|
||||
const restored: Record<number, boolean> = {}
|
||||
currentPerson.matches.forEach(match => {
|
||||
restored[match.id] = originalSelectedFaces[match.id] || false
|
||||
})
|
||||
setSelectedFaces(restored)
|
||||
}
|
||||
}, [currentIndex, filteredPeople.length, people.length]) // Only when person changes
|
||||
|
||||
const goBack = () => {
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(currentIndex - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
|
||||
if (currentIndex < activePeople.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('')
|
||||
setFilteredPeople([])
|
||||
setCurrentIndex(0)
|
||||
}
|
||||
|
||||
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
|
||||
const canGoBack = currentIndex > 0
|
||||
const canGoNext = currentIndex < activePeople.length - 1
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Auto-Match</h1>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-gray-600">Auto-matching functionality coming in Phase 2.</p>
|
||||
<div className="flex flex-col h-full">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">🔗 Auto-Match Faces</h1>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={startAutoMatch}
|
||||
disabled={busy || isActive}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? 'Processing...' : '🚀 Start Auto-Match'}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Tolerance:</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={tolerance}
|
||||
onChange={(e) => setTolerance(parseFloat(e.target.value) || 0)}
|
||||
disabled={isActive}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">(lower = stricter matching)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<>
|
||||
{/* Main panels */}
|
||||
<div className="flex-1 grid grid-cols-2 gap-4 mb-4">
|
||||
{/* Left panel - Identified Person */}
|
||||
<div className="bg-white rounded-lg shadow p-4 flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-4">Identified Person</h2>
|
||||
|
||||
{/* Search controls */}
|
||||
<div className="mb-4">
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type Last Name"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
disabled={people.length === 1}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
disabled={people.length === 1}
|
||||
className="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{people.length === 1 && (
|
||||
<p className="text-xs text-gray-500">(Search disabled - only one person found)</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Person info */}
|
||||
{currentPerson && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<p className="font-semibold">👤 Person: {currentPerson.person_name}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
📁 Photo: {currentPerson.reference_photo_filename}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
📍 Face location: {currentPerson.reference_location}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
📊 {currentPerson.face_count} faces already identified
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Person face image */}
|
||||
<div className="mb-4 flex justify-center">
|
||||
<img
|
||||
src={`/api/v1/faces/${currentPerson.reference_face_id}/crop`}
|
||||
alt="Reference face"
|
||||
className="max-w-[300px] max-h-[300px] rounded border border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<button
|
||||
onClick={saveChanges}
|
||||
disabled={saving}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? '💾 Saving...' : `💾 Save changes for ${currentPerson.person_name}`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right panel - Unidentified Faces */}
|
||||
<div className="bg-white rounded-lg shadow p-4 flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-4">Unidentified Faces to Match</h2>
|
||||
|
||||
{/* Select All / Clear All buttons */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
disabled={currentMatches.length === 0}
|
||||
className="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
☑️ Select All
|
||||
</button>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
disabled={currentMatches.length === 0}
|
||||
className="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
☐ Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Matches grid */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{currentMatches.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">No matches found</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{currentMatches.map((match) => (
|
||||
<div
|
||||
key={match.id}
|
||||
className="flex items-center gap-3 p-2 border border-gray-200 rounded hover:bg-gray-50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFaces[match.id] || false}
|
||||
onChange={() => handleFaceToggle(match.id)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<img
|
||||
src={`/api/v1/faces/${match.id}/crop`}
|
||||
alt="Match face"
|
||||
className="w-20 h-20 object-cover rounded border border-gray-300"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-semibold ${
|
||||
match.similarity >= 70
|
||||
? 'bg-green-100 text-green-800'
|
||||
: match.similarity >= 60
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-orange-100 text-orange-800'
|
||||
}`}
|
||||
>
|
||||
{Math.round(match.similarity)}% Match
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">📁 {match.photo_filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation controls */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow p-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
>
|
||||
⏮️ Back
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={!canGoNext}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
>
|
||||
⏭️ Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Person {currentIndex + 1} of {activePeople.length}
|
||||
{currentPerson && ` • ${currentPerson.total_matches} matches`}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to exit auto-match?')) {
|
||||
setIsActive(false)
|
||||
setPeople([])
|
||||
setFilteredPeople([])
|
||||
setCurrentIndex(0)
|
||||
setSelectedFaces({})
|
||||
setOriginalSelectedFaces({})
|
||||
setSearchQuery('')
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
❌ Exit Auto-Match
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isActive && (
|
||||
<div className="bg-white rounded-lg shadow p-6 text-center text-gray-500">
|
||||
<p>Click "Start Auto-Match" to begin matching unidentified faces with identified people.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import peopleApi, { PersonWithFaces, PersonFaceItem, PersonUpdateRequest } from '../api/people'
|
||||
import facesApi from '../api/faces'
|
||||
|
||||
interface EditDialogProps {
|
||||
person: PersonWithFaces
|
||||
onSave: (personId: number, data: PersonUpdateRequest) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function EditPersonDialog({ person, onSave, onClose }: EditDialogProps) {
|
||||
const [firstName, setFirstName] = useState(person.first_name || '')
|
||||
const [lastName, setLastName] = useState(person.last_name || '')
|
||||
const [middleName, setMiddleName] = useState(person.middle_name || '')
|
||||
const [maidenName, setMaidenName] = useState(person.maiden_name || '')
|
||||
const [dob, setDob] = useState(person.date_of_birth || '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const canSave = firstName.trim() && lastName.trim()
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSave || busy) return
|
||||
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onSave(person.id, {
|
||||
first_name: firstName.trim(),
|
||||
last_name: lastName.trim(),
|
||||
middle_name: middleName.trim() || undefined,
|
||||
maiden_name: maidenName.trim() || undefined,
|
||||
date_of_birth: dob.trim() || null,
|
||||
})
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to update person')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && canSave && !busy) {
|
||||
handleSave()
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h2 className="text-xl font-bold mb-4">Edit {person.first_name} {person.last_name}</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
First name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Last name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Middle name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={middleName}
|
||||
onChange={(e) => setMiddleName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Maiden name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={maidenName}
|
||||
onChange={(e) => setMaidenName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Date of birth</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dob}
|
||||
onChange={(e) => setDob(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!canSave || busy}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Modify() {
|
||||
const [people, setPeople] = useState<PersonWithFaces[]>([])
|
||||
const [lastNameFilter, setLastNameFilter] = useState('')
|
||||
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null)
|
||||
const [selectedPersonName, setSelectedPersonName] = useState('')
|
||||
const [faces, setFaces] = useState<PersonFaceItem[]>([])
|
||||
const [unmatchedFaces, setUnmatchedFaces] = useState<Set<number>>(new Set())
|
||||
const [unmatchedByPerson, setUnmatchedByPerson] = useState<Record<number, Set<number>>>({})
|
||||
const [editDialogPerson, setEditDialogPerson] = useState<PersonWithFaces | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Load people with faces
|
||||
const loadPeople = useCallback(async () => {
|
||||
try {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
const res = await peopleApi.listWithFaces(lastNameFilter || undefined)
|
||||
setPeople(res.items)
|
||||
|
||||
// Auto-select first person if available and none selected
|
||||
if (res.items.length > 0 && !selectedPersonId) {
|
||||
const firstPerson = res.items[0]
|
||||
setSelectedPersonId(firstPerson.id)
|
||||
setSelectedPersonName(formatPersonName(firstPerson))
|
||||
loadPersonFaces(firstPerson.id)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to load people')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [lastNameFilter, selectedPersonId])
|
||||
|
||||
// Load faces for a person
|
||||
const loadPersonFaces = useCallback(async (personId: number) => {
|
||||
try {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
const res = await peopleApi.getFaces(personId)
|
||||
// Filter out unmatched faces (show only matched faces)
|
||||
const visibleFaces = res.items.filter((f) => !unmatchedFaces.has(f.id))
|
||||
setFaces(visibleFaces)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to load faces')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [unmatchedFaces])
|
||||
|
||||
useEffect(() => {
|
||||
loadPeople()
|
||||
}, [loadPeople])
|
||||
|
||||
// Reload faces when person changes
|
||||
useEffect(() => {
|
||||
if (selectedPersonId) {
|
||||
loadPersonFaces(selectedPersonId)
|
||||
}
|
||||
}, [selectedPersonId, loadPersonFaces])
|
||||
|
||||
const formatPersonName = (person: PersonWithFaces): string => {
|
||||
const parts: string[] = []
|
||||
if (person.first_name) parts.push(person.first_name)
|
||||
if (person.middle_name) parts.push(person.middle_name)
|
||||
if (person.last_name) parts.push(person.last_name)
|
||||
if (person.maiden_name) parts.push(`(${person.maiden_name})`)
|
||||
const name = parts.join(' ') || 'Unknown'
|
||||
if (person.date_of_birth) {
|
||||
return `${name} - Born: ${person.date_of_birth}`
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
loadPeople()
|
||||
}
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setLastNameFilter('')
|
||||
// loadPeople will be called by useEffect
|
||||
}
|
||||
|
||||
const handlePersonClick = (person: PersonWithFaces) => {
|
||||
setSelectedPersonId(person.id)
|
||||
setSelectedPersonName(formatPersonName(person))
|
||||
loadPersonFaces(person.id)
|
||||
}
|
||||
|
||||
const handleEditPerson = (person: PersonWithFaces) => {
|
||||
setEditDialogPerson(person)
|
||||
}
|
||||
|
||||
const handleSavePerson = async (personId: number, data: PersonUpdateRequest) => {
|
||||
await peopleApi.update(personId, data)
|
||||
// Reload people list
|
||||
await loadPeople()
|
||||
// Reload faces if this is the selected person
|
||||
if (selectedPersonId === personId) {
|
||||
await loadPersonFaces(personId)
|
||||
}
|
||||
setSuccess('Person information updated successfully')
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
}
|
||||
|
||||
const handleUnmatchFace = async (faceId: number) => {
|
||||
// Add to unmatched set (temporary, not persisted until save)
|
||||
const newUnmatched = new Set(unmatchedFaces)
|
||||
newUnmatched.add(faceId)
|
||||
setUnmatchedFaces(newUnmatched)
|
||||
|
||||
// Track by person
|
||||
if (selectedPersonId) {
|
||||
const newByPerson = { ...unmatchedByPerson }
|
||||
if (!newByPerson[selectedPersonId]) {
|
||||
newByPerson[selectedPersonId] = new Set()
|
||||
}
|
||||
newByPerson[selectedPersonId].add(faceId)
|
||||
setUnmatchedByPerson(newByPerson)
|
||||
}
|
||||
|
||||
// Immediately refresh display to hide unmatched face
|
||||
if (selectedPersonId) {
|
||||
await loadPersonFaces(selectedPersonId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUndoChanges = () => {
|
||||
if (!selectedPersonId) return
|
||||
|
||||
const personFaces = unmatchedByPerson[selectedPersonId]
|
||||
if (!personFaces || personFaces.size === 0) return
|
||||
|
||||
// Remove faces for current person from unmatched sets
|
||||
const newUnmatched = new Set(unmatchedFaces)
|
||||
for (const faceId of personFaces) {
|
||||
newUnmatched.delete(faceId)
|
||||
}
|
||||
setUnmatchedFaces(newUnmatched)
|
||||
|
||||
const newByPerson = { ...unmatchedByPerson }
|
||||
delete newByPerson[selectedPersonId]
|
||||
setUnmatchedByPerson(newByPerson)
|
||||
|
||||
// Reload faces to show restored faces
|
||||
if (selectedPersonId) {
|
||||
loadPersonFaces(selectedPersonId)
|
||||
}
|
||||
|
||||
setSuccess(`Undid changes for ${personFaces.size} face(s)`)
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
}
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
if (unmatchedFaces.size === 0) return
|
||||
|
||||
try {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
|
||||
// Batch unmatch all faces
|
||||
const faceIds = Array.from(unmatchedFaces)
|
||||
await facesApi.batchUnmatch({ face_ids: faceIds })
|
||||
|
||||
// Clear unmatched sets
|
||||
setUnmatchedFaces(new Set())
|
||||
setUnmatchedByPerson({})
|
||||
|
||||
// Reload faces to reflect changes
|
||||
if (selectedPersonId) {
|
||||
await loadPersonFaces(selectedPersonId)
|
||||
}
|
||||
|
||||
// Reload people list to update face counts
|
||||
await loadPeople()
|
||||
|
||||
setSuccess(`Successfully unlinked ${faceIds.length} face(s)`)
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to save changes')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExit = () => {
|
||||
if (unmatchedFaces.size > 0) {
|
||||
const confirmed = window.confirm(
|
||||
`You have ${unmatchedFaces.size} unsaved changes.\n\n` +
|
||||
'Do you want to save them before exiting?\n\n' +
|
||||
'OK: Save changes and exit\n' +
|
||||
'Cancel: Return to modify'
|
||||
)
|
||||
if (confirmed) {
|
||||
handleSaveChanges().then(() => {
|
||||
// Navigate to home after save
|
||||
window.location.href = '/'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
window.location.href = '/'
|
||||
}
|
||||
}
|
||||
|
||||
const visibleFaces = faces.filter((f) => !unmatchedFaces.has(f.id))
|
||||
const currentPersonHasUnmatched = selectedPersonId
|
||||
? Boolean(unmatchedByPerson[selectedPersonId]?.size)
|
||||
: false
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold">✏️ Modify Identified</h1>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded text-green-700">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Left panel: People list */}
|
||||
<div className="col-span-1">
|
||||
<div className="bg-white rounded-lg shadow p-4 h-full flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-4">People</h2>
|
||||
|
||||
{/* Search controls */}
|
||||
<div className="mb-4">
|
||||
<div className="flex gap-2 mb-1">
|
||||
<input
|
||||
type="text"
|
||||
value={lastNameFilter}
|
||||
onChange={(e) => setLastNameFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Type Last Name"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Type Last Name</p>
|
||||
</div>
|
||||
|
||||
{/* People list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{busy && people.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">Loading...</div>
|
||||
) : people.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">No people found</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{people.map((person) => {
|
||||
const isSelected = selectedPersonId === person.id
|
||||
const name = formatPersonName(person)
|
||||
return (
|
||||
<div
|
||||
key={person.id}
|
||||
className={`flex items-center gap-2 p-2 rounded hover:bg-gray-50 cursor-pointer ${
|
||||
isSelected ? 'bg-blue-50 font-semibold' : ''
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEditPerson(person)
|
||||
}}
|
||||
className="text-sm px-2 py-1 hover:bg-gray-200 rounded"
|
||||
title="Update name"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<div
|
||||
onClick={() => handlePersonClick(person)}
|
||||
className="flex-1"
|
||||
>
|
||||
{name} ({person.face_count})
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel: Faces grid */}
|
||||
<div className="col-span-2">
|
||||
<div className="bg-white rounded-lg shadow p-4 h-full flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-4">Faces</h2>
|
||||
|
||||
{selectedPersonId ? (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{busy && visibleFaces.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">Loading faces...</div>
|
||||
) : visibleFaces.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
{faces.length === 0
|
||||
? 'No faces found for this person'
|
||||
: 'All faces unmatched'}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
|
||||
>
|
||||
{visibleFaces.map((face) => (
|
||||
<div key={face.id} className="flex flex-col items-center">
|
||||
<div className="relative w-20 h-20 mb-2">
|
||||
<img
|
||||
src={`/api/v1/faces/${face.id}/crop`}
|
||||
alt={`Face ${face.id}`}
|
||||
className="w-full h-full object-cover rounded"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = '/placeholder.png'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Open photo in new window (similar to desktop photo icon)
|
||||
window.open(`/api/v1/photos/${face.photo_id}/image`, '_blank')
|
||||
}}
|
||||
className="absolute top-0 right-0 bg-white bg-opacity-80 hover:bg-opacity-100 rounded p-1 text-xs"
|
||||
title="Show original photo"
|
||||
>
|
||||
📷
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleUnmatchFace(face.id)}
|
||||
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
Unmatch
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
Select a person to view their faces
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control buttons */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={handleUndoChanges}
|
||||
disabled={!currentPersonHasUnmatched || busy}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
↶ Undo changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveChanges}
|
||||
disabled={unmatchedFaces.size === 0 || busy}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
💾 Save changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExit}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
❌ Exit Edit Identified
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Edit person dialog */}
|
||||
{editDialogPerson && (
|
||||
<EditPersonDialog
|
||||
person={editDialogPerson}
|
||||
onSave={handleSavePerson}
|
||||
onClose={() => setEditDialogPerson(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user