feat: Add sorting and filtering capabilities to Tags component with people names integration

This commit enhances the Tags component by introducing sorting functionality for various columns, including ID, filename, media type, and more. A filter option is added to display only photos with unidentified faces. Additionally, the API and data models are updated to include a new field for people names, allowing users to see identified individuals in the photo. The UI is improved with dropdowns for sorting and checkboxes for filtering, enhancing user experience. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-12-04 15:44:48 -05:00
parent a41e30b101
commit 2f2e44c933
5 changed files with 322 additions and 18 deletions

View File

@ -52,6 +52,8 @@ export interface PhotoWithTagsItem {
face_count: number
unidentified_face_count: number // Count of faces with person_id IS NULL
tags: string // Comma-separated tags string
people_names: string // Comma-separated people names string
media_type?: string | null // 'image' or 'video'
}
export interface PhotosWithTagsResponse {

View File

@ -57,6 +57,13 @@ export default function Tags() {
const [selectedPhotoIds, setSelectedPhotoIds] = useState<Set<number>>(new Set())
const [showTagSelectedDialog, setShowTagSelectedDialog] = useState(false)
// Sorting state - default to date_taken (descending, matching the default sort behavior)
const [sortColumn, setSortColumn] = useState<string | null>('date_taken')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
// Filter state
const [showOnlyUnidentified, setShowOnlyUnidentified] = useState(false)
// Refs to capture latest folder states for unmount save
const folderStatesRef = useRef(folderStates)
@ -113,9 +120,28 @@ export default function Tags() {
}
// Group photos by folder
// Handle sorting
const handleSort = (column: string) => {
if (sortColumn === column) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
} else {
setSortColumn(column)
setSortDir('asc')
}
}
const folderGroups = useMemo(() => {
// Filter photos if "Show only unIdentified photos" is checked
const filteredPhotos = showOnlyUnidentified
? photos.filter(photo => {
const unidentifiedCount = photo.unidentified_face_count || 0
return unidentifiedCount > 0
})
: photos
const groups: Record<string, PhotoWithTagsItem[]> = {}
photos.forEach(photo => {
filteredPhotos.forEach(photo => {
const folderPath = photo.path.substring(0, photo.path.lastIndexOf('/') || photo.path.length)
if (!groups[folderPath]) {
groups[folderPath] = []
@ -126,11 +152,98 @@ export default function Tags() {
const sortedFolders: FolderGroup[] = []
Object.keys(groups).sort().forEach(folderPath => {
const folderName = folderPath.substring(folderPath.lastIndexOf('/') + 1) || 'Root'
const photosInFolder = groups[folderPath].sort((a, b) => {
const dateA = a.date_taken || ''
const dateB = b.date_taken || ''
return dateB.localeCompare(dateA)
})
let photosInFolder = groups[folderPath]
// Apply sorting if a column is selected
if (sortColumn) {
photosInFolder = [...photosInFolder].sort((a, b) => {
let aVal: any
let bVal: any
switch (sortColumn) {
case 'id':
aVal = a.id
bVal = b.id
break
case 'filename':
aVal = a.filename || ''
bVal = b.filename || ''
break
case 'path':
aVal = a.path || ''
bVal = b.path || ''
break
case 'processed':
aVal = a.processed ? 'Yes' : 'No'
bVal = b.processed ? 'Yes' : 'No'
break
case 'media_type':
aVal = (a.media_type || 'image').toLowerCase()
bVal = (b.media_type || 'image').toLowerCase()
break
case 'date_taken':
aVal = a.date_taken || '9999-12-31'
bVal = b.date_taken || '9999-12-31'
break
case 'faces':
aVal = a.face_count || 0
bVal = b.face_count || 0
break
case 'identified':
// Sort by identified count (identified/total ratio)
const aTotal = a.face_count || 0
const aIdentified = aTotal - (a.unidentified_face_count || 0)
const bTotal = b.face_count || 0
const bIdentified = bTotal - (b.unidentified_face_count || 0)
// Use ratio for sorting (identified/total), or just identified count if same ratio
const aRatio = aTotal > 0 ? aIdentified / aTotal : 0
const bRatio = bTotal > 0 ? bIdentified / bTotal : 0
if (aRatio !== bRatio) {
aVal = aRatio
bVal = bRatio
} else {
aVal = aIdentified
bVal = bIdentified
}
break
case 'tags':
// Get tags for comparison - use photo.tags directly
const aTags = (a.tags || '').toLowerCase()
const bTags = (b.tags || '').toLowerCase()
aVal = aTags
bVal = bTags
break
default:
return 0
}
// Handle string comparison
if (typeof aVal === 'string' && typeof bVal === 'string') {
aVal = aVal.toLowerCase()
bVal = bVal.toLowerCase()
}
// Numeric comparison
if (typeof aVal === 'number' && typeof bVal === 'number') {
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1
return 0
}
// String comparison
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1
return 0
})
} else {
// Default sort by date_taken (descending)
photosInFolder = [...photosInFolder].sort((a, b) => {
const dateA = a.date_taken || ''
const dateB = b.date_taken || ''
return dateB.localeCompare(dateA)
})
}
sortedFolders.push({
folderPath,
folderName,
@ -140,7 +253,7 @@ export default function Tags() {
})
return sortedFolders
}, [photos])
}, [photos, sortColumn, sortDir, showOnlyUnidentified])
// Count selected photos with unidentified faces (for Identify Faces button)
const selectedPhotosWithFacesCount = useMemo(() => {
@ -391,9 +504,72 @@ export default function Tags() {
)
}
// Sort options for dropdown
const sortOptions = [
{ value: 'date_taken', label: 'Default (Date Taken)' },
{ value: 'id', label: 'ID' },
{ value: 'filename', label: 'Filename' },
{ value: 'path', label: 'Path' },
{ value: 'processed', label: 'Processed' },
{ value: 'media_type', label: 'Media Type' },
{ value: 'faces', label: 'Faces' },
{ value: 'identified', label: 'Identified' },
{ value: 'tags', label: 'Tags' },
]
const handleSortChange = (value: string) => {
setSortColumn(value)
setSortDir('asc')
}
const toggleSortDirection = () => {
if (sortColumn) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
}
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-end mb-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="showOnlyUnidentified"
checked={showOnlyUnidentified}
onChange={(e) => setShowOnlyUnidentified(e.target.checked)}
className="w-4 h-4"
/>
<label htmlFor="showOnlyUnidentified" className="text-sm font-medium text-gray-700 cursor-pointer">
Show only unIdentified photos
</label>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Sort by:</label>
<div className="flex items-center gap-2">
<select
value={sortColumn || 'date_taken'}
onChange={(e) => handleSortChange(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{sortOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{sortColumn && (
<button
onClick={toggleSortDirection}
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
title={`Sort ${sortDir === 'asc' ? 'Ascending' : 'Descending'}`}
>
{sortDir === 'asc' ? '↑' : '↓'}
</button>
)}
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">View:</label>
@ -472,20 +648,67 @@ export default function Tags() {
className="w-4 h-4"
/>
</th>
<th className="text-left p-2 font-semibold">ID</th>
<th className="text-left p-2 font-semibold">Filename</th>
<th className="text-left p-2 font-semibold">Path</th>
<th className="text-left p-2 font-semibold">Processed</th>
<th className="text-left p-2 font-semibold">Date Taken</th>
<th className="text-left p-2 font-semibold">Faces</th>
<th className="text-left p-2 font-semibold">Tags</th>
<th
className="text-left p-2 font-semibold cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('id')}
>
ID {sortColumn === 'id' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 font-semibold cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('filename')}
>
Filename {sortColumn === 'filename' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 font-semibold cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('path')}
>
Path {sortColumn === 'path' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 font-semibold cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('processed')}
>
Processed {sortColumn === 'processed' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 font-semibold cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('media_type')}
>
Media type {sortColumn === 'media_type' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 font-semibold cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('date_taken')}
>
Date Taken {sortColumn === 'date_taken' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 font-semibold cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('faces')}
>
Faces {sortColumn === 'faces' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 font-semibold cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('identified')}
>
Identified {sortColumn === 'identified' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 font-semibold cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('tags')}
>
Tags {sortColumn === 'tags' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
</tr>
</thead>
<tbody>
{folderGroups.map(folder => (
<React.Fragment key={folder.folderPath}>
<tr className="bg-gray-50 border-b">
<td colSpan={8} className="p-2">
<td colSpan={10} className="p-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
@ -542,6 +765,7 @@ export default function Tags() {
</td>
<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.media_type === 'video' ? 'Video' : 'Photo'}</td>
<td className="p-2">{photo.date_taken || 'Unknown'}</td>
<td className="p-2">
<div className="flex items-center justify-center">
@ -558,6 +782,30 @@ export default function Tags() {
)}
</div>
</td>
<td className="p-2">
{(() => {
const totalFaces = photo.face_count || 0
const identifiedFaces = totalFaces - (photo.unidentified_face_count || 0)
const allIdentified = totalFaces > 0 && identifiedFaces === totalFaces
const someIdentified = totalFaces > 0 && identifiedFaces < totalFaces
const peopleNames = photo.people_names || ''
return (
<span
className={`px-2 py-1 rounded text-sm cursor-help ${
allIdentified
? 'bg-green-100 text-green-800'
: someIdentified
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-600'
}`}
title={peopleNames || (totalFaces > 0 ? 'No people identified' : 'No faces detected')}
>
{identifiedFaces}/{totalFaces}
</span>
)
})()}
</td>
<td className="p-2">
<div className="flex items-center gap-2">
<span className="text-sm">{getPhotoTags(photo.id)}</span>
@ -701,7 +949,32 @@ export default function Tags() {
</div>
)}
<div className="text-gray-600">
👤 {photo.face_count} face{photo.face_count !== 1 ? 's' : ''}
{(() => {
const totalFaces = photo.face_count || 0
const identifiedFaces = totalFaces - (photo.unidentified_face_count || 0)
const allIdentified = totalFaces > 0 && identifiedFaces === totalFaces
const someIdentified = totalFaces > 0 && identifiedFaces < totalFaces
const peopleNames = photo.people_names || ''
return (
<span
className={`px-2 py-1 rounded text-sm cursor-help inline-block ${
allIdentified
? 'bg-green-100 text-green-800'
: someIdentified
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-600'
}`}
title={peopleNames || (totalFaces > 0 ? 'No people identified' : 'No faces detected')}
>
👤 {totalFaces > 0 ? (
<span>{identifiedFaces}/{totalFaces}</span>
) : (
<span></span>
)}
</span>
)
})()}
</div>
<div className="text-gray-600 truncate" title={getPhotoTags(photo.id)}>
🏷 {getPhotoTags(photo.id)}

View File

@ -182,6 +182,7 @@ def get_photos_with_tags_endpoint(db: Session = Depends(get_db)) -> PhotosWithTa
face_count=p['face_count'],
unidentified_face_count=p['unidentified_face_count'],
tags=p['tags'],
people_names=p.get('people_names', ''),
)
for p in photos_data
]

View File

@ -87,6 +87,7 @@ class PhotoWithTagsItem(BaseModel):
face_count: int
unidentified_face_count: int # Count of faces with person_id IS NULL
tags: str # Comma-separated tags string (matching desktop)
people_names: str = "" # Comma-separated people names string
class PhotosWithTagsResponse(BaseModel):

View File

@ -7,7 +7,7 @@ from datetime import datetime
from sqlalchemy.orm import Session
from src.web.db.models import Photo, PhotoTagLinkage, Tag, Face
from src.web.db.models import Photo, PhotoTagLinkage, Tag, Face, Person
def list_tags(db: Session) -> List[Tag]:
@ -268,6 +268,31 @@ def get_photos_with_tags(db: Session) -> List[dict]:
)
tags = ", ".join([t[0] for t in tags_query]) if tags_query else ""
# Get people names as comma-separated string (unique people identified in photo)
people_query = (
db.query(Person)
.join(Face, Person.id == Face.person_id)
.filter(Face.photo_id == photo.id)
.filter(Face.person_id.isnot(None))
.order_by(Person.last_name, Person.first_name)
.distinct()
.all()
)
people_names = []
for person in people_query:
name_parts = []
if person.first_name:
name_parts.append(person.first_name)
if person.middle_name:
name_parts.append(person.middle_name)
if person.last_name:
name_parts.append(person.last_name)
if person.maiden_name:
name_parts.append(f"({person.maiden_name})")
full_name = " ".join(name_parts) if name_parts else "Unknown"
people_names.append(full_name)
people_names_str = ", ".join(people_names) if people_names else ""
result.append({
'id': photo.id,
'filename': photo.filename,
@ -278,6 +303,8 @@ def get_photos_with_tags(db: Session) -> List[dict]:
'face_count': face_count,
'unidentified_face_count': unidentified_face_count,
'tags': tags,
'people_names': people_names_str,
'media_type': photo.media_type or 'image',
})
return result