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 +