Compare commits

..

2 Commits

Author SHA1 Message Date
tanyar09
b0c9ad8d5d feat: enhance tag management in Tags page
All checks were successful
CI / skip-ci-check (pull_request) Successful in 10s
CI / lint-and-type-check (pull_request) Successful in 56s
CI / python-lint (pull_request) Successful in 35s
CI / test-backend (pull_request) Successful in 3m44s
CI / build (pull_request) Successful in 3m26s
CI / secret-scanning (pull_request) Successful in 15s
CI / dependency-scan (pull_request) Successful in 16s
CI / sast-scan (pull_request) Successful in 1m29s
CI / workflow-summary (pull_request) Successful in 6s
- Added functionality to create new tags and update existing tags in bulk.
- Implemented local state management for tags to improve user experience.
- Updated UI to allow users to enter new tag names alongside selecting existing ones.
- Ensured tags are reloaded in the parent component after creation for synchronization.
2026-02-05 17:27:41 +00:00
tanyar09
09ee8712aa 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
2026-02-05 16:57:47 +00:00
6 changed files with 157 additions and 64 deletions

View File

@ -46,8 +46,8 @@ export const peopleApi = {
const res = await apiClient.get<PeopleListResponse>('/api/v1/people', { params })
return res.data
},
listWithFaces: async (lastName?: string): Promise<PeopleWithFacesListResponse> => {
const params = lastName ? { last_name: lastName } : {}
listWithFaces: async (name?: string): Promise<PeopleWithFacesListResponse> => {
const params = name ? { last_name: name } : {}
const res = await apiClient.get<PeopleWithFacesListResponse>('/api/v1/people/with-faces', { params })
return res.data
},

View File

@ -672,7 +672,7 @@ function ModifyPageHelp({ onBack }: { onBack: () => void }) {
<p className="text-gray-700 font-medium mb-2">Finding and Selecting a Person:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Navigate to Modify page</li>
<li>Optionally search for a person by entering their last name or maiden name in the search box</li>
<li>Optionally search for a person by entering their first, middle, last, or maiden name in the search box</li>
<li>Click "Search" to filter the list, or "Clear" to show all people</li>
<li>Click on a person's name in the left panel to select them</li>
<li>The person's faces and videos will load in the right panels</li>

View File

@ -147,7 +147,7 @@ function EditPersonDialog({ person, onSave, onClose }: EditDialogProps) {
export default function Modify() {
const [people, setPeople] = useState<PersonWithFaces[]>([])
const [lastNameFilter, setLastNameFilter] = useState('')
const [nameFilter, setNameFilter] = useState('')
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null)
const [selectedPersonName, setSelectedPersonName] = useState('')
const [faces, setFaces] = useState<PersonFaceItem[]>([])
@ -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() {
<div className="flex gap-2 mb-1">
<input
type="text"
value={lastNameFilter}
onChange={(e) => 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"
/>
<button
@ -739,7 +742,7 @@ export default function Modify() {
Clear
</button>
</div>
<p className="text-xs text-gray-500">Search by Last Name or Maiden Name</p>
<p className="text-xs text-gray-500">Search by First, Middle, Last, or Maiden Name</p>
</div>
{/* People list */}

View File

@ -1115,6 +1115,11 @@ export default function Tags() {
selectedPhotoIds={Array.from(selectedPhotoIds)}
photos={photos.filter(p => selectedPhotoIds.has(p.id))}
tags={tags}
onTagsUpdated={async () => {
// Reload tags when new tags are created
const tagsRes = await tagsApi.list()
setTags(tagsRes.items)
}}
onClose={async () => {
setShowTagSelectedDialog(false)
setSelectedPhotoIds(new Set())
@ -1775,17 +1780,26 @@ function TagSelectedPhotosDialog({
selectedPhotoIds,
photos,
tags,
onTagsUpdated,
onClose,
}: {
selectedPhotoIds: number[]
photos: PhotoWithTagsItem[]
tags: TagResponse[]
onTagsUpdated?: () => Promise<void>
onClose: () => void
}) {
const [selectedTagName, setSelectedTagName] = useState('')
const [newTagName, setNewTagName] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set())
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const [photoTagsData, setPhotoTagsData] = useState<Record<number, any[]>>({})
const [localTags, setLocalTags] = useState<TagResponse[]>(tags)
// Update local tags when tags prop changes
useEffect(() => {
setLocalTags(tags)
}, [tags])
// Load tag linkage information for all selected photos
useEffect(() => {
@ -1809,28 +1823,59 @@ function TagSelectedPhotosDialog({
}, [selectedPhotoIds])
const handleAddTag = async () => {
if (!selectedTagName.trim() || selectedPhotoIds.length === 0) return
if (selectedPhotoIds.length === 0) return
// Check if tag exists, create if not
let tag = tags.find(t => t.tag_name.toLowerCase() === selectedTagName.toLowerCase().trim())
if (!tag) {
try {
tag = await tagsApi.create(selectedTagName.trim())
// Note: We don't update the tags list here since it's passed from parent
} catch (error) {
console.error('Failed to create tag:', error)
alert('Failed to create tag')
return
}
// Collect both tags: selected existing tag and new tag name
const tagsToAdd: string[] = []
if (selectedTagName.trim()) {
tagsToAdd.push(selectedTagName.trim())
}
if (newTagName.trim()) {
tagsToAdd.push(newTagName.trim())
}
if (tagsToAdd.length === 0) {
alert('Please select a tag or enter a new tag name.')
return
}
// Make single batch API call for all selected photos
try {
// Create any new tags first
const newTags = tagsToAdd.filter(tag =>
!localTags.some(availableTag =>
availableTag.tag_name.toLowerCase() === tag.toLowerCase()
)
)
if (newTags.length > 0) {
const createdTags: TagResponse[] = []
for (const newTag of newTags) {
const createdTag = await tagsApi.create(newTag)
createdTags.push(createdTag)
}
// Update local tags immediately with newly created tags
setLocalTags(prev => {
const updated = [...prev, ...createdTags]
// Sort by tag name
return updated.sort((a, b) => a.tag_name.localeCompare(b.tag_name))
})
// Also reload tags list in parent to keep it in sync
if (onTagsUpdated) {
await onTagsUpdated()
}
}
// Add all tags to photos in a single API call
await tagsApi.addToPhotos({
photo_ids: selectedPhotoIds,
tag_names: [selectedTagName.trim()],
tag_names: tagsToAdd,
})
// Clear inputs after successful tagging
setSelectedTagName('')
setNewTagName('')
// Reload photo tags data to update the common tags list
const tagsData: Record<number, any[]> = {}
@ -1901,7 +1946,7 @@ function TagSelectedPhotosDialog({
allPhotoTags[photoId] = photoTagsData[photoId] || []
})
const tagIdToName = new Map(tags.map(t => [t.id, t.tag_name]))
const tagIdToName = new Map(localTags.map(t => [t.id, t.tag_name]))
// Get all unique tag IDs from all photos
const allTagIds = new Set<number>()
@ -1930,7 +1975,7 @@ function TagSelectedPhotosDialog({
}
})
.filter(Boolean) as any[]
}, [photos, tags, selectedPhotoIds, photoTagsData])
}, [photos, localTags, selectedPhotoIds, photoTagsData])
// Get selected tag names for confirmation message
const selectedTagNames = useMemo(() => {
@ -1961,11 +2006,14 @@ function TagSelectedPhotosDialog({
</p>
</div>
<div className="p-4 flex-1 overflow-auto">
<div className="mb-4 flex gap-2">
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Existing Tag:
</label>
<select
value={selectedTagName}
onChange={(e) => setSelectedTagName(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded"
className="w-full px-3 py-2 border border-gray-300 rounded"
>
<option value="">Select tag...</option>
{tags.map(tag => (
@ -1974,13 +2022,29 @@ function TagSelectedPhotosDialog({
</option>
))}
</select>
<button
onClick={handleAddTag}
disabled={!selectedTagName.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Add
</button>
<p className="text-xs text-gray-500 mt-1">
You can select an existing tag and enter a new tag name to add both at once.
</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Enter New Tag Name:
</label>
<input
type="text"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded"
placeholder="Type new tag name..."
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddTag()
}
}}
/>
<p className="text-xs text-gray-500 mt-1">
New tags will be created in the database automatically.
</p>
</div>
<div className="space-y-2">
@ -2024,12 +2088,21 @@ function TagSelectedPhotosDialog({
>
Remove selected tags
</button>
<button
onClick={onClose}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Close
</button>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Close
</button>
<button
onClick={handleAddTag}
disabled={!selectedTagName.trim() && !newTagName.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Add Tag
</button>
</div>
</div>
</div>
</div>

View File

@ -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()

13
scripts/start-api.sh Executable file
View File

@ -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