feat: Update documentation and API for face identification and people management
This commit enhances the README with detailed instructions on the automatic database initialization and schema compatibility between the web and desktop versions. It also introduces new API endpoints for managing unidentified faces and people, including listing, creating, and identifying faces. The schemas for these operations have been updated to reflect the new data structures. Additionally, tests have been added to ensure the functionality of the new API features, improving overall coverage and reliability.
This commit is contained in:
@@ -15,6 +15,50 @@ export interface ProcessFacesResponse {
|
||||
model_name: string
|
||||
}
|
||||
|
||||
export interface FaceItem {
|
||||
id: number
|
||||
photo_id: number
|
||||
quality_score: number
|
||||
face_confidence: number
|
||||
location: string
|
||||
}
|
||||
|
||||
export interface UnidentifiedFacesResponse {
|
||||
items: FaceItem[]
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface SimilarFaceItem {
|
||||
id: number
|
||||
photo_id: number
|
||||
similarity: number
|
||||
location: string
|
||||
quality_score: number
|
||||
}
|
||||
|
||||
export interface SimilarFacesResponse {
|
||||
base_face_id: number
|
||||
items: SimilarFaceItem[]
|
||||
}
|
||||
|
||||
export interface IdentifyFaceRequest {
|
||||
person_id?: number
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
middle_name?: string
|
||||
maiden_name?: string
|
||||
date_of_birth?: string
|
||||
additional_face_ids?: number[]
|
||||
}
|
||||
|
||||
export interface IdentifyFaceResponse {
|
||||
identified_face_ids: number[]
|
||||
person_id: number
|
||||
created_person: boolean
|
||||
}
|
||||
|
||||
export const facesApi = {
|
||||
/**
|
||||
* Start face processing job
|
||||
@@ -23,6 +67,28 @@ export const facesApi = {
|
||||
const response = await apiClient.post<ProcessFacesResponse>('/api/v1/faces/process', request)
|
||||
return response.data
|
||||
},
|
||||
getUnidentified: async (params: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
min_quality?: number
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
sort_by?: 'quality' | 'date_taken' | 'date_added'
|
||||
sort_dir?: 'asc' | 'desc'
|
||||
}): Promise<UnidentifiedFacesResponse> => {
|
||||
const response = await apiClient.get<UnidentifiedFacesResponse>('/api/v1/faces/unidentified', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
getSimilar: async (faceId: number): Promise<SimilarFacesResponse> => {
|
||||
const response = await apiClient.get<SimilarFacesResponse>(`/api/v1/faces/${faceId}/similar`)
|
||||
return response.data
|
||||
},
|
||||
identify: async (faceId: number, payload: IdentifyFaceRequest): Promise<IdentifyFaceResponse> => {
|
||||
const response = await apiClient.post<IdentifyFaceResponse>(`/api/v1/faces/${faceId}/identify`, payload)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default facesApi
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface Person {
|
||||
id: number
|
||||
first_name: string
|
||||
last_name: string
|
||||
middle_name?: string | null
|
||||
maiden_name?: string | null
|
||||
date_of_birth?: string | null
|
||||
}
|
||||
|
||||
export interface PeopleListResponse {
|
||||
items: Person[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface PersonCreateRequest {
|
||||
first_name: string
|
||||
last_name: string
|
||||
middle_name?: string
|
||||
maiden_name?: string
|
||||
date_of_birth: string
|
||||
}
|
||||
|
||||
export const peopleApi = {
|
||||
list: async (): Promise<PeopleListResponse> => {
|
||||
const res = await apiClient.get<PeopleListResponse>('/api/v1/people')
|
||||
return res.data
|
||||
},
|
||||
create: async (payload: PersonCreateRequest): Promise<Person> => {
|
||||
const res = await apiClient.post<Person>('/api/v1/people', payload)
|
||||
return res.data
|
||||
},
|
||||
}
|
||||
|
||||
export default peopleApi
|
||||
|
||||
|
||||
@@ -1,11 +1,478 @@
|
||||
import { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import facesApi, { FaceItem, SimilarFaceItem } from '../api/faces'
|
||||
import peopleApi, { Person } from '../api/people'
|
||||
import { apiClient } from '../api/client'
|
||||
|
||||
type SortBy = 'quality' | 'date_taken' | 'date_added'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
export default function Identify() {
|
||||
const [faces, setFaces] = useState<FaceItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(50)
|
||||
const [minQuality, setMinQuality] = useState(0.0)
|
||||
const [sortBy, setSortBy] = useState<SortBy>('quality')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
||||
const [dateFrom, setDateFrom] = useState<string>('')
|
||||
const [dateTo, setDateTo] = useState<string>('')
|
||||
|
||||
const [currentIdx, setCurrentIdx] = useState(0)
|
||||
const currentFace = faces[currentIdx]
|
||||
|
||||
const [similar, setSimilar] = useState<SimilarFaceItem[]>([])
|
||||
const [compareEnabled, setCompareEnabled] = useState(true)
|
||||
const [selectedSimilar, setSelectedSimilar] = useState<Record<number, boolean>>({})
|
||||
|
||||
const [people, setPeople] = useState<Person[]>([])
|
||||
const [personId, setPersonId] = useState<number | undefined>(undefined)
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [middleName, setMiddleName] = useState('')
|
||||
const [maidenName, setMaidenName] = useState('')
|
||||
const [dob, setDob] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
// Store form data per face ID (matching desktop behavior)
|
||||
const [faceFormData, setFaceFormData] = useState<Record<number, {
|
||||
personId?: number
|
||||
firstName: string
|
||||
lastName: string
|
||||
middleName: string
|
||||
maidenName: string
|
||||
dob: string
|
||||
}>>({})
|
||||
|
||||
// Track previous face ID to save data on navigation
|
||||
const prevFaceIdRef = useRef<number | undefined>(undefined)
|
||||
|
||||
const canIdentify = useMemo(() => {
|
||||
return Boolean((personId && currentFace) || (firstName && lastName && dob && currentFace))
|
||||
}, [personId, firstName, lastName, dob, currentFace])
|
||||
|
||||
const loadFaces = async () => {
|
||||
const res = await facesApi.getUnidentified({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
min_quality: minQuality,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
sort_by: sortBy,
|
||||
sort_dir: sortDir,
|
||||
})
|
||||
setFaces(res.items)
|
||||
setTotal(res.total)
|
||||
setCurrentIdx(0)
|
||||
}
|
||||
|
||||
const loadPeople = async () => {
|
||||
const res = await peopleApi.list()
|
||||
setPeople(res.items)
|
||||
}
|
||||
|
||||
const loadSimilar = async (faceId: number) => {
|
||||
if (!compareEnabled) {
|
||||
setSimilar([])
|
||||
return
|
||||
}
|
||||
const res = await facesApi.getSimilar(faceId)
|
||||
setSimilar(res.items)
|
||||
setSelectedSimilar({})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFaces()
|
||||
loadPeople()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentFace) loadSimilar(currentFace.id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentFace?.id, compareEnabled])
|
||||
|
||||
// Save form data whenever fields change (for current face)
|
||||
useEffect(() => {
|
||||
if (!currentFace) return
|
||||
|
||||
setFaceFormData((prev) => ({
|
||||
...prev,
|
||||
[currentFace.id]: {
|
||||
personId,
|
||||
firstName,
|
||||
lastName,
|
||||
middleName,
|
||||
maidenName,
|
||||
dob,
|
||||
},
|
||||
}))
|
||||
}, [currentFace?.id, personId, firstName, lastName, middleName, maidenName, dob])
|
||||
|
||||
// Restore form data when face changes (matching desktop behavior)
|
||||
useEffect(() => {
|
||||
if (!currentFace) {
|
||||
// Clear form when no face
|
||||
setPersonId(undefined)
|
||||
setFirstName('')
|
||||
setLastName('')
|
||||
setMiddleName('')
|
||||
setMaidenName('')
|
||||
setDob('')
|
||||
prevFaceIdRef.current = undefined
|
||||
return
|
||||
}
|
||||
|
||||
// Don't restore if we're just setting the initial face
|
||||
if (prevFaceIdRef.current === currentFace.id) {
|
||||
return
|
||||
}
|
||||
|
||||
// Restore saved form data for this face
|
||||
const saved = faceFormData[currentFace.id]
|
||||
if (saved) {
|
||||
setPersonId(saved.personId)
|
||||
setFirstName(saved.firstName)
|
||||
setLastName(saved.lastName)
|
||||
setMiddleName(saved.middleName)
|
||||
setMaidenName(saved.maidenName)
|
||||
setDob(saved.dob)
|
||||
} else {
|
||||
// No saved data - clear form
|
||||
setPersonId(undefined)
|
||||
setFirstName('')
|
||||
setLastName('')
|
||||
setMiddleName('')
|
||||
setMaidenName('')
|
||||
setDob('')
|
||||
}
|
||||
|
||||
prevFaceIdRef.current = currentFace.id
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentFace?.id]) // Only restore when face ID changes
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key.toLowerCase() === 'j') {
|
||||
setCurrentIdx((i) => Math.min(i + 1, Math.max(0, faces.length - 1)))
|
||||
} else if (e.key.toLowerCase() === 'k') {
|
||||
setCurrentIdx((i) => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter' && canIdentify) {
|
||||
handleIdentify()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [faces.length, canIdentify])
|
||||
|
||||
const handleIdentify = async () => {
|
||||
if (!currentFace) return
|
||||
setBusy(true)
|
||||
const additional = Object.entries(selectedSimilar)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => Number(k))
|
||||
try {
|
||||
const payload: any = { additional_face_ids: additional }
|
||||
if (personId) {
|
||||
payload.person_id = personId
|
||||
} else {
|
||||
payload.first_name = firstName
|
||||
payload.last_name = lastName
|
||||
payload.middle_name = middleName || undefined
|
||||
payload.maiden_name = maidenName || undefined
|
||||
payload.date_of_birth = dob
|
||||
}
|
||||
await facesApi.identify(currentFace.id, payload)
|
||||
// Optimistic: remove identified faces from list
|
||||
const identifiedSet = new Set([currentFace.id, ...additional])
|
||||
const remaining = faces.filter((f) => !identifiedSet.has(f.id))
|
||||
setFaces(remaining)
|
||||
setCurrentIdx((i) => Math.min(i, Math.max(0, remaining.length - 1)))
|
||||
setSimilar([])
|
||||
setSelectedSimilar({})
|
||||
|
||||
// Remove form data for identified faces (they're gone from the list)
|
||||
setFaceFormData((prev) => {
|
||||
const updated = { ...prev }
|
||||
identifiedSet.forEach((faceId) => delete updated[faceId])
|
||||
return updated
|
||||
})
|
||||
|
||||
// Refresh people list if we created a new person
|
||||
if (!personId) {
|
||||
loadPeople()
|
||||
}
|
||||
|
||||
// Don't clear form - let the useEffect handle restoring/clearing when face changes
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const currentInfo = useMemo(() => {
|
||||
if (!currentFace) return ''
|
||||
return `Face ${currentIdx + 1} of ${faces.length}`
|
||||
}, [currentFace, currentIdx, faces.length])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Identify</h1>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-gray-600">Face identification workflow coming in Phase 2.</p>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Left: Controls and current face */}
|
||||
<div className="col-span-4">
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Min Quality</label>
|
||||
<input type="range" min={0} max={1} step={0.05} value={minQuality}
|
||||
onChange={(e) => setMinQuality(parseFloat(e.target.value))} className="w-full" />
|
||||
<div className="text-xs text-gray-500">{(minQuality * 100).toFixed(0)}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Batch Size</label>
|
||||
<select value={pageSize} onChange={(e) => setPageSize(parseInt(e.target.value))}
|
||||
className="mt-1 block w-full border rounded px-2 py-1">
|
||||
{[25, 50, 100, 200].map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Date From</label>
|
||||
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="mt-1 block w-full border rounded px-2 py-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Date To</label>
|
||||
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
|
||||
className="mt-1 block w-full border rounded px-2 py-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Sort By</label>
|
||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as SortBy)}
|
||||
className="mt-1 block w-full border rounded px-2 py-1">
|
||||
<option value="quality">Quality</option>
|
||||
<option value="date_taken">Date Taken</option>
|
||||
<option value="date_added">Date Processed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Sort Dir</label>
|
||||
<select value={sortDir} onChange={(e) => setSortDir(e.target.value as SortDir)}
|
||||
className="mt-1 block w-full border rounded px-2 py-1">
|
||||
<option value="desc">Desc</option>
|
||||
<option value="asc">Asc</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-gray-600">{currentInfo}</div>
|
||||
<div className="space-x-2">
|
||||
<button className="px-2 py-1 text-sm border rounded" onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))}>Prev (K)</button>
|
||||
<button className="px-2 py-1 text-sm border rounded" onClick={() => setCurrentIdx((i) => Math.min(faces.length - 1, i + 1))}>Next (J)</button>
|
||||
</div>
|
||||
</div>
|
||||
{!currentFace ? (
|
||||
<div className="text-gray-500">No faces to identify.</div>
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
className="aspect-video bg-gray-100 rounded mb-3 overflow-hidden flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => {
|
||||
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${currentFace.photo_id}/image`
|
||||
window.open(photoUrl, '_blank')
|
||||
}}
|
||||
title="Click to open full photo"
|
||||
>
|
||||
<img
|
||||
key={currentFace.id}
|
||||
src={`${apiClient.defaults.baseURL}/api/v1/faces/${currentFace.id}/crop?t=${Date.now()}`}
|
||||
alt={`Face ${currentFace.id}`}
|
||||
className="max-w-full max-h-full object-contain pointer-events-none"
|
||||
crossOrigin="anonymous"
|
||||
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 error-fallback'
|
||||
fallback.textContent = `Photo #${currentFace.photo_id}`
|
||||
parent.appendChild(fallback)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">Select Existing Person (optional)</label>
|
||||
<select value={personId ?? ''} onChange={(e) => {
|
||||
const val = e.target.value ? parseInt(e.target.value) : undefined
|
||||
setPersonId(val)
|
||||
// Populate fields with selected person's data
|
||||
if (val) {
|
||||
const selectedPerson = people.find(p => p.id === val)
|
||||
if (selectedPerson) {
|
||||
setFirstName(selectedPerson.first_name || '')
|
||||
setLastName(selectedPerson.last_name || '')
|
||||
setMiddleName(selectedPerson.middle_name || '')
|
||||
setMaidenName(selectedPerson.maiden_name || '')
|
||||
setDob(selectedPerson.date_of_birth || '')
|
||||
}
|
||||
} else {
|
||||
// Clear fields when selection is cleared
|
||||
setFirstName('')
|
||||
setLastName('')
|
||||
setMiddleName('')
|
||||
setMaidenName('')
|
||||
setDob('')
|
||||
}
|
||||
}}
|
||||
className="mt-1 block w-full border rounded px-2 py-1">
|
||||
<option value="">— Or create new person below —</option>
|
||||
{people.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.last_name}, {p.first_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2 border-t pt-2 mt-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Create New Person</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">First Name *</label>
|
||||
<input value={firstName} onChange={(e) => {
|
||||
setFirstName(e.target.value)
|
||||
setPersonId(undefined) // Clear person selection when typing
|
||||
}}
|
||||
className="mt-1 block w-full border rounded px-2 py-1"
|
||||
readOnly={!!personId}
|
||||
disabled={!!personId} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Last Name *</label>
|
||||
<input value={lastName} onChange={(e) => {
|
||||
setLastName(e.target.value)
|
||||
setPersonId(undefined) // Clear person selection when typing
|
||||
}}
|
||||
className="mt-1 block w-full border rounded px-2 py-1"
|
||||
readOnly={!!personId}
|
||||
disabled={!!personId} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Middle Name</label>
|
||||
<input value={middleName} onChange={(e) => {
|
||||
setMiddleName(e.target.value)
|
||||
setPersonId(undefined) // Clear person selection when typing
|
||||
}}
|
||||
className="mt-1 block w-full border rounded px-2 py-1"
|
||||
readOnly={!!personId}
|
||||
disabled={!!personId} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Maiden Name</label>
|
||||
<input value={maidenName} onChange={(e) => {
|
||||
setMaidenName(e.target.value)
|
||||
setPersonId(undefined) // Clear person selection when typing
|
||||
}}
|
||||
className="mt-1 block w-full border rounded px-2 py-1"
|
||||
readOnly={!!personId}
|
||||
disabled={!!personId} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">Date of Birth *</label>
|
||||
<input type="date" value={dob} onChange={(e) => {
|
||||
setDob(e.target.value)
|
||||
setPersonId(undefined) // Clear person selection when typing
|
||||
}}
|
||||
className="mt-1 block w-full border rounded px-2 py-1"
|
||||
readOnly={!!personId}
|
||||
disabled={!!personId} />
|
||||
</div>
|
||||
<div className="col-span-2 flex gap-2 mt-2">
|
||||
<button disabled={!canIdentify || busy}
|
||||
onClick={handleIdentify}
|
||||
className={`px-3 py-2 rounded text-white ${canIdentify && !busy ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-gray-400 cursor-not-allowed'}`}>
|
||||
{busy ? 'Identifying...' : 'Identify (Enter)'}
|
||||
</button>
|
||||
<button onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))} className="px-3 py-2 rounded border">Back (K)</button>
|
||||
<button onClick={() => setCurrentIdx((i) => Math.min(faces.length - 1, i + 1))} className="px-3 py-2 rounded border">Next (J)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Similar faces */}
|
||||
<div className="col-span-8">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input id="compare" type="checkbox" checked={compareEnabled}
|
||||
onChange={(e) => setCompareEnabled(e.target.checked)} />
|
||||
<label htmlFor="compare" className="text-sm text-gray-700">Compare with similar faces</label>
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<button className="px-2 py-1 text-sm border rounded"
|
||||
onClick={() => setSelectedSimilar(Object.fromEntries(similar.map(s => [s.id, true])))}
|
||||
disabled={!compareEnabled || similar.length === 0}>Select All</button>
|
||||
<button className="px-2 py-1 text-sm border rounded"
|
||||
onClick={() => setSelectedSimilar({})}
|
||||
disabled={!compareEnabled || similar.length === 0}>Clear All</button>
|
||||
</div>
|
||||
</div>
|
||||
{!compareEnabled ? (
|
||||
<div className="text-gray-500">Comparison disabled.</div>
|
||||
) : similar.length === 0 ? (
|
||||
<div className="text-gray-500">No similar faces.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
{similar.map((s) => (
|
||||
<label key={s.id} className="border rounded p-2 flex flex-col gap-2 cursor-pointer">
|
||||
<div
|
||||
className="aspect-square bg-gray-100 rounded overflow-hidden flex items-center justify-center relative group"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation() // Prevent triggering checkbox
|
||||
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${s.photo_id}/image`
|
||||
window.open(photoUrl, '_blank')
|
||||
}}
|
||||
title="Click to open full photo"
|
||||
>
|
||||
<img
|
||||
src={`${apiClient.defaults.baseURL}/api/v1/faces/${s.id}/crop?t=${Date.now()}`}
|
||||
alt={`Face ${s.id}`}
|
||||
className="max-w-full max-h-full object-contain pointer-events-none"
|
||||
crossOrigin="anonymous"
|
||||
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 = `#${s.photo_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>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox" checked={!!selectedSimilar[s.id]}
|
||||
onChange={(e) => setSelectedSimilar((prev) => ({ ...prev, [s.id]: e.target.checked }))} />
|
||||
<div className="text-gray-600">{Math.round(s.similarity * 100)}%</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user