feat: Add video management features and API endpoints for person identification
This commit introduces several enhancements related to video management, including the addition of a new API for handling video-person identifications. The frontend has been updated to support video listing, person identification in videos, and the ability to remove person identifications. A new database table, photo_person_linkage, has been created to manage the relationships between videos and identified persons. Additionally, video thumbnail generation has been implemented, improving the user experience when interacting with video content. Documentation has been updated to reflect these changes.
This commit is contained in:
@@ -62,10 +62,17 @@ export const peopleApi = {
|
||||
const res = await apiClient.get<PersonFacesResponse>(`/api/v1/people/${personId}/faces`)
|
||||
return res.data
|
||||
},
|
||||
getVideos: async (personId: number): Promise<PersonVideosResponse> => {
|
||||
const res = await apiClient.get<PersonVideosResponse>(`/api/v1/people/${personId}/videos`)
|
||||
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
|
||||
},
|
||||
delete: async (personId: number): Promise<void> => {
|
||||
await apiClient.delete(`/api/v1/people/${personId}`)
|
||||
},
|
||||
}
|
||||
|
||||
export interface IdentifyFaceResponse {
|
||||
@@ -92,6 +99,21 @@ export interface PersonFacesResponse {
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface PersonVideoItem {
|
||||
id: number
|
||||
filename: string
|
||||
path: string
|
||||
date_taken: string | null
|
||||
date_added: string
|
||||
linkage_id: number
|
||||
}
|
||||
|
||||
export interface PersonVideosResponse {
|
||||
person_id: number
|
||||
items: PersonVideoItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export default peopleApi
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface PersonInfo {
|
||||
id: number
|
||||
first_name: string
|
||||
last_name: string
|
||||
middle_name?: string | null
|
||||
maiden_name?: string | null
|
||||
date_of_birth?: string | null
|
||||
}
|
||||
|
||||
export interface VideoListItem {
|
||||
id: number
|
||||
filename: string
|
||||
path: string
|
||||
date_taken: string | null
|
||||
date_added: string
|
||||
identified_people: PersonInfo[]
|
||||
identified_people_count: number
|
||||
}
|
||||
|
||||
export interface ListVideosResponse {
|
||||
items: VideoListItem[]
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface VideoPersonInfo {
|
||||
person_id: number
|
||||
first_name: string
|
||||
last_name: string
|
||||
middle_name?: string | null
|
||||
maiden_name?: string | null
|
||||
date_of_birth?: string | null
|
||||
identified_by: string | null
|
||||
identified_date: string
|
||||
}
|
||||
|
||||
export interface VideoPeopleResponse {
|
||||
video_id: number
|
||||
people: VideoPersonInfo[]
|
||||
}
|
||||
|
||||
export interface IdentifyVideoRequest {
|
||||
person_id?: number
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
middle_name?: string
|
||||
maiden_name?: string
|
||||
date_of_birth?: string | null
|
||||
}
|
||||
|
||||
export interface IdentifyVideoResponse {
|
||||
video_id: number
|
||||
person_id: number
|
||||
created_person: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface RemoveVideoPersonResponse {
|
||||
video_id: number
|
||||
person_id: number
|
||||
removed: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export const videosApi = {
|
||||
listVideos: async (params: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
folder_path?: string
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
has_people?: boolean
|
||||
person_name?: string
|
||||
sort_by?: string
|
||||
sort_dir?: string
|
||||
}): Promise<ListVideosResponse> => {
|
||||
const res = await apiClient.get<ListVideosResponse>('/api/v1/videos', { params })
|
||||
return res.data
|
||||
},
|
||||
|
||||
getVideoPeople: async (videoId: number): Promise<VideoPeopleResponse> => {
|
||||
const res = await apiClient.get<VideoPeopleResponse>(`/api/v1/videos/${videoId}/people`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
identifyPerson: async (
|
||||
videoId: number,
|
||||
request: IdentifyVideoRequest
|
||||
): Promise<IdentifyVideoResponse> => {
|
||||
const res = await apiClient.post<IdentifyVideoResponse>(
|
||||
`/api/v1/videos/${videoId}/identify`,
|
||||
request
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
removePerson: async (
|
||||
videoId: number,
|
||||
personId: number
|
||||
): Promise<RemoveVideoPersonResponse> => {
|
||||
const res = await apiClient.delete<RemoveVideoPersonResponse>(
|
||||
`/api/v1/videos/${videoId}/people/${personId}`
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
getThumbnailUrl: (videoId: number): string => {
|
||||
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
|
||||
return `${baseURL}/api/v1/videos/${videoId}/thumbnail`
|
||||
},
|
||||
|
||||
getVideoUrl: (videoId: number): string => {
|
||||
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
|
||||
return `${baseURL}/api/v1/videos/${videoId}/video`
|
||||
},
|
||||
}
|
||||
|
||||
export default videosApi
|
||||
|
||||
@@ -4,6 +4,7 @@ import facesApi, { FaceItem, SimilarFaceItem } from '../api/faces'
|
||||
import peopleApi, { Person } from '../api/people'
|
||||
import { apiClient } from '../api/client'
|
||||
import tagsApi, { TagResponse } from '../api/tags'
|
||||
import videosApi, { VideoListItem, VideoPersonInfo, IdentifyVideoRequest } from '../api/videos'
|
||||
import { useDeveloperMode } from '../context/DeveloperModeContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import pendingIdentificationsApi, {
|
||||
@@ -76,6 +77,33 @@ export default function Identify() {
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState<'faces' | 'videos'>('faces')
|
||||
|
||||
// Video identification state
|
||||
const [videos, setVideos] = useState<VideoListItem[]>([])
|
||||
const [videosTotal, setVideosTotal] = useState(0)
|
||||
const [videosPage, setVideosPage] = useState(1)
|
||||
const [videosPageSize, setVideosPageSize] = useState(50)
|
||||
const [videosLoading, setVideosLoading] = useState(false)
|
||||
const [selectedVideo, setSelectedVideo] = useState<VideoListItem | null>(null)
|
||||
const [videoPeople, setVideoPeople] = useState<VideoPersonInfo[]>([])
|
||||
const [videoPeopleLoading, setVideoPeopleLoading] = useState(false)
|
||||
const [videosFolderFilter, setVideosFolderFilter] = useState<string>('')
|
||||
const [videosDateFrom, setVideosDateFrom] = useState<string>('')
|
||||
const [videosDateTo, setVideosDateTo] = useState<string>('')
|
||||
const [videosHasPeople, setVideosHasPeople] = useState<boolean | undefined>(undefined)
|
||||
const [videosPersonName, setVideosPersonName] = useState<string>('')
|
||||
const [videosSortBy, setVideosSortBy] = useState<string>('filename')
|
||||
const [videosSortDir, setVideosSortDir] = useState<string>('asc')
|
||||
const [videosFiltersCollapsed, setVideosFiltersCollapsed] = useState(true)
|
||||
|
||||
// Person identification form state
|
||||
const [videoPersonId, setVideoPersonId] = useState<number | undefined>(undefined)
|
||||
const [videoFirstName, setVideoFirstName] = useState('')
|
||||
const [videoLastName, setVideoLastName] = useState('')
|
||||
const [videoMiddleName, setVideoMiddleName] = useState('')
|
||||
const [videoMaidenName, setVideoMaidenName] = useState('')
|
||||
const [videoDob, setVideoDob] = useState('')
|
||||
const [videoIdentifying, setVideoIdentifying] = useState(false)
|
||||
|
||||
// Store form data per face ID (matching desktop behavior)
|
||||
const [faceFormData, setFaceFormData] = useState<Record<number, {
|
||||
personId?: number
|
||||
@@ -694,6 +722,146 @@ export default function Identify() {
|
||||
return `Face ${currentIdx + 1} of ${faces.length}`
|
||||
}, [currentFace, currentIdx, faces.length])
|
||||
|
||||
// Video functions
|
||||
const loadVideos = async () => {
|
||||
setVideosLoading(true)
|
||||
try {
|
||||
const response = await videosApi.listVideos({
|
||||
page: videosPage,
|
||||
page_size: videosPageSize,
|
||||
folder_path: videosFolderFilter || undefined,
|
||||
date_from: videosDateFrom || undefined,
|
||||
date_to: videosDateTo || undefined,
|
||||
has_people: videosHasPeople,
|
||||
person_name: videosPersonName || undefined,
|
||||
sort_by: videosSortBy,
|
||||
sort_dir: videosSortDir,
|
||||
})
|
||||
setVideos(response.items)
|
||||
setVideosTotal(response.total)
|
||||
} catch (error) {
|
||||
console.error('Failed to load videos:', error)
|
||||
alert('Failed to load videos. Please try again.')
|
||||
} finally {
|
||||
setVideosLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadVideoPeople = async (videoId: number) => {
|
||||
setVideoPeopleLoading(true)
|
||||
try {
|
||||
const response = await videosApi.getVideoPeople(videoId)
|
||||
setVideoPeople(response.people)
|
||||
} catch (error) {
|
||||
console.error('Failed to load video people:', error)
|
||||
alert('Failed to load people for this video.')
|
||||
} finally {
|
||||
setVideoPeopleLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVideoSelect = async (video: VideoListItem) => {
|
||||
setSelectedVideo(video)
|
||||
await loadVideoPeople(video.id)
|
||||
// Clear form
|
||||
setVideoPersonId(undefined)
|
||||
setVideoFirstName('')
|
||||
setVideoLastName('')
|
||||
setVideoMiddleName('')
|
||||
setVideoMaidenName('')
|
||||
setVideoDob('')
|
||||
}
|
||||
|
||||
const handleVideoIdentify = async () => {
|
||||
if (!selectedVideo) return
|
||||
|
||||
const trimmedFirstName = videoFirstName.trim()
|
||||
const trimmedLastName = videoLastName.trim()
|
||||
const trimmedMiddleName = videoMiddleName.trim()
|
||||
const trimmedMaidenName = videoMaidenName.trim()
|
||||
const trimmedDob = videoDob.trim()
|
||||
|
||||
if (!videoPersonId && (!trimmedFirstName || !trimmedLastName)) {
|
||||
alert('Please select an existing person or enter first name and last name.')
|
||||
return
|
||||
}
|
||||
|
||||
setVideoIdentifying(true)
|
||||
try {
|
||||
const payload: IdentifyVideoRequest = {}
|
||||
if (videoPersonId) {
|
||||
payload.person_id = videoPersonId
|
||||
} else {
|
||||
payload.first_name = trimmedFirstName
|
||||
payload.last_name = trimmedLastName
|
||||
if (trimmedMiddleName) {
|
||||
payload.middle_name = trimmedMiddleName
|
||||
}
|
||||
if (trimmedMaidenName) {
|
||||
payload.maiden_name = trimmedMaidenName
|
||||
}
|
||||
if (trimmedDob) {
|
||||
payload.date_of_birth = trimmedDob
|
||||
}
|
||||
}
|
||||
|
||||
await videosApi.identifyPerson(selectedVideo.id, payload)
|
||||
|
||||
// Refresh video people list
|
||||
await loadVideoPeople(selectedVideo.id)
|
||||
|
||||
// Refresh videos list to update people count
|
||||
await loadVideos()
|
||||
|
||||
// Refresh people list if we created a new person
|
||||
if (!videoPersonId) {
|
||||
loadPeople()
|
||||
}
|
||||
|
||||
// Clear form
|
||||
setVideoPersonId(undefined)
|
||||
setVideoFirstName('')
|
||||
setVideoLastName('')
|
||||
setVideoMiddleName('')
|
||||
setVideoMaidenName('')
|
||||
setVideoDob('')
|
||||
} catch (error: any) {
|
||||
console.error('Failed to identify person in video:', error)
|
||||
alert(error.response?.data?.detail || 'Failed to identify person in video. Please try again.')
|
||||
} finally {
|
||||
setVideoIdentifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemovePersonFromVideo = async (personId: number) => {
|
||||
if (!selectedVideo) return
|
||||
|
||||
if (!confirm('Remove this person from the video?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await videosApi.removePerson(selectedVideo.id, personId)
|
||||
|
||||
// Refresh video people list
|
||||
await loadVideoPeople(selectedVideo.id)
|
||||
|
||||
// Refresh videos list to update people count
|
||||
await loadVideos()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to remove person from video:', error)
|
||||
alert(error.response?.data?.detail || 'Failed to remove person from video. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
// Load videos when tab is active
|
||||
useEffect(() => {
|
||||
if (activeTab === 'videos') {
|
||||
loadVideos()
|
||||
loadPeople() // Load people for the dropdown
|
||||
}
|
||||
}, [activeTab, videosPage, videosPageSize, videosFolderFilter, videosDateFrom, videosDateTo, videosHasPeople, videosPersonName, videosSortBy, videosSortDir])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{photoIds && (
|
||||
@@ -1329,13 +1497,397 @@ export default function Identify() {
|
||||
)}
|
||||
|
||||
{activeTab === 'videos' && (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-gray-700 mb-2">
|
||||
Identify People in Videos
|
||||
</h2>
|
||||
<p className="text-gray-500">
|
||||
This functionality will be available in a future update.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<button
|
||||
onClick={() => setVideosFiltersCollapsed(!videosFiltersCollapsed)}
|
||||
className="flex items-center justify-between w-full text-left font-medium text-gray-700"
|
||||
>
|
||||
<span>Filters</span>
|
||||
<span>{videosFiltersCollapsed ? '▼' : '▲'}</span>
|
||||
</button>
|
||||
{!videosFiltersCollapsed && (
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Folder Path
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={videosFolderFilter}
|
||||
onChange={(e) => setVideosFolderFilter(e.target.value)}
|
||||
placeholder="Filter by folder path"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Date From
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={videosDateFrom}
|
||||
onChange={(e) => setVideosDateFrom(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Date To
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={videosDateTo}
|
||||
onChange={(e) => setVideosDateTo(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Person Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={videosPersonName}
|
||||
onChange={(e) => setVideosPersonName(e.target.value)}
|
||||
placeholder="Search by person name"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Has People
|
||||
</label>
|
||||
<select
|
||||
value={videosHasPeople === undefined ? 'all' : videosHasPeople ? 'yes' : 'no'}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setVideosHasPeople(val === 'all' ? undefined : val === 'yes')
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="yes">With People</option>
|
||||
<option value="no">Without People</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Sort By
|
||||
</label>
|
||||
<select
|
||||
value={videosSortBy}
|
||||
onChange={(e) => setVideosSortBy(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="filename">Filename</option>
|
||||
<option value="date_taken">Date Taken</option>
|
||||
<option value="date_added">Date Added</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Sort Direction
|
||||
</label>
|
||||
<select
|
||||
value={videosSortDir}
|
||||
onChange={(e) => setVideosSortDir(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="asc">Ascending</option>
|
||||
<option value="desc">Descending</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Left: Video List */}
|
||||
<div className="col-span-5">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-700">
|
||||
Videos ({videosTotal})
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-600">Page Size:</label>
|
||||
<select
|
||||
value={videosPageSize}
|
||||
onChange={(e) => {
|
||||
setVideosPageSize(Number(e.target.value))
|
||||
setVideosPage(1)
|
||||
}}
|
||||
className="px-2 py-1 border border-gray-300 rounded text-sm"
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{videosLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Loading videos...</div>
|
||||
) : videos.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">No videos found</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
{videos.map((video) => (
|
||||
<div key={video.id}>
|
||||
<div
|
||||
onClick={() => handleVideoSelect(video)}
|
||||
className={`p-3 border rounded-lg cursor-pointer transition ${
|
||||
selectedVideo?.id === video.id
|
||||
? 'border-indigo-500 bg-indigo-50'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<img
|
||||
src={videosApi.getThumbnailUrl(video.id)}
|
||||
alt={video.filename}
|
||||
className="w-20 h-15 object-cover rounded"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="80" height="60"%3E%3Crect fill="%23ddd" width="80" height="60"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999" font-size="12"%3EVideo%3C/text%3E%3C/svg%3E'
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 truncate">
|
||||
{video.filename}
|
||||
</div>
|
||||
{video.date_taken && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(video.date_taken).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
{video.identified_people_count > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{video.identified_people.slice(0, 3).map((person) => (
|
||||
<span
|
||||
key={person.id}
|
||||
className="px-2 py-0.5 bg-indigo-100 text-indigo-700 text-xs rounded"
|
||||
>
|
||||
{person.first_name} {person.last_name}
|
||||
</span>
|
||||
))}
|
||||
{video.identified_people_count > 3 && (
|
||||
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">
|
||||
+{video.identified_people_count - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identified People - shown directly under selected video */}
|
||||
{selectedVideo?.id === video.id && (
|
||||
<div className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<h4 className="font-medium text-gray-700 mb-2 text-sm">
|
||||
Identified People ({videoPeople.length})
|
||||
</h4>
|
||||
{videoPeopleLoading ? (
|
||||
<div className="text-sm text-gray-500 text-center py-2">Loading...</div>
|
||||
) : videoPeople.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 text-center py-2">No people identified yet</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{videoPeople.map((person) => (
|
||||
<div
|
||||
key={person.person_id}
|
||||
className="flex items-center justify-between p-2 bg-white rounded border border-gray-200"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 text-sm truncate">
|
||||
{person.first_name} {person.last_name}
|
||||
{person.middle_name && ` ${person.middle_name}`}
|
||||
{person.maiden_name && ` (${person.maiden_name})`}
|
||||
</div>
|
||||
{person.identified_by && (
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
Identified by {person.identified_by}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemovePersonFromVideo(person.person_id)}
|
||||
className="ml-2 px-2 py-1 text-xs text-red-600 hover:bg-red-50 rounded flex-shrink-0"
|
||||
title="Remove person"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{videosTotal > 0 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Page {videosPage} of {Math.ceil(videosTotal / videosPageSize)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setVideosPage((p) => Math.max(1, p - 1))}
|
||||
disabled={videosPage === 1}
|
||||
className="px-3 py-1 border border-gray-300 rounded disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setVideosPage((p) => p + 1)}
|
||||
disabled={videosPage >= Math.ceil(videosTotal / videosPageSize)}
|
||||
className="px-3 py-1 border border-gray-300 rounded disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Video Details and Identification */}
|
||||
<div className="col-span-7">
|
||||
{selectedVideo ? (
|
||||
<div className="bg-white rounded-lg shadow p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-2">
|
||||
{selectedVideo.filename}
|
||||
</h3>
|
||||
<video
|
||||
src={videosApi.getVideoUrl(selectedVideo.id)}
|
||||
controls
|
||||
className="w-full max-h-64 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add Person Form */}
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="font-medium text-gray-700 mb-3">Add Person</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Existing Person
|
||||
</label>
|
||||
<select
|
||||
value={videoPersonId || ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setVideoPersonId(val ? Number(val) : undefined)
|
||||
if (val) {
|
||||
const person = people.find((p) => p.id === Number(val))
|
||||
if (person) {
|
||||
setVideoFirstName('')
|
||||
setVideoLastName('')
|
||||
setVideoMiddleName('')
|
||||
setVideoMaidenName('')
|
||||
setVideoDob('')
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="">-- Select existing person --</option>
|
||||
{people.map((person) => (
|
||||
<option key={person.id} value={person.id}>
|
||||
{person.first_name} {person.last_name}
|
||||
{person.middle_name && ` ${person.middle_name}`}
|
||||
{person.maiden_name && ` (${person.maiden_name})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 text-center">OR</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
First Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={videoFirstName}
|
||||
onChange={(e) => setVideoFirstName(e.target.value)}
|
||||
disabled={!!videoPersonId}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Last Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={videoLastName}
|
||||
onChange={(e) => setVideoLastName(e.target.value)}
|
||||
disabled={!!videoPersonId}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Middle Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={videoMiddleName}
|
||||
onChange={(e) => setVideoMiddleName(e.target.value)}
|
||||
disabled={!!videoPersonId}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Maiden Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={videoMaidenName}
|
||||
onChange={(e) => setVideoMaidenName(e.target.value)}
|
||||
disabled={!!videoPersonId}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Date of Birth
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={videoDob}
|
||||
onChange={(e) => setVideoDob(e.target.value)}
|
||||
disabled={!!videoPersonId}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleVideoIdentify}
|
||||
disabled={videoIdentifying || (!videoPersonId && (!videoFirstName.trim() || !videoLastName.trim()))}
|
||||
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{videoIdentifying ? 'Adding...' : 'Add Person to Video'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||
<p className="text-gray-500">Select a video to identify people</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+239
-65
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import peopleApi, { PersonWithFaces, PersonFaceItem, PersonUpdateRequest } from '../api/people'
|
||||
import peopleApi, { PersonWithFaces, PersonFaceItem, PersonVideoItem, PersonUpdateRequest } from '../api/people'
|
||||
import facesApi from '../api/faces'
|
||||
|
||||
interface EditDialogProps {
|
||||
@@ -149,10 +149,14 @@ export default function Modify() {
|
||||
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null)
|
||||
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 [selectedFaces, setSelectedFaces] = 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 [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
@@ -173,6 +177,7 @@ export default function Modify() {
|
||||
setSelectedPersonId(firstPerson.id)
|
||||
setSelectedPersonName(formatPersonName(firstPerson))
|
||||
loadPersonFaces(firstPerson.id)
|
||||
loadPersonVideos(firstPerson.id)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to load people')
|
||||
@@ -198,6 +203,7 @@ export default function Modify() {
|
||||
setSelectedPersonId(null)
|
||||
setSelectedPersonName('')
|
||||
setFaces([])
|
||||
setVideos([])
|
||||
setError(null) // Don't show error for deleted person
|
||||
} else {
|
||||
setError(errorDetail || 'Failed to load faces')
|
||||
@@ -207,18 +213,36 @@ export default function Modify() {
|
||||
}
|
||||
}, [unmatchedFaces])
|
||||
|
||||
// Load videos for a person
|
||||
const loadPersonVideos = useCallback(async (personId: number) => {
|
||||
try {
|
||||
const res = await peopleApi.getVideos(personId)
|
||||
setVideos(res.items)
|
||||
} catch (err: any) {
|
||||
// If person not found, videos will be empty
|
||||
if (err.response?.status !== 404) {
|
||||
console.error('Failed to load videos:', err)
|
||||
}
|
||||
setVideos([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadPeople()
|
||||
}, [loadPeople])
|
||||
|
||||
// Reload faces when person changes
|
||||
// Reload faces and videos when person changes
|
||||
useEffect(() => {
|
||||
if (selectedPersonId) {
|
||||
loadPersonFaces(selectedPersonId)
|
||||
loadPersonVideos(selectedPersonId)
|
||||
// Clear selected faces when person changes
|
||||
setSelectedFaces(new Set())
|
||||
} else {
|
||||
setFaces([])
|
||||
setVideos([])
|
||||
}
|
||||
}, [selectedPersonId, loadPersonFaces])
|
||||
}, [selectedPersonId, loadPersonFaces, loadPersonVideos])
|
||||
|
||||
const formatPersonName = (person: PersonWithFaces): string => {
|
||||
const parts: string[] = []
|
||||
@@ -246,6 +270,7 @@ export default function Modify() {
|
||||
setSelectedPersonId(person.id)
|
||||
setSelectedPersonName(formatPersonName(person))
|
||||
loadPersonFaces(person.id)
|
||||
loadPersonVideos(person.id)
|
||||
}
|
||||
|
||||
const handleEditPerson = (person: PersonWithFaces) => {
|
||||
@@ -256,14 +281,44 @@ export default function Modify() {
|
||||
await peopleApi.update(personId, data)
|
||||
// Reload people list
|
||||
await loadPeople()
|
||||
// Reload faces if this is the selected person
|
||||
// Reload faces and videos if this is the selected person
|
||||
if (selectedPersonId === personId) {
|
||||
await loadPersonFaces(personId)
|
||||
await loadPersonVideos(personId)
|
||||
}
|
||||
setSuccess('Person information updated successfully')
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
}
|
||||
|
||||
const handleDeletePerson = async () => {
|
||||
if (!deleteDialogPerson) return
|
||||
|
||||
try {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
await peopleApi.delete(deleteDialogPerson.id)
|
||||
|
||||
// Clear selection if deleted person was selected
|
||||
if (selectedPersonId === deleteDialogPerson.id) {
|
||||
setSelectedPersonId(null)
|
||||
setSelectedPersonName('')
|
||||
setFaces([])
|
||||
setVideos([])
|
||||
}
|
||||
|
||||
// Reload people list
|
||||
await loadPeople()
|
||||
|
||||
setDeleteDialogPerson(null)
|
||||
setSuccess(`Person "${formatPersonName(deleteDialogPerson)}" deleted successfully`)
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to delete person')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnmatchFace = async (faceId: number) => {
|
||||
// Add to unmatched set (temporary, not persisted until save)
|
||||
const newUnmatched = new Set(unmatchedFaces)
|
||||
@@ -436,28 +491,28 @@ export default function Modify() {
|
||||
: false
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col" style={{ height: 'calc(100vh - 3rem - 2rem)', overflow: 'hidden' }}>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded text-red-700">
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded text-red-700 flex-shrink-0">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded text-green-700">
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded text-green-700 flex-shrink-0">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-3 gap-6 flex-1" style={{ minHeight: 0, overflow: 'hidden' }}>
|
||||
{/* 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>
|
||||
<div className="col-span-1" style={{ minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<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>
|
||||
|
||||
{/* Search controls */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-4 flex-shrink-0">
|
||||
<div className="flex gap-2 mb-1">
|
||||
<input
|
||||
type="text"
|
||||
@@ -484,7 +539,7 @@ export default function Modify() {
|
||||
</div>
|
||||
|
||||
{/* People list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1" style={{ minHeight: 0, overflowY: 'auto' }}>
|
||||
{busy && people.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">Loading...</div>
|
||||
) : people.length === 0 ? (
|
||||
@@ -517,6 +572,17 @@ export default function Modify() {
|
||||
>
|
||||
{name} ({person.face_count})
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteDialogPerson(person)
|
||||
}}
|
||||
className="text-sm px-2 py-1 hover:bg-red-100 text-red-600 rounded"
|
||||
title="Delete person"
|
||||
disabled={busy}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -526,11 +592,19 @@ export default function Modify() {
|
||||
</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">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Faces</h2>
|
||||
{/* Right panel: Faces and Videos split horizontally */}
|
||||
<div className="col-span-2" style={{ minHeight: 0, display: 'flex', flexDirection: 'column', gap: '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' }}>
|
||||
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setFacesExpanded(!facesExpanded)}
|
||||
className="flex items-center gap-2 hover:bg-gray-100 px-2 py-1 rounded"
|
||||
>
|
||||
<span className="text-lg font-semibold">Faces</span>
|
||||
<span>{facesExpanded ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{selectedPersonId && (
|
||||
<div className="flex gap-2">
|
||||
{visibleFaces.length > 0 && (
|
||||
@@ -576,58 +650,118 @@ export default function Modify() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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'}
|
||||
{facesExpanded && (
|
||||
<>
|
||||
{selectedPersonId ? (
|
||||
<div className="flex-1" style={{ minHeight: 0, overflowY: '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="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 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => {
|
||||
// Open photo in new window
|
||||
window.open(`/api/v1/photos/${face.photo_id}/image`, '_blank')
|
||||
}}
|
||||
title="Click to show original photo"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = '/placeholder.png'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFaces.has(face.id)}
|
||||
onChange={() => handleToggleFaceSelection(face.id)}
|
||||
className="rounded"
|
||||
disabled={busy}
|
||||
/>
|
||||
<span className="text-xs text-gray-700">Unmatch</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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="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 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => {
|
||||
// Open photo in new window
|
||||
window.open(`/api/v1/photos/${face.photo_id}/image`, '_blank')
|
||||
}}
|
||||
title="Click to show original photo"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = '/placeholder.png'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFaces.has(face.id)}
|
||||
onChange={() => handleToggleFaceSelection(face.id)}
|
||||
className="rounded"
|
||||
disabled={busy}
|
||||
/>
|
||||
<span className="text-xs text-gray-700">Unmatch</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
Select a person to view their faces
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
Select a person to view their faces
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom section: Videos list */}
|
||||
<div className={videosExpanded ? "flex-1" : ""} style={{ minHeight: 0, display: 'flex', flexDirection: 'column', flexShrink: videosExpanded ? 1 : 0 }}>
|
||||
<div className={`bg-white rounded-lg shadow p-4 flex flex-col ${videosExpanded ? 'h-full' : ''}`} style={{ minHeight: 0, overflow: 'hidden' }}>
|
||||
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setVideosExpanded(!videosExpanded)}
|
||||
className="flex items-center gap-2 hover:bg-gray-100 px-2 py-1 rounded"
|
||||
>
|
||||
<span className="text-lg font-semibold">Videos</span>
|
||||
<span>{videosExpanded ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{videosExpanded && (
|
||||
<>
|
||||
{selectedPersonId ? (
|
||||
<div className="flex-1" style={{ minHeight: 0, overflowY: 'auto' }}>
|
||||
{videos.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">No videos found for this person</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{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"
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
Select a person to view their videos
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -640,6 +774,46 @@ export default function Modify() {
|
||||
onClose={() => setEditDialogPerson(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{deleteDialogPerson && (
|
||||
<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">Delete Person</h2>
|
||||
<p className="mb-4">
|
||||
Are you sure you want to delete <strong>{formatPersonName(deleteDialogPerson)}</strong>?
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
This will:
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Unlink all faces from this person</li>
|
||||
<li>Remove all video linkages</li>
|
||||
<li>Delete all person encodings</li>
|
||||
<li>Permanently delete the person record</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p className="mb-6 text-sm font-semibold text-red-600">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setDeleteDialogPerson(null)}
|
||||
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={handleDeletePerson}
|
||||
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 ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user