|
|
|
|
@ -1,6 +1,7 @@
|
|
|
|
|
import { useEffect, useState, useRef, useCallback } from 'react'
|
|
|
|
|
import peopleApi, { PersonWithFaces, PersonFaceItem, PersonVideoItem, PersonUpdateRequest } from '../api/people'
|
|
|
|
|
import facesApi from '../api/faces'
|
|
|
|
|
import videosApi from '../api/videos'
|
|
|
|
|
|
|
|
|
|
interface EditDialogProps {
|
|
|
|
|
person: PersonWithFaces
|
|
|
|
|
@ -150,13 +151,21 @@ export default function Modify() {
|
|
|
|
|
const [selectedPersonName, setSelectedPersonName] = useState('')
|
|
|
|
|
const [faces, setFaces] = useState<PersonFaceItem[]>([])
|
|
|
|
|
const [videos, setVideos] = useState<PersonVideoItem[]>([])
|
|
|
|
|
const [unmatchedFaces, setUnmatchedFaces] = useState<Set<number>>(new Set())
|
|
|
|
|
const [unmatchedByPerson, setUnmatchedByPerson] = useState<Record<number, Set<number>>>({})
|
|
|
|
|
const [unmatchConfirmDialog, setUnmatchConfirmDialog] = useState<{
|
|
|
|
|
type: 'face' | 'video'
|
|
|
|
|
single: boolean
|
|
|
|
|
faceId?: number
|
|
|
|
|
videoId?: number
|
|
|
|
|
count?: number
|
|
|
|
|
} | null>(null)
|
|
|
|
|
const [selectedFaces, setSelectedFaces] = useState<Set<number>>(new Set())
|
|
|
|
|
const [selectedVideos, setSelectedVideos] = useState<Set<number>>(new Set())
|
|
|
|
|
const [editDialogPerson, setEditDialogPerson] = useState<PersonWithFaces | null>(null)
|
|
|
|
|
const [deleteDialogPerson, setDeleteDialogPerson] = useState<PersonWithFaces | null>(null)
|
|
|
|
|
const [facesExpanded, setFacesExpanded] = useState(true)
|
|
|
|
|
const [videosExpanded, setVideosExpanded] = useState(false)
|
|
|
|
|
const [peoplePanelWidth, setPeoplePanelWidth] = useState(450) // Default width in pixels (will be set to 50% on mount)
|
|
|
|
|
const [isResizing, setIsResizing] = useState(false)
|
|
|
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
const [success, setSuccess] = useState<string | null>(null)
|
|
|
|
|
@ -192,9 +201,7 @@ export default function Modify() {
|
|
|
|
|
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)
|
|
|
|
|
setFaces(res.items)
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
// If person not found (404), clear selection instead of showing error
|
|
|
|
|
// This can happen if person was deleted after selection
|
|
|
|
|
@ -211,7 +218,7 @@ export default function Modify() {
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(false)
|
|
|
|
|
}
|
|
|
|
|
}, [unmatchedFaces])
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
// Load videos for a person
|
|
|
|
|
const loadPersonVideos = useCallback(async (personId: number) => {
|
|
|
|
|
@ -231,13 +238,57 @@ export default function Modify() {
|
|
|
|
|
loadPeople()
|
|
|
|
|
}, [loadPeople])
|
|
|
|
|
|
|
|
|
|
// Initialize panel width to 50% of container
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const container = document.querySelector('[data-modify-container]') as HTMLElement
|
|
|
|
|
if (container) {
|
|
|
|
|
const containerWidth = container.getBoundingClientRect().width
|
|
|
|
|
const initialWidth = Math.max(450, containerWidth * 0.5) // At least 450px, or 50% of container
|
|
|
|
|
setPeoplePanelWidth(initialWidth)
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
// Handle resize
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
|
|
|
if (!isResizing) return
|
|
|
|
|
const container = document.querySelector('[data-modify-container]') as HTMLElement
|
|
|
|
|
if (!container) return
|
|
|
|
|
const containerRect = container.getBoundingClientRect()
|
|
|
|
|
const newWidth = e.clientX - containerRect.left
|
|
|
|
|
const minWidth = 450 // Minimum width for People panel (current size)
|
|
|
|
|
if (newWidth >= minWidth) {
|
|
|
|
|
setPeoplePanelWidth(newWidth)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleMouseUp = () => {
|
|
|
|
|
setIsResizing(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isResizing) {
|
|
|
|
|
document.addEventListener('mousemove', handleMouseMove)
|
|
|
|
|
document.addEventListener('mouseup', handleMouseUp)
|
|
|
|
|
document.body.style.cursor = 'col-resize'
|
|
|
|
|
document.body.style.userSelect = 'none'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
document.removeEventListener('mousemove', handleMouseMove)
|
|
|
|
|
document.removeEventListener('mouseup', handleMouseUp)
|
|
|
|
|
document.body.style.cursor = ''
|
|
|
|
|
document.body.style.userSelect = ''
|
|
|
|
|
}
|
|
|
|
|
}, [isResizing])
|
|
|
|
|
|
|
|
|
|
// Reload faces and videos when person changes
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (selectedPersonId) {
|
|
|
|
|
loadPersonFaces(selectedPersonId)
|
|
|
|
|
loadPersonVideos(selectedPersonId)
|
|
|
|
|
// Clear selected faces when person changes
|
|
|
|
|
// Clear selected faces and videos when person changes
|
|
|
|
|
setSelectedFaces(new Set())
|
|
|
|
|
setSelectedVideos(new Set())
|
|
|
|
|
} else {
|
|
|
|
|
setFaces([])
|
|
|
|
|
setVideos([])
|
|
|
|
|
@ -319,33 +370,6 @@ export default function Modify() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove from selected faces
|
|
|
|
|
const newSelected = new Set(selectedFaces)
|
|
|
|
|
newSelected.delete(faceId)
|
|
|
|
|
setSelectedFaces(newSelected)
|
|
|
|
|
|
|
|
|
|
// Immediately refresh display to hide unmatched face
|
|
|
|
|
if (selectedPersonId) {
|
|
|
|
|
await loadPersonFaces(selectedPersonId)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleToggleFaceSelection = (faceId: number) => {
|
|
|
|
|
const newSelected = new Set(selectedFaces)
|
|
|
|
|
if (newSelected.has(faceId)) {
|
|
|
|
|
@ -357,7 +381,7 @@ export default function Modify() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleSelectAll = () => {
|
|
|
|
|
const allFaceIds = new Set(visibleFaces.map(f => f.id))
|
|
|
|
|
const allFaceIds = new Set(faces.map(f => f.id))
|
|
|
|
|
setSelectedFaces(allFaceIds)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -365,38 +389,38 @@ export default function Modify() {
|
|
|
|
|
setSelectedFaces(new Set())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleBulkUnmatch = async () => {
|
|
|
|
|
const handleBulkUnmatch = () => {
|
|
|
|
|
if (selectedFaces.size === 0) return
|
|
|
|
|
setUnmatchConfirmDialog({
|
|
|
|
|
type: 'face',
|
|
|
|
|
single: false,
|
|
|
|
|
count: selectedFaces.size,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const confirmBulkUnmatchFaces = async () => {
|
|
|
|
|
if (!unmatchConfirmDialog || !selectedPersonId || selectedFaces.size === 0) return
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
setBusy(true)
|
|
|
|
|
setError(null)
|
|
|
|
|
setUnmatchConfirmDialog(null)
|
|
|
|
|
|
|
|
|
|
// Add all selected faces to unmatched set
|
|
|
|
|
const newUnmatched = new Set(unmatchedFaces)
|
|
|
|
|
// Batch unmatch all selected faces
|
|
|
|
|
const faceIds = Array.from(selectedFaces)
|
|
|
|
|
faceIds.forEach(id => newUnmatched.add(id))
|
|
|
|
|
setUnmatchedFaces(newUnmatched)
|
|
|
|
|
|
|
|
|
|
// Track by person
|
|
|
|
|
if (selectedPersonId) {
|
|
|
|
|
const newByPerson = { ...unmatchedByPerson }
|
|
|
|
|
if (!newByPerson[selectedPersonId]) {
|
|
|
|
|
newByPerson[selectedPersonId] = new Set()
|
|
|
|
|
}
|
|
|
|
|
faceIds.forEach(id => newByPerson[selectedPersonId].add(id))
|
|
|
|
|
setUnmatchedByPerson(newByPerson)
|
|
|
|
|
}
|
|
|
|
|
await facesApi.batchUnmatch({ face_ids: faceIds })
|
|
|
|
|
|
|
|
|
|
// Clear selected faces
|
|
|
|
|
setSelectedFaces(new Set())
|
|
|
|
|
|
|
|
|
|
// Immediately refresh display to hide unmatched faces
|
|
|
|
|
if (selectedPersonId) {
|
|
|
|
|
await loadPersonFaces(selectedPersonId)
|
|
|
|
|
}
|
|
|
|
|
// Reload people list to update face counts
|
|
|
|
|
const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
|
|
|
|
|
setPeople(peopleRes.items)
|
|
|
|
|
|
|
|
|
|
setSuccess(`Marked ${faceIds.length} face(s) for unmatching`)
|
|
|
|
|
// Reload faces
|
|
|
|
|
await loadPersonFaces(selectedPersonId)
|
|
|
|
|
|
|
|
|
|
setSuccess(`Successfully unlinked ${faceIds.length} face(s)`)
|
|
|
|
|
setTimeout(() => setSuccess(null), 3000)
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
setError(err.response?.data?.detail || err.message || 'Failed to unmatch faces')
|
|
|
|
|
@ -405,93 +429,96 @@ export default function Modify() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
// Clear selected faces
|
|
|
|
|
setSelectedFaces(new Set())
|
|
|
|
|
|
|
|
|
|
// Reload faces to show restored faces
|
|
|
|
|
if (selectedPersonId) {
|
|
|
|
|
loadPersonFaces(selectedPersonId)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setSuccess(`Undid changes for ${personFaces.size} face(s)`)
|
|
|
|
|
setTimeout(() => setSuccess(null), 3000)
|
|
|
|
|
const handleUnmatchVideo = (videoId: number) => {
|
|
|
|
|
setUnmatchConfirmDialog({
|
|
|
|
|
type: 'video',
|
|
|
|
|
single: true,
|
|
|
|
|
videoId,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleSaveChanges = async () => {
|
|
|
|
|
if (unmatchedFaces.size === 0) return
|
|
|
|
|
const confirmUnmatchVideo = async () => {
|
|
|
|
|
if (!unmatchConfirmDialog || !selectedPersonId || !unmatchConfirmDialog.videoId) return
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
setBusy(true)
|
|
|
|
|
setError(null)
|
|
|
|
|
setUnmatchConfirmDialog(null)
|
|
|
|
|
|
|
|
|
|
// Batch unmatch all faces
|
|
|
|
|
const faceIds = Array.from(unmatchedFaces)
|
|
|
|
|
await facesApi.batchUnmatch({ face_ids: faceIds })
|
|
|
|
|
// Unmatch the single video
|
|
|
|
|
await videosApi.removePerson(unmatchConfirmDialog.videoId, selectedPersonId)
|
|
|
|
|
|
|
|
|
|
// Clear unmatched sets
|
|
|
|
|
setUnmatchedFaces(new Set())
|
|
|
|
|
setUnmatchedByPerson({})
|
|
|
|
|
setSelectedFaces(new Set())
|
|
|
|
|
|
|
|
|
|
// Reload people list first to update face counts and check if person still exists
|
|
|
|
|
// Reload people list
|
|
|
|
|
const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
|
|
|
|
|
|
|
|
|
|
// Check if selected person still exists BEFORE updating state
|
|
|
|
|
// This prevents useEffect from triggering unnecessary requests
|
|
|
|
|
const currentSelectedId = selectedPersonId
|
|
|
|
|
const personStillExists = currentSelectedId
|
|
|
|
|
? peopleRes.items.some(p => p.id === currentSelectedId)
|
|
|
|
|
: false
|
|
|
|
|
|
|
|
|
|
// Clear selection immediately if person was deleted (before state update)
|
|
|
|
|
if (currentSelectedId && !personStillExists) {
|
|
|
|
|
setSelectedPersonId(null)
|
|
|
|
|
setSelectedPersonName('')
|
|
|
|
|
setFaces([])
|
|
|
|
|
setError(null)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update people list
|
|
|
|
|
setPeople(peopleRes.items)
|
|
|
|
|
|
|
|
|
|
// Reload faces only if person still exists
|
|
|
|
|
if (currentSelectedId && personStillExists) {
|
|
|
|
|
await loadPersonFaces(currentSelectedId)
|
|
|
|
|
}
|
|
|
|
|
// Reload videos
|
|
|
|
|
await loadPersonVideos(selectedPersonId)
|
|
|
|
|
|
|
|
|
|
setSuccess(`Successfully unlinked ${faceIds.length} face(s)`)
|
|
|
|
|
setSuccess('Successfully unlinked video')
|
|
|
|
|
setTimeout(() => setSuccess(null), 3000)
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
setError(err.response?.data?.detail || err.message || 'Failed to save changes')
|
|
|
|
|
setError(err.response?.data?.detail || err.message || 'Failed to unmatch video')
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const visibleFaces = faces.filter((f) => !unmatchedFaces.has(f.id))
|
|
|
|
|
const currentPersonHasUnmatched = selectedPersonId
|
|
|
|
|
? Boolean(unmatchedByPerson[selectedPersonId]?.size)
|
|
|
|
|
: false
|
|
|
|
|
const handleToggleVideoSelection = (videoId: number) => {
|
|
|
|
|
const newSelected = new Set(selectedVideos)
|
|
|
|
|
if (newSelected.has(videoId)) {
|
|
|
|
|
newSelected.delete(videoId)
|
|
|
|
|
} else {
|
|
|
|
|
newSelected.add(videoId)
|
|
|
|
|
}
|
|
|
|
|
setSelectedVideos(newSelected)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleBulkUnmatchVideos = () => {
|
|
|
|
|
if (selectedVideos.size === 0) return
|
|
|
|
|
setUnmatchConfirmDialog({
|
|
|
|
|
type: 'video',
|
|
|
|
|
single: false,
|
|
|
|
|
count: selectedVideos.size,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const confirmBulkUnmatchVideos = async () => {
|
|
|
|
|
if (!unmatchConfirmDialog || !selectedPersonId || selectedVideos.size === 0) return
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
setBusy(true)
|
|
|
|
|
setError(null)
|
|
|
|
|
setUnmatchConfirmDialog(null)
|
|
|
|
|
|
|
|
|
|
// Unmatch all selected videos
|
|
|
|
|
const videoIds = Array.from(selectedVideos)
|
|
|
|
|
for (const videoId of videoIds) {
|
|
|
|
|
await videosApi.removePerson(videoId, selectedPersonId)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear selected videos
|
|
|
|
|
setSelectedVideos(new Set())
|
|
|
|
|
|
|
|
|
|
// Reload people list
|
|
|
|
|
const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
|
|
|
|
|
setPeople(peopleRes.items)
|
|
|
|
|
|
|
|
|
|
// Reload videos
|
|
|
|
|
await loadPersonVideos(selectedPersonId)
|
|
|
|
|
|
|
|
|
|
setSuccess(`Successfully unlinked ${videoIds.length} video(s)`)
|
|
|
|
|
setTimeout(() => setSuccess(null), 3000)
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
setError(err.response?.data?.detail || err.message || 'Failed to unmatch videos')
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col" style={{ height: 'calc(100vh - 3rem - 2rem)', overflow: 'hidden' }}>
|
|
|
|
|
<div className="flex flex-col" data-modify-container style={{ height: 'calc(100vh - 3rem - 2rem)', overflow: 'hidden' }}>
|
|
|
|
|
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="p-4 bg-red-50 border border-red-200 rounded text-red-700 flex-shrink-0">
|
|
|
|
|
@ -505,9 +532,9 @@ export default function Modify() {
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-3 gap-6 flex-1" style={{ minHeight: 0, overflow: 'hidden' }}>
|
|
|
|
|
<div className="flex flex-1" style={{ minHeight: 0, overflow: 'hidden' }}>
|
|
|
|
|
{/* Left panel: People list */}
|
|
|
|
|
<div className="col-span-1" style={{ minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
|
|
|
|
<div style={{ width: `${peoplePanelWidth}px`, minWidth: '450px', minHeight: 0, display: 'flex', flexDirection: 'column', flexShrink: 0 }}>
|
|
|
|
|
<div className="bg-white rounded-lg shadow p-4 h-full flex flex-col" style={{ minHeight: 0, overflow: 'hidden' }}>
|
|
|
|
|
<h2 className="text-lg font-semibold mb-4 flex-shrink-0">People</h2>
|
|
|
|
|
|
|
|
|
|
@ -570,7 +597,7 @@ export default function Modify() {
|
|
|
|
|
onClick={() => handlePersonClick(person)}
|
|
|
|
|
className="flex-1"
|
|
|
|
|
>
|
|
|
|
|
{name} ({person.face_count})
|
|
|
|
|
{name} ({person.face_count})({person.video_count})
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
@ -592,8 +619,31 @@ export default function Modify() {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Resize handle */}
|
|
|
|
|
<div
|
|
|
|
|
onMouseDown={() => setIsResizing(true)}
|
|
|
|
|
style={{
|
|
|
|
|
width: '4px',
|
|
|
|
|
cursor: 'col-resize',
|
|
|
|
|
backgroundColor: '#e5e7eb',
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
position: 'relative',
|
|
|
|
|
}}
|
|
|
|
|
className="hover:bg-gray-400 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
left: '-2px',
|
|
|
|
|
right: '-2px',
|
|
|
|
|
top: 0,
|
|
|
|
|
bottom: 0,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Right panel: Faces and Videos split horizontally */}
|
|
|
|
|
<div className="col-span-2" style={{ minHeight: 0, display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
|
|
|
|
<div style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', gap: '1.5rem', marginLeft: '1.5rem' }}>
|
|
|
|
|
{/* Top section: Faces grid */}
|
|
|
|
|
<div className={facesExpanded ? "flex-1" : ""} style={{ minHeight: 0, display: 'flex', flexDirection: 'column', flexShrink: facesExpanded ? 1 : 0 }}>
|
|
|
|
|
<div className={`bg-white rounded-lg shadow p-4 flex flex-col ${facesExpanded ? 'h-full' : ''}`} style={{ minHeight: 0, overflow: 'hidden' }}>
|
|
|
|
|
@ -607,7 +657,7 @@ export default function Modify() {
|
|
|
|
|
</button>
|
|
|
|
|
{selectedPersonId && (
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
{visibleFaces.length > 0 && (
|
|
|
|
|
{faces.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleSelectAll}
|
|
|
|
|
@ -632,20 +682,6 @@ export default function Modify() {
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleUndoChanges}
|
|
|
|
|
disabled={!currentPersonHasUnmatched || busy}
|
|
|
|
|
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
>
|
|
|
|
|
↶ Undo changes
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleSaveChanges}
|
|
|
|
|
disabled={unmatchedFaces.size === 0 || busy}
|
|
|
|
|
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
>
|
|
|
|
|
💾 Save changes
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
@ -654,20 +690,16 @@ export default function Modify() {
|
|
|
|
|
<>
|
|
|
|
|
{selectedPersonId ? (
|
|
|
|
|
<div className="flex-1" style={{ minHeight: 0, overflowY: 'auto' }}>
|
|
|
|
|
{busy && visibleFaces.length === 0 ? (
|
|
|
|
|
{busy && faces.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>
|
|
|
|
|
) : faces.length === 0 ? (
|
|
|
|
|
<div className="text-center text-gray-500 py-8">No faces found for this person</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) => (
|
|
|
|
|
{faces.map((face) => (
|
|
|
|
|
<div key={face.id} className="flex flex-col items-center">
|
|
|
|
|
<div className="w-20 h-20 mb-2">
|
|
|
|
|
<img
|
|
|
|
|
@ -692,7 +724,7 @@ export default function Modify() {
|
|
|
|
|
className="rounded"
|
|
|
|
|
disabled={busy}
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-xs text-gray-700">Unmatch</span>
|
|
|
|
|
<span className="text-xs text-gray-700">Select</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
@ -722,6 +754,38 @@ export default function Modify() {
|
|
|
|
|
<span className="text-lg font-semibold">Videos</span>
|
|
|
|
|
<span>{videosExpanded ? '▼' : '▶'}</span>
|
|
|
|
|
</button>
|
|
|
|
|
{selectedPersonId && videosExpanded && (
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
{videos.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const allVideoIds = new Set(videos.map(v => v.id))
|
|
|
|
|
setSelectedVideos(allVideoIds)
|
|
|
|
|
}}
|
|
|
|
|
disabled={busy}
|
|
|
|
|
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
>
|
|
|
|
|
Select All
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setSelectedVideos(new Set())}
|
|
|
|
|
disabled={busy || selectedVideos.size === 0}
|
|
|
|
|
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
>
|
|
|
|
|
Unselect All
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleBulkUnmatchVideos}
|
|
|
|
|
disabled={busy || selectedVideos.size === 0}
|
|
|
|
|
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
>
|
|
|
|
|
Unmatch Selected
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{videosExpanded && (
|
|
|
|
|
<>
|
|
|
|
|
@ -734,19 +798,44 @@ export default function Modify() {
|
|
|
|
|
{videos.map((video) => (
|
|
|
|
|
<div
|
|
|
|
|
key={video.id}
|
|
|
|
|
className="p-3 border border-gray-200 rounded hover:bg-gray-50 cursor-pointer"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
// Open video in new window or navigate to video page
|
|
|
|
|
window.open(`/api/v1/videos/${video.id}/thumbnail`, '_blank')
|
|
|
|
|
}}
|
|
|
|
|
title="Click to view video"
|
|
|
|
|
className="p-3 border border-gray-200 rounded hover:bg-gray-50"
|
|
|
|
|
>
|
|
|
|
|
<div className="font-medium text-sm truncate">{video.filename}</div>
|
|
|
|
|
{video.date_taken && (
|
|
|
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
|
|
|
{new Date(video.date_taken).toLocaleDateString()}
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={selectedVideos.has(video.id)}
|
|
|
|
|
onChange={() => handleToggleVideoSelection(video.id)}
|
|
|
|
|
className="rounded"
|
|
|
|
|
disabled={busy}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
className="flex-1 cursor-pointer"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
// Open video in new window or navigate to video page
|
|
|
|
|
window.open(`/api/v1/videos/${video.id}/thumbnail`, '_blank')
|
|
|
|
|
}}
|
|
|
|
|
title="Click to view video"
|
|
|
|
|
>
|
|
|
|
|
<div className="font-medium text-sm truncate">{video.filename}</div>
|
|
|
|
|
{video.date_taken && (
|
|
|
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
|
|
|
{new Date(video.date_taken).toLocaleDateString()}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
handleUnmatchVideo(video.id)
|
|
|
|
|
}}
|
|
|
|
|
className="px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
|
|
|
|
|
disabled={busy}
|
|
|
|
|
title="Unmatch video"
|
|
|
|
|
>
|
|
|
|
|
Unmatch
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
@ -814,6 +903,53 @@ export default function Modify() {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Unmatch confirmation dialog */}
|
|
|
|
|
{unmatchConfirmDialog && (
|
|
|
|
|
<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 text-red-600">Confirm Unmatch</h2>
|
|
|
|
|
<p className="mb-4">
|
|
|
|
|
{unmatchConfirmDialog.single
|
|
|
|
|
? `Are you sure you want to unmatch this ${unmatchConfirmDialog.type === 'face' ? 'face' : 'video'} from ${selectedPersonName || 'this person'}?`
|
|
|
|
|
: `Are you sure you want to unmatch ${unmatchConfirmDialog.count} ${unmatchConfirmDialog.type === 'face' ? 'face(s)' : 'video(s)'} from ${selectedPersonName || 'this person'}?`}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="mb-6 text-sm text-gray-600">
|
|
|
|
|
This action cannot be undone.
|
|
|
|
|
</p>
|
|
|
|
|
<div className="flex justify-end gap-3">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setUnmatchConfirmDialog(null)}
|
|
|
|
|
disabled={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"
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (unmatchConfirmDialog.type === 'face') {
|
|
|
|
|
if (unmatchConfirmDialog.single) {
|
|
|
|
|
confirmUnmatchFace()
|
|
|
|
|
} else {
|
|
|
|
|
confirmBulkUnmatchFaces()
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if (unmatchConfirmDialog.single) {
|
|
|
|
|
confirmUnmatchVideo()
|
|
|
|
|
} else {
|
|
|
|
|
confirmBulkUnmatchVideos()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
disabled={busy}
|
|
|
|
|
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
>
|
|
|
|
|
{busy ? 'Processing...' : 'Confirm'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|