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:
parent
a41e30b101
commit
2f2e44c933
@ -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 {
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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
|
||||
]
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user