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:
@@ -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 {
|
||||
|
||||
+290
-17
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user