/** * FaceGallery.jsx — Enrolled person management. * * Services: * /social/enrollment/list_persons (ListPersons.srv) * /social/enrollment/enroll_person (EnrollPerson.srv) * /social/enrollment/delete_person (DeletePerson.srv) * * Topics (live detections): * /social/face_detections (FaceDetectionArray) — optional, shows live bboxes */ import { useEffect, useState, useCallback } from 'react'; function PersonCard({ person, onDelete, liveDetection }) { const [deleting, setDeleting] = useState(false); const enrolledAt = person.enrolled_at ? new Date(person.enrolled_at.sec * 1000).toLocaleDateString() : '—'; const handleDelete = async () => { if (!confirm(`Delete ${person.person_name}?`)) return; setDeleting(true); try { await onDelete(person.person_id); } finally { setDeleting(false); } }; const isActive = liveDetection?.face_id === person.person_id; return (
{/* Avatar placeholder */}
{isActive && (
)}
{/* Info */}
{person.person_name}
ID: {person.person_id}
Samples: {person.sample_count}
Enrolled: {enrolledAt}
{/* Delete */}
); } export function FaceGallery({ subscribe, callService }) { const [persons, setPersons] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [enrolling, setEnrolling] = useState(false); const [newName, setNewName] = useState(''); const [newSamples, setNewSamples] = useState(5); const [liveDetections, setLiveDetections] = useState([]); const loadPersons = useCallback(async () => { setLoading(true); setError(null); try { const result = await callService( '/social/enrollment/list_persons', 'saltybot_social_msgs/srv/ListPersons', {} ); setPersons(result?.persons ?? []); } catch (e) { setError('Service unavailable: ' + (e?.message ?? e)); } finally { setLoading(false); } }, [callService]); useEffect(() => { loadPersons(); }, [loadPersons]); // Live face detections useEffect(() => { const unsub = subscribe( '/social/face_detections', 'saltybot_social_msgs/FaceDetectionArray', (msg) => setLiveDetections(msg.faces ?? []) ); return unsub; }, [subscribe]); const handleEnroll = async () => { if (!newName.trim()) return; setEnrolling(true); setError(null); try { const result = await callService( '/social/enrollment/enroll_person', 'saltybot_social_msgs/srv/EnrollPerson', { name: newName.trim(), mode: 'capture', n_samples: newSamples } ); if (result?.success) { setNewName(''); await loadPersons(); } else { setError(result?.message ?? 'Enrollment failed'); } } catch (e) { setError('Enroll error: ' + (e?.message ?? e)); } finally { setEnrolling(false); } }; const handleDelete = async (personId) => { try { await callService( '/social/enrollment/delete_person', 'saltybot_social_msgs/srv/DeletePerson', { person_id: personId } ); await loadPersons(); } catch (e) { setError('Delete error: ' + (e?.message ?? e)); } }; return (
{/* Enroll new person */}
ENROLL NEW PERSON
setNewName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleEnroll()} className="flex-1 bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-cyan-700" />
Samples: setNewSamples(Number(e.target.value))} className="w-16 bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-cyan-700" />
{enrolling && (
Capturing face samples — look at the camera…
)}
{/* Error */} {error && (
{error}
)} {/* Gallery header */}
ENROLLED PERSONS ({persons.length})
{liveDetections.length > 0 && (
{liveDetections.length} detected
)}
{/* Grid */} {loading && persons.length === 0 ? (
Loading…
) : persons.length === 0 ? (
No enrolled persons
) : (
{persons.map((p) => ( d.face_id === p.person_id)} /> ))}
)}
); }