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