From e0e5aae2fffadd7a380fe038b252f81aa5b14eea Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Tue, 2 Dec 2025 16:03:15 -0500 Subject: [PATCH] feat: Add video count to person API and frontend for enhanced media management This commit introduces a new `video_count` field in the `PersonWithFaces` interface and updates the API to return video counts alongside face counts. The frontend has been modified to display video counts in the people list and includes functionality for selecting and unmatching videos. Additionally, the layout has been enhanced to support resizing of the people panel, improving user experience when managing faces and videos. Documentation has been updated to reflect these changes. --- frontend/.eslintrc.cjs | 1 + frontend/src/api/people.ts | 1 + frontend/src/api/rolePermissions.ts | 1 + frontend/src/api/videos.ts | 1 + frontend/src/pages/Modify.tsx | 454 ++++++++++++++++++---------- src/web/api/people.py | 26 +- src/web/api/role_permissions.py | 1 + src/web/api/videos.py | 1 + src/web/schemas/people.py | 1 + src/web/schemas/role_permissions.py | 1 + src/web/schemas/videos.py | 1 + 11 files changed, 327 insertions(+), 162 deletions(-) diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 0ca6462..47ee835 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -47,3 +47,4 @@ module.exports = { }, } + diff --git a/frontend/src/api/people.ts b/frontend/src/api/people.ts index 2f09ed0..99b55f5 100644 --- a/frontend/src/api/people.ts +++ b/frontend/src/api/people.ts @@ -16,6 +16,7 @@ export interface PeopleListResponse { export interface PersonWithFaces extends Person { face_count: number + video_count: number } export interface PeopleWithFacesListResponse { diff --git a/frontend/src/api/rolePermissions.ts b/frontend/src/api/rolePermissions.ts index b707b85..4aaf333 100644 --- a/frontend/src/api/rolePermissions.ts +++ b/frontend/src/api/rolePermissions.ts @@ -34,3 +34,4 @@ export const rolePermissionsApi = { }, } + diff --git a/frontend/src/api/videos.ts b/frontend/src/api/videos.ts index 8a05250..60855a3 100644 --- a/frontend/src/api/videos.ts +++ b/frontend/src/api/videos.ts @@ -120,3 +120,4 @@ export const videosApi = { export default videosApi + diff --git a/frontend/src/pages/Modify.tsx b/frontend/src/pages/Modify.tsx index f799058..b0621db 100644 --- a/frontend/src/pages/Modify.tsx +++ b/frontend/src/pages/Modify.tsx @@ -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([]) const [videos, setVideos] = useState([]) - const [unmatchedFaces, setUnmatchedFaces] = useState>(new Set()) - const [unmatchedByPerson, setUnmatchedByPerson] = useState>>({}) + const [unmatchConfirmDialog, setUnmatchConfirmDialog] = useState<{ + type: 'face' | 'video' + single: boolean + faceId?: number + videoId?: number + count?: number + } | null>(null) const [selectedFaces, setSelectedFaces] = useState>(new Set()) + const [selectedVideos, setSelectedVideos] = useState>(new Set()) const [editDialogPerson, setEditDialogPerson] = useState(null) const [deleteDialogPerson, setDeleteDialogPerson] = useState(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(null) const [success, setSuccess] = useState(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 ( -
+
{error && (
@@ -505,9 +532,9 @@ export default function Modify() {
)} -
+
{/* Left panel: People list */} -
+

People

@@ -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})
+ {/* Resize handle */} +
setIsResizing(true)} + style={{ + width: '4px', + cursor: 'col-resize', + backgroundColor: '#e5e7eb', + flexShrink: 0, + position: 'relative', + }} + className="hover:bg-gray-400 transition-colors" + > +
+
+ {/* Right panel: Faces and Videos split horizontally */} -
+
{/* Top section: Faces grid */}
@@ -607,7 +657,7 @@ export default function Modify() { {selectedPersonId && (
- {visibleFaces.length > 0 && ( + {faces.length > 0 && ( <> -
)}
@@ -654,20 +690,16 @@ export default function Modify() { <> {selectedPersonId ? (
- {busy && visibleFaces.length === 0 ? ( + {busy && faces.length === 0 ? (
Loading faces...
- ) : visibleFaces.length === 0 ? ( -
- {faces.length === 0 - ? 'No faces found for this person' - : 'All faces unmatched'} -
+ ) : faces.length === 0 ? ( +
No faces found for this person
) : (
- {visibleFaces.map((face) => ( + {faces.map((face) => (
- Unmatch + Select
))} @@ -722,6 +754,38 @@ export default function Modify() { Videos {videosExpanded ? '▼' : '▶'} + {selectedPersonId && videosExpanded && ( +
+ {videos.length > 0 && ( + <> + + + + + )} +
+ )}
{videosExpanded && ( <> @@ -734,19 +798,44 @@ export default function Modify() { {videos.map((video) => (
{ - // 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" > -
{video.filename}
- {video.date_taken && ( -
- {new Date(video.date_taken).toLocaleDateString()} +
+ handleToggleVideoSelection(video.id)} + className="rounded" + disabled={busy} + onClick={(e) => e.stopPropagation()} + /> +
{ + // Open video in new window or navigate to video page + window.open(`/api/v1/videos/${video.id}/thumbnail`, '_blank') + }} + title="Click to view video" + > +
{video.filename}
+ {video.date_taken && ( +
+ {new Date(video.date_taken).toLocaleDateString()} +
+ )}
- )} + +
))}
@@ -814,6 +903,53 @@ export default function Modify() {
)} + + {/* Unmatch confirmation dialog */} + {unmatchConfirmDialog && ( +
+
+

Confirm Unmatch

+

+ {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'}?`} +

+

+ This action cannot be undone. +

+
+ + +
+
+
+ )}
) } diff --git a/src/web/api/people.py b/src/web/api/people.py index 94659ed..5de1bb0 100644 --- a/src/web/api/people.py +++ b/src/web/api/people.py @@ -50,16 +50,16 @@ def list_people_with_faces( last_name: str | None = Query(None, description="Filter by last name or maiden name (case-insensitive)"), db: Session = Depends(get_db), ) -> PeopleWithFacesListResponse: - """List all people with face counts, sorted by last_name, first_name. + """List all people with face counts and video counts, sorted by last_name, first_name. Optionally filter by last_name or maiden_name if provided (case-insensitive search). - Returns all people, including those with zero faces. + Returns all people, including those with zero faces or videos. """ # Query people with face counts using LEFT OUTER JOIN to include people with no faces query = ( db.query( Person, - func.count(Face.id).label('face_count') + func.count(Face.id.distinct()).label('face_count') ) .outerjoin(Face, Person.id == Face.person_id) .group_by(Person.id) @@ -75,6 +75,25 @@ def list_people_with_faces( results = query.order_by(Person.last_name.asc(), Person.first_name.asc()).all() + # Get video counts separately for each person + person_ids = [person.id for person, _ in results] + video_counts = {} + if person_ids: + video_count_query = ( + db.query( + PhotoPersonLinkage.person_id, + func.count(PhotoPersonLinkage.id).label('video_count') + ) + .join(Photo, PhotoPersonLinkage.photo_id == Photo.id) + .filter( + PhotoPersonLinkage.person_id.in_(person_ids), + Photo.media_type == "video" + ) + .group_by(PhotoPersonLinkage.person_id) + ) + for person_id, video_count in video_count_query.all(): + video_counts[person_id] = video_count + items = [ PersonWithFacesResponse( id=person.id, @@ -84,6 +103,7 @@ def list_people_with_faces( maiden_name=person.maiden_name, date_of_birth=person.date_of_birth, face_count=face_count or 0, # Convert None to 0 for people with no faces + video_count=video_counts.get(person.id, 0), # Get video count or default to 0 ) for person, face_count in results ] diff --git a/src/web/api/role_permissions.py b/src/web/api/role_permissions.py index 61b21d9..e1af258 100644 --- a/src/web/api/role_permissions.py +++ b/src/web/api/role_permissions.py @@ -66,3 +66,4 @@ def update_role_permissions( features = [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES] return RolePermissionsResponse(features=features, permissions=permissions) + diff --git a/src/web/api/videos.py b/src/web/api/videos.py index 5ddb122..01a46d1 100644 --- a/src/web/api/videos.py +++ b/src/web/api/videos.py @@ -335,3 +335,4 @@ def get_video_file( response.headers["Cache-Control"] = "public, max-age=3600" return response + diff --git a/src/web/schemas/people.py b/src/web/schemas/people.py index 2087ff1..53679cf 100644 --- a/src/web/schemas/people.py +++ b/src/web/schemas/people.py @@ -66,6 +66,7 @@ class PersonWithFacesResponse(BaseModel): maiden_name: Optional[str] = None date_of_birth: Optional[date] = None face_count: int + video_count: int class PeopleWithFacesListResponse(BaseModel): diff --git a/src/web/schemas/role_permissions.py b/src/web/schemas/role_permissions.py index 4a05624..68be603 100644 --- a/src/web/schemas/role_permissions.py +++ b/src/web/schemas/role_permissions.py @@ -40,3 +40,4 @@ class RolePermissionsUpdateRequest(BaseModel): def build_feature_list() -> list[RoleFeatureSchema]: return [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES] + diff --git a/src/web/schemas/videos.py b/src/web/schemas/videos.py index fce5373..de4b8ff 100644 --- a/src/web/schemas/videos.py +++ b/src/web/schemas/videos.py @@ -88,3 +88,4 @@ class RemoveVideoPersonResponse(BaseModel): removed: bool message: str +