From 2f2e44c9333ab7ab84be3b23b2ee90a22b5e2f04 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Thu, 4 Dec 2025 15:44:48 -0500 Subject: [PATCH] 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. --- frontend/src/api/tags.ts | 2 + frontend/src/pages/Tags.tsx | 307 ++++++++++++++++++++++++++++++-- src/web/api/tags.py | 1 + src/web/schemas/tags.py | 1 + src/web/services/tag_service.py | 29 ++- 5 files changed, 322 insertions(+), 18 deletions(-) diff --git a/frontend/src/api/tags.ts b/frontend/src/api/tags.ts index c58f3d6..02219e5 100644 --- a/frontend/src/api/tags.ts +++ b/frontend/src/api/tags.ts @@ -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 { diff --git a/frontend/src/pages/Tags.tsx b/frontend/src/pages/Tags.tsx index 8a0a8a2..896ef43 100644 --- a/frontend/src/pages/Tags.tsx +++ b/frontend/src/pages/Tags.tsx @@ -57,6 +57,13 @@ export default function Tags() { const [selectedPhotoIds, setSelectedPhotoIds] = useState>(new Set()) const [showTagSelectedDialog, setShowTagSelectedDialog] = useState(false) + // Sorting state - default to date_taken (descending, matching the default sort behavior) + const [sortColumn, setSortColumn] = useState('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 = {} - 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 (
-
+
+
+
+ setShowOnlyUnidentified(e.target.checked)} + className="w-4 h-4" + /> + +
+
+ +
+ + {sortColumn && ( + + )} +
+
+
@@ -472,20 +648,67 @@ export default function Tags() { className="w-4 h-4" /> - ID - Filename - Path - Processed - Date Taken - Faces - Tags + handleSort('id')} + > + ID {sortColumn === 'id' && (sortDir === 'asc' ? '↑' : '↓')} + + handleSort('filename')} + > + Filename {sortColumn === 'filename' && (sortDir === 'asc' ? '↑' : '↓')} + + handleSort('path')} + > + Path {sortColumn === 'path' && (sortDir === 'asc' ? '↑' : '↓')} + + handleSort('processed')} + > + Processed {sortColumn === 'processed' && (sortDir === 'asc' ? '↑' : '↓')} + + handleSort('media_type')} + > + Media type {sortColumn === 'media_type' && (sortDir === 'asc' ? '↑' : '↓')} + + handleSort('date_taken')} + > + Date Taken {sortColumn === 'date_taken' && (sortDir === 'asc' ? '↑' : '↓')} + + handleSort('faces')} + > + Faces {sortColumn === 'faces' && (sortDir === 'asc' ? '↑' : '↓')} + + handleSort('identified')} + > + Identified {sortColumn === 'identified' && (sortDir === 'asc' ? '↑' : '↓')} + + handleSort('tags')} + > + Tags {sortColumn === 'tags' && (sortDir === 'asc' ? '↑' : '↓')} + {folderGroups.map(folder => ( - +
{photo.path} {photo.processed ? 'Yes' : 'No'} + {photo.media_type === 'video' ? 'Video' : 'Photo'} {photo.date_taken || 'Unknown'}
@@ -558,6 +782,30 @@ export default function Tags() { )}
+ + {(() => { + 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 ( + 0 ? 'No people identified' : 'No faces detected')} + > + {identifiedFaces}/{totalFaces} + + ) + })()} +
{getPhotoTags(photo.id)} @@ -701,7 +949,32 @@ export default function Tags() {
)}
- 👤 {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 ( + 0 ? 'No people identified' : 'No faces detected')} + > + 👤 {totalFaces > 0 ? ( + {identifiedFaces}/{totalFaces} + ) : ( + + )} + + ) + })()}
🏷️ {getPhotoTags(photo.id)} diff --git a/src/web/api/tags.py b/src/web/api/tags.py index 6cc2850..bff8712 100644 --- a/src/web/api/tags.py +++ b/src/web/api/tags.py @@ -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 ] diff --git a/src/web/schemas/tags.py b/src/web/schemas/tags.py index 29a2b60..30c0a68 100644 --- a/src/web/schemas/tags.py +++ b/src/web/schemas/tags.py @@ -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): diff --git a/src/web/services/tag_service.py b/src/web/services/tag_service.py index ed5715c..af4e20a 100644 --- a/src/web/services/tag_service.py +++ b/src/web/services/tag_service.py @@ -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