feat: Enhance photo identification and tagging features with new filters and counts

This commit introduces several enhancements to the photo identification and tagging functionalities. The Identify component now supports filtering by photo IDs, allowing users to view faces from specific photos. Additionally, the Tags component has been updated to include an unidentified face count for each photo, improving user awareness of untagged faces. The API has been modified to accommodate these new parameters, ensuring seamless integration with the frontend. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09
2025-11-12 14:17:16 -05:00
parent 89a63cbf57
commit cfb94900ef
10 changed files with 404 additions and 74 deletions
+1
View File
@@ -184,6 +184,7 @@ export const facesApi = {
sort_dir?: 'asc' | 'desc'
tag_names?: string
match_all?: boolean
photo_ids?: string
}): Promise<UnidentifiedFacesResponse> => {
const response = await apiClient.get<UnidentifiedFacesResponse>('/api/v1/faces/unidentified', {
params,
+1
View File
@@ -52,6 +52,7 @@ export interface PhotoWithTagsItem {
date_taken?: string | null
date_added?: string | null
face_count: number
unidentified_face_count: number // Count of faces with person_id IS NULL
tags: string // Comma-separated tags string
}
+81 -19
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import facesApi, { FaceItem, SimilarFaceItem } from '../api/faces'
import peopleApi, { Person } from '../api/people'
import { apiClient } from '../api/client'
@@ -10,6 +11,7 @@ type SortDir = 'asc' | 'desc'
export default function Identify() {
const { isDeveloperMode } = useDeveloperMode()
const [searchParams, setSearchParams] = useSearchParams()
const [faces, setFaces] = useState<FaceItem[]>([])
const [, setTotal] = useState(0)
const [pageSize, setPageSize] = useState(50)
@@ -20,6 +22,14 @@ export default function Identify() {
const [dateTo, setDateTo] = useState<string>('')
// dateProcessed filter is hidden, so state removed
// const [dateProcessed, setDateProcessed] = useState<string>('')
// Parse photo_ids from URL parameter
const photoIds = useMemo(() => {
const photoIdsParam = searchParams.get('photo_ids')
if (!photoIdsParam) return null
const ids = photoIdsParam.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id))
return ids.length > 0 ? ids : null
}, [searchParams])
const [currentIdx, setCurrentIdx] = useState(0)
const currentFace = faces[currentIdx]
@@ -48,6 +58,7 @@ export default function Identify() {
const [availableTags, setAvailableTags] = useState<TagResponse[]>([])
const [selectedTags, setSelectedTags] = useState<string[]>([])
const [tagsExpanded, setTagsExpanded] = useState(false)
const [selectKey, setSelectKey] = useState(0) // Key to force select re-render when clearing
// Store form data per face ID (matching desktop behavior)
const [faceFormData, setFaceFormData] = useState<Record<number, {
@@ -74,7 +85,7 @@ export default function Identify() {
return Boolean((personId && currentFace) || (firstName && lastName && dob && currentFace))
}, [personId, firstName, lastName, dob, currentFace])
const loadFaces = async (clearState: boolean = false) => {
const loadFaces = async (clearState: boolean = false, ignorePhotoIds: boolean = false) => {
setLoadingFaces(true)
try {
@@ -95,6 +106,7 @@ export default function Identify() {
sort_dir: sortDir,
tag_names: selectedTags.length > 0 ? selectedTags.join(', ') : undefined,
match_all: false, // Default to match any tag
photo_ids: (ignorePhotoIds || !photoIds) ? undefined : photoIds.join(','), // Filter by photo IDs if provided and not ignored
})
// Apply unique faces filter if enabled
@@ -258,8 +270,15 @@ export default function Identify() {
}
// Load settings from sessionStorage on mount
// If photoIds is provided, skip loading filter settings (we want to show all faces from those photos)
useEffect(() => {
try {
// If photoIds is in URL, don't load filter settings - we'll use defaults to show all faces
if (photoIds !== null) {
setSettingsLoaded(true)
return
}
const saved = sessionStorage.getItem(SETTINGS_KEY)
if (saved) {
const settings = JSON.parse(saved)
@@ -281,11 +300,18 @@ export default function Identify() {
setSettingsLoaded(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [photoIds])
// Load state from sessionStorage on mount (faces, current index, similar, form data)
// Skip state restoration if photoIds is provided in URL (we want fresh filtered data)
useEffect(() => {
try {
// If photoIds is in URL, don't restore state - we need to load fresh filtered faces
if (photoIds !== null) {
setStateRestored(true)
return
}
const saved = sessionStorage.getItem(STATE_KEY)
if (saved) {
const state = JSON.parse(saved)
@@ -318,7 +344,7 @@ export default function Identify() {
setStateRestored(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [photoIds])
// Save state to sessionStorage whenever it changes (but only after initial restore)
useEffect(() => {
@@ -395,12 +421,36 @@ export default function Identify() {
}
}, [pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo, uniqueFacesOnly, compareEnabled, selectedTags, settingsLoaded])
// Load tags and people on mount (always, regardless of other conditions)
useEffect(() => {
if (settingsLoaded) {
loadPeople()
loadTags()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsLoaded])
// Reset filters when photoIds is provided (to ensure all faces from those photos are shown)
useEffect(() => {
if (photoIds !== null && settingsLoaded) {
// Reset filters to defaults to show all faces from the selected photos
setMinQuality(0.0)
setDateFrom('')
setDateTo('')
setSelectedTags([])
// Keep uniqueFacesOnly as is (user preference)
// Keep sortBy/sortDir as defaults (quality desc)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [photoIds, settingsLoaded])
// Initial load on mount (after settings and state are loaded)
useEffect(() => {
if (!initialLoadRef.current && settingsLoaded && stateRestored) {
initialLoadRef.current = true
// Only load if we didn't restore state (no faces means we need to load)
if (faces.length === 0) {
// Always load if photoIds is provided (from URL) - we need fresh filtered faces
// Otherwise, only load if we didn't restore state (no faces means we need to load)
if (photoIds !== null || faces.length === 0) {
loadFaces()
// If we're loading fresh, mark restoration as complete immediately
restorationCompleteRef.current = true
@@ -413,11 +463,9 @@ export default function Identify() {
}, 50)
}
}
loadPeople()
loadTags()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsLoaded, stateRestored])
}, [settingsLoaded, stateRestored, photoIds])
// Reload when uniqueFacesOnly changes (immediate reload)
// But only if restoration is complete (prevents reload during initial restoration)
@@ -589,7 +637,14 @@ export default function Identify() {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">Identify</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-4">
Identify
{photoIds && (
<span className="ml-2 text-sm font-normal text-gray-600">
(Filtered by {photoIds.length} photo{photoIds.length !== 1 ? 's' : ''})
</span>
)}
</h1>
<div className="grid grid-cols-12 gap-4">
{/* Left: Controls and current face */}
@@ -695,6 +750,7 @@ export default function Identify() {
<div className="mt-2">
<div className="flex gap-2">
<select
key={selectKey}
multiple
value={selectedTags}
onChange={(e) => {
@@ -711,7 +767,10 @@ export default function Identify() {
))}
</select>
<button
onClick={() => setSelectedTags([])}
onClick={() => {
setSelectedTags([])
setSelectKey(prev => prev + 1) // Force select to re-render and clear visual selection
}}
disabled={selectedTags.length === 0}
className="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed text-sm self-start"
title="Clear all tag selections"
@@ -734,14 +793,6 @@ export default function Identify() {
>
{loadingFaces ? 'Loading...' : 'Apply Filters'}
</button>
<button
onClick={() => loadFaces(true)}
disabled={loadingFaces}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
title="Refresh and start from beginning"
>
{loadingFaces ? 'Refreshing...' : '🔄 Refresh'}
</button>
</div>
</div>
</div>
@@ -756,7 +807,18 @@ export default function Identify() {
<button className="px-2 py-1 text-sm border rounded" onClick={() => setCurrentIdx((i) => Math.min(faces.length - 1, i + 1))}>Next</button>
<button
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
onClick={() => loadFaces(true)}
onClick={() => {
// Clear photo_ids from URL if present
if (photoIds !== null) {
const newParams = new URLSearchParams(searchParams)
newParams.delete('photo_ids')
setSearchParams(newParams, { replace: true })
// Load faces without photo_ids filter (ignorePhotoIds = true)
loadFaces(true, true)
} else {
loadFaces(true)
}
}}
disabled={loadingFaces}
title="Refresh and start from beginning"
>
+214 -43
View File
@@ -99,6 +99,11 @@ export default function Tags() {
tagsApi.getPhotosWithTags(),
tagsApi.list(),
])
// Debug: Check if unidentified_face_count is present
if (photosRes.items.length > 0) {
console.log('Sample photo data:', photosRes.items[0])
console.log('unidentified_face_count:', photosRes.items[0].unidentified_face_count)
}
setPhotos(photosRes.items)
setTags(tagsRes.items)
} catch (error) {
@@ -139,6 +144,14 @@ export default function Tags() {
return sortedFolders
}, [photos])
// Count selected photos with unidentified faces (for Identify Faces button)
const selectedPhotosWithFacesCount = useMemo(() => {
return Array.from(selectedPhotoIds).filter(photoId => {
const photo = photos.find(p => p.id === photoId)
return photo && photo.processed && photo.unidentified_face_count > 0
}).length
}, [selectedPhotoIds, photos])
// Get tags for a photo (including pending changes)
const getPhotoTags = (photoId: number): string => {
const photo = photos.find(p => p.id === photoId)
@@ -174,6 +187,23 @@ export default function Tags() {
}))
}
// Handle identifying faces from selected photos
const handleIdentifyFaces = (photoIds: number[]) => {
// Filter to only processed photos with faces
const processedPhotos = photos.filter(p =>
photoIds.includes(p.id) && p.processed && p.unidentified_face_count > 0
)
if (processedPhotos.length === 0) {
alert('No processed photos with faces selected. Please select photos that have been processed and contain faces.')
return
}
// Navigate to Identify page with photo IDs in new tab
const photoIdsStr = processedPhotos.map(p => p.id).join(',')
window.open(`/identify?photo_ids=${photoIdsStr}`, '_blank')
}
// Add tag to photo (with optional immediate save)
const addTagToPhoto = async (photoId: number, tagName: string, linkageType: number = 0, saveImmediately: boolean = false) => {
const tag = tags.find(t => t.tag_name.toLowerCase() === tagName.toLowerCase().trim())
@@ -384,43 +414,31 @@ export default function Tags() {
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-gray-900">Photos tagging interface</h1>
<div className="flex items-center gap-4">
{isDeveloperMode && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">View:</label>
<div className="flex gap-2">
<button
onClick={() => setViewMode('list')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'list'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
List
</button>
<button
onClick={() => setViewMode('icons')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'icons'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Icons
</button>
<button
onClick={() => setViewMode('compact')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'compact'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Compact
</button>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">View:</label>
<div className="flex gap-2">
<button
onClick={() => setViewMode('list')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'list'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
List
</button>
<button
onClick={() => setViewMode('icons')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'icons'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Icons
</button>
</div>
)}
</div>
<button
onClick={() => setShowTagSelectedDialog(true)}
disabled={selectedPhotoIds.size === 0}
@@ -428,6 +446,22 @@ export default function Tags() {
>
Tag Selected Photos ({selectedPhotoIds.size})
</button>
<button
onClick={() => handleIdentifyFaces(Array.from(selectedPhotoIds))}
disabled={selectedPhotosWithFacesCount === 0}
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
title="Open Identify page in new tab with faces from selected photos"
>
🔍 Identify Faces ({selectedPhotosWithFacesCount})
</button>
<button
onClick={() => setSelectedPhotoIds(new Set())}
disabled={selectedPhotoIds.size === 0}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed"
title="Clear all selected photos"
>
Clear Selected
</button>
<button
onClick={() => setShowManageTags(true)}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
@@ -484,6 +518,7 @@ export default function Tags() {
<button
onClick={() => setShowBulkTagDialog(folder.folderPath)}
className="ml-2 px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
title="Manage bulk tags for this folder"
>
🔗
</button>
@@ -522,13 +557,26 @@ export default function Tags() {
<td className="p-2 text-sm text-gray-600">{photo.path}</td>
<td className="p-2">{photo.processed ? 'Yes' : 'No'}</td>
<td className="p-2">{photo.date_taken || 'Unknown'}</td>
<td className="p-2">{photo.face_count}</td>
<td className="p-2">
{photo.processed && (photo.unidentified_face_count ?? 0) > 0 ? (
<button
onClick={() => handleIdentifyFaces([photo.id])}
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
title="Identify faces in this photo"
>
🔍
</button>
) : (
<span>{photo.face_count || 0}</span>
)}
</td>
<td className="p-2">
<div className="flex items-center gap-2">
<span className="text-sm">{getPhotoTags(photo.id)}</span>
<button
onClick={() => setShowTagDialog(photo.id)}
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
title="Tag photos"
>
🔗
</button>
@@ -545,15 +593,138 @@ export default function Tags() {
{viewMode === 'icons' && (
<div className="p-4">
<p className="text-gray-600">Icons view coming soon...</p>
{folderGroups.map(folder => (
<div key={folder.folderPath} className="mb-6">
{/* Folder Header */}
<div className="bg-gray-50 border-b p-3 mb-3 rounded-t">
<div className="flex items-center gap-2">
<button
onClick={() => toggleFolder(folder.folderPath)}
className="px-2 py-1 text-sm hover:bg-gray-200 rounded"
>
{folderStates[folder.folderPath] === true ? '▼' : '▶'}
</button>
<span className="font-semibold">
📁 {folder.folderName} ({folder.photoCount} photos)
</span>
<button
onClick={() => setShowBulkTagDialog(folder.folderPath)}
className="ml-2 px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
title="Manage bulk tags for this folder"
>
🔗
</button>
</div>
</div>
{/* Photo Grid */}
{folderStates[folder.folderPath] === true && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{folder.photos.map(photo => {
const photoUrl = `/api/v1/photos/${photo.id}/image`
const isSelected = selectedPhotoIds.has(photo.id)
return (
<div
key={photo.id}
className="relative bg-white border border-gray-200 rounded-lg overflow-hidden hover:shadow-lg transition-shadow"
>
{/* Checkbox */}
<div className="absolute top-2 left-2 z-10">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
const newSet = new Set(selectedPhotoIds)
if (e.target.checked) {
newSet.add(photo.id)
} else {
newSet.delete(photo.id)
}
setSelectedPhotoIds(newSet)
}}
className="w-5 h-5 cursor-pointer"
onClick={(e) => e.stopPropagation()}
/>
</div>
{/* Action Buttons */}
<div className="absolute top-2 right-2 z-10 flex gap-1">
{photo.processed && (photo.unidentified_face_count ?? 0) > 0 && (
<button
onClick={(e) => {
e.stopPropagation()
handleIdentifyFaces([photo.id])
}}
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200 shadow"
title="Identify faces in this photo"
>
🔍
</button>
)}
<button
onClick={(e) => {
e.stopPropagation()
setShowTagDialog(photo.id)
}}
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200 shadow"
title="Tag photos"
>
🔗
</button>
</div>
{/* Thumbnail */}
<div className="w-full aspect-square bg-gray-100 overflow-hidden">
<img
src={photoUrl}
alt={photo.filename}
className="w-full h-full object-cover cursor-pointer"
onClick={() => window.open(photoUrl, '_blank')}
onError={(e) => {
const target = e.target as HTMLImageElement
target.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="150" height="150"%3E%3Crect fill="%23ddd" width="150" height="150"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999"%3ENo Image%3C/text%3E%3C/svg%3E'
}}
/>
</div>
{/* Metadata Overlay */}
<div className="p-2 bg-white">
<div className="text-xs space-y-1">
<div className="font-semibold text-gray-900 truncate" title={`ID: ${photo.id}`}>
ID: {photo.id}
</div>
<div className="text-gray-700 truncate" title={photo.filename}>
{photo.filename}
</div>
<div className="flex items-center gap-2 text-gray-600">
<span className={photo.processed ? 'text-green-600' : 'text-orange-600'}>
{photo.processed ? '✓ Processed' : '○ Unprocessed'}
</span>
</div>
{photo.date_taken && (
<div className="text-gray-600 truncate" title={photo.date_taken}>
📅 {photo.date_taken}
</div>
)}
<div className="text-gray-600">
👤 {photo.face_count} face{photo.face_count !== 1 ? 's' : ''}
</div>
<div className="text-gray-600 truncate" title={getPhotoTags(photo.id)}>
🏷️ {getPhotoTags(photo.id)}
</div>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
))}
</div>
)}
{viewMode === 'compact' && (
<div className="p-4">
<p className="text-gray-600">Compact view coming soon...</p>
</div>
)}
</div>
{/* Manage Tags Dialog */}