From 09ee8712aad6540f04564ab577184a2f6dd1ed48 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Thu, 5 Feb 2026 16:57:47 +0000 Subject: [PATCH] feat: extend people search to first/middle/last/maiden names and fix port binding issue - Backend: Updated list_people_with_faces to search by first_name, middle_name, last_name, and maiden_name - Frontend: Updated Modify page search UI and API client to support extended search - Frontend: Updated Help page documentation for new search capabilities - Infrastructure: Added start-api.sh wrapper script to prevent port 8000 binding conflicts - Infrastructure: Updated PM2 config with improved kill_timeout and restart_delay settings --- admin-frontend/src/api/people.ts | 4 +-- admin-frontend/src/pages/Help.tsx | 2 +- admin-frontend/src/pages/Modify.tsx | 51 +++++++++++++++-------------- backend/api/people.py | 16 +++++---- scripts/start-api.sh | 13 ++++++++ 5 files changed, 53 insertions(+), 33 deletions(-) create mode 100755 scripts/start-api.sh diff --git a/admin-frontend/src/api/people.ts b/admin-frontend/src/api/people.ts index 99b55f5..4a0712a 100644 --- a/admin-frontend/src/api/people.ts +++ b/admin-frontend/src/api/people.ts @@ -46,8 +46,8 @@ export const peopleApi = { const res = await apiClient.get('/api/v1/people', { params }) return res.data }, - listWithFaces: async (lastName?: string): Promise => { - const params = lastName ? { last_name: lastName } : {} + listWithFaces: async (name?: string): Promise => { + const params = name ? { last_name: name } : {} const res = await apiClient.get('/api/v1/people/with-faces', { params }) return res.data }, diff --git a/admin-frontend/src/pages/Help.tsx b/admin-frontend/src/pages/Help.tsx index 07dd36b..b1df125 100644 --- a/admin-frontend/src/pages/Help.tsx +++ b/admin-frontend/src/pages/Help.tsx @@ -672,7 +672,7 @@ function ModifyPageHelp({ onBack }: { onBack: () => void }) {

Finding and Selecting a Person:

  1. Navigate to Modify page
  2. -
  3. Optionally search for a person by entering their last name or maiden name in the search box
  4. +
  5. Optionally search for a person by entering their first, middle, last, or maiden name in the search box
  6. Click "Search" to filter the list, or "Clear" to show all people
  7. Click on a person's name in the left panel to select them
  8. The person's faces and videos will load in the right panels
  9. diff --git a/admin-frontend/src/pages/Modify.tsx b/admin-frontend/src/pages/Modify.tsx index 0fc9ee7..1bf799d 100644 --- a/admin-frontend/src/pages/Modify.tsx +++ b/admin-frontend/src/pages/Modify.tsx @@ -147,7 +147,7 @@ function EditPersonDialog({ person, onSave, onClose }: EditDialogProps) { export default function Modify() { const [people, setPeople] = useState([]) - const [lastNameFilter, setLastNameFilter] = useState('') + const [nameFilter, setNameFilter] = useState('') const [selectedPersonId, setSelectedPersonId] = useState(null) const [selectedPersonName, setSelectedPersonName] = useState('') const [faces, setFaces] = useState([]) @@ -187,7 +187,7 @@ export default function Modify() { try { setBusy(true) setError(null) - const res = await peopleApi.listWithFaces(lastNameFilter || undefined) + const res = await peopleApi.listWithFaces(nameFilter || undefined) setPeople(res.items) // Auto-select first person if available and none selected (only if not restoring state) @@ -203,7 +203,7 @@ export default function Modify() { } finally { setBusy(false) } - }, [lastNameFilter, selectedPersonId]) + }, [nameFilter, selectedPersonId]) // Load faces for a person const loadPersonFaces = useCallback(async (personId: number) => { @@ -248,12 +248,15 @@ export default function Modify() { useEffect(() => { let restoredPanelWidth = false try { - const saved = sessionStorage.getItem(STATE_KEY) - if (saved) { - const state = JSON.parse(saved) - if (state.lastNameFilter !== undefined) { - setLastNameFilter(state.lastNameFilter || '') - } + const saved = sessionStorage.getItem(STATE_KEY) + if (saved) { + const state = JSON.parse(saved) + if (state.nameFilter !== undefined) { + setNameFilter(state.nameFilter || '') + } else if (state.lastNameFilter !== undefined) { + // Backward compatibility with old state key + setNameFilter(state.lastNameFilter || '') + } if (state.selectedPersonId !== undefined && state.selectedPersonId !== null) { setSelectedPersonId(state.selectedPersonId) } @@ -365,7 +368,7 @@ export default function Modify() { try { const state = { - lastNameFilter, + nameFilter, selectedPersonId, selectedPersonName, faces, @@ -380,10 +383,10 @@ export default function Modify() { } catch (error) { console.error('Error saving state to sessionStorage:', error) } - }, [lastNameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth, stateRestored]) + }, [nameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth, stateRestored]) // Save state on unmount (when navigating away) - use refs to capture latest values - const lastNameFilterRef = useRef(lastNameFilter) + const nameFilterRef = useRef(nameFilter) const selectedPersonIdRef = useRef(selectedPersonId) const selectedPersonNameRef = useRef(selectedPersonName) const facesRef = useRef(faces) @@ -396,7 +399,7 @@ export default function Modify() { // Update refs whenever state changes useEffect(() => { - lastNameFilterRef.current = lastNameFilter + nameFilterRef.current = nameFilter selectedPersonIdRef.current = selectedPersonId selectedPersonNameRef.current = selectedPersonName facesRef.current = faces @@ -406,14 +409,14 @@ export default function Modify() { facesExpandedRef.current = facesExpanded videosExpandedRef.current = videosExpanded peoplePanelWidthRef.current = peoplePanelWidth - }, [lastNameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth]) + }, [nameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth]) // Save state on unmount (when navigating away) useEffect(() => { return () => { try { const state = { - lastNameFilter: lastNameFilterRef.current, + nameFilter: nameFilterRef.current, selectedPersonId: selectedPersonIdRef.current, selectedPersonName: selectedPersonNameRef.current, faces: facesRef.current, @@ -463,7 +466,7 @@ export default function Modify() { } const handleClearSearch = () => { - setLastNameFilter('') + setNameFilter('') // loadPeople will be called by useEffect } @@ -560,7 +563,7 @@ export default function Modify() { await facesApi.batchUnmatch({ face_ids: [unmatchConfirmDialog.faceId] }) // Reload people list to update face counts - const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined) + const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined) setPeople(peopleRes.items) // Reload faces @@ -591,7 +594,7 @@ export default function Modify() { setSelectedFaces(new Set()) // Reload people list to update face counts - const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined) + const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined) setPeople(peopleRes.items) // Reload faces @@ -627,7 +630,7 @@ export default function Modify() { await videosApi.removePerson(unmatchConfirmDialog.videoId, selectedPersonId) // Reload people list - const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined) + const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined) setPeople(peopleRes.items) // Reload videos @@ -679,7 +682,7 @@ export default function Modify() { setSelectedVideos(new Set()) // Reload people list - const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined) + const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined) setPeople(peopleRes.items) // Reload videos @@ -720,10 +723,10 @@ export default function Modify() {
    setLastNameFilter(e.target.value)} + value={nameFilter} + onChange={(e) => setNameFilter(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} - placeholder="Type Last Name or Maiden Name" + placeholder="Type First, Middle, Last, or Maiden Name" className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
    -

    Search by Last Name or Maiden Name

    +

    Search by First, Middle, Last, or Maiden Name

    {/* People list */} diff --git a/backend/api/people.py b/backend/api/people.py index 2a5165e..c17b001 100644 --- a/backend/api/people.py +++ b/backend/api/people.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, Response, status -from sqlalchemy import func +from sqlalchemy import func, or_ from sqlalchemy.orm import Session from backend.db.session import get_db @@ -48,12 +48,12 @@ def list_people( @router.get("/with-faces", response_model=PeopleWithFacesListResponse) def list_people_with_faces( - last_name: str | None = Query(None, description="Filter by last name or maiden name (case-insensitive)"), + last_name: str | None = Query(None, description="Filter by first, middle, last, or maiden name (case-insensitive)"), db: Session = Depends(get_db), ) -> PeopleWithFacesListResponse: """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). + Optionally filter by first_name, middle_name, last_name, or maiden_name if provided (case-insensitive search). 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 @@ -67,11 +67,15 @@ def list_people_with_faces( ) if last_name: - # Case-insensitive search on both last_name and maiden_name + # Case-insensitive search on first_name, middle_name, last_name, and maiden_name search_term = last_name.lower() query = query.filter( - (func.lower(Person.last_name).contains(search_term)) | - ((Person.maiden_name.isnot(None)) & (func.lower(Person.maiden_name).contains(search_term))) + or_( + func.lower(Person.first_name).contains(search_term), + func.lower(Person.middle_name).contains(search_term), + func.lower(Person.last_name).contains(search_term), + ((Person.maiden_name.isnot(None)) & (func.lower(Person.maiden_name).contains(search_term))) + ) ) results = query.order_by(Person.last_name.asc(), Person.first_name.asc()).all() diff --git a/scripts/start-api.sh b/scripts/start-api.sh new file mode 100755 index 0000000..f322b0d --- /dev/null +++ b/scripts/start-api.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Wrapper script to start the API with port cleanup + +# Kill any processes using port 8000 (except our own if we're restarting) +PORT=8000 +lsof -ti :${PORT} | xargs -r kill -9 2>/dev/null || true + +# Wait a moment for port to be released +sleep 2 + +# Start uvicorn +exec /opt/punimtag/venv/bin/uvicorn backend.app:app --host 0.0.0.0 --port 8000 +