feat: Enhance photo identification and tagging features with new filters and counts
This commit introduces several enhancements to the photo identification and tagging functionalities. The Identify component now supports filtering by photo IDs, allowing users to view faces from specific photos. Additionally, the Tags component has been updated to include an unidentified face count for each photo, improving user awareness of untagged faces. The API has been modified to accommodate these new parameters, ensuring seamless integration with the frontend. Documentation has been updated to reflect these changes.
This commit is contained in:
parent
89a63cbf57
commit
cfb94900ef
90
README.md
90
README.md
@ -116,7 +116,9 @@ The web version uses the **exact same schema** as the desktop version for full c
|
||||
redis-server
|
||||
```
|
||||
|
||||
**Terminal 2 - Backend API (automatically starts RQ worker):**
|
||||
#### Option 1: Manual Start (Recommended for Development)
|
||||
|
||||
**Terminal 1 - Backend API:**
|
||||
```bash
|
||||
cd /home/ladmin/Code/punimtag
|
||||
source venv/bin/activate
|
||||
@ -126,33 +128,99 @@ uvicorn src.web.app:app --host 127.0.0.1 --port 8000
|
||||
|
||||
You should see:
|
||||
```
|
||||
✅ Database already initialized (7 tables exist)
|
||||
✅ RQ worker started in background subprocess (PID: ...)
|
||||
INFO: Started server process
|
||||
INFO: Uvicorn running on http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
**Note:** The RQ worker automatically starts in a background subprocess when the API starts. You'll see a confirmation message with the worker PID. If Redis isn't running, you'll see a warning message.
|
||||
|
||||
**Terminal 3 - Frontend:**
|
||||
**Terminal 2 - Frontend:**
|
||||
```bash
|
||||
cd /home/ladmin/Code/punimtag/frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Then open your browser to **http://localhost:3000**
|
||||
You should see:
|
||||
```
|
||||
VITE v5.4.21 ready in 811 ms
|
||||
➜ Local: http://localhost:3000/
|
||||
```
|
||||
|
||||
**Default Login:**
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
#### Option 2: Using Helper Script (Backend + Worker)
|
||||
|
||||
**Terminal 1 - Backend API + Worker:**
|
||||
```bash
|
||||
cd /home/ladmin/Code/punimtag
|
||||
./run_api_with_worker.sh
|
||||
```
|
||||
|
||||
This script will:
|
||||
- Check if Redis is running (start it if needed)
|
||||
- Start the RQ worker in the background
|
||||
- Start the FastAPI server
|
||||
- Handle cleanup on Ctrl+C
|
||||
|
||||
**Terminal 2 - Frontend:**
|
||||
```bash
|
||||
cd /home/ladmin/Code/punimtag/frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
#### Access the Application
|
||||
|
||||
1. Open your browser to **http://localhost:3000**
|
||||
2. Login with default credentials:
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
3. API documentation available at **http://127.0.0.1:8000/docs**
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
**Port 8000 already in use:**
|
||||
```bash
|
||||
# Find and kill the process using port 8000
|
||||
lsof -i :8000
|
||||
kill <PID>
|
||||
|
||||
# Or use pkill
|
||||
pkill -f "uvicorn.*app"
|
||||
```
|
||||
|
||||
**Port 3000 already in use:**
|
||||
```bash
|
||||
# Find and kill the process using port 3000
|
||||
lsof -i :3000
|
||||
kill <PID>
|
||||
|
||||
# Or change the port in frontend/vite.config.ts
|
||||
```
|
||||
|
||||
**Redis not running:**
|
||||
```bash
|
||||
# Start Redis
|
||||
sudo systemctl start redis-server
|
||||
# Or
|
||||
redis-server
|
||||
```
|
||||
|
||||
**Database issues:**
|
||||
```bash
|
||||
# Recreate all tables (WARNING: This will delete all data!)
|
||||
cd /home/ladmin/Code/punimtag
|
||||
source venv/bin/activate
|
||||
export PYTHONPATH=/home/ladmin/Code/punimtag
|
||||
python scripts/recreate_tables_web.py
|
||||
```
|
||||
|
||||
#### Important Notes
|
||||
|
||||
**Note:**
|
||||
- The database and tables are **automatically created on first startup** - no manual setup needed!
|
||||
- The RQ worker starts automatically in a background subprocess when the API server starts
|
||||
- Make sure Redis is running first, or the worker won't start
|
||||
- Worker names are unique to avoid conflicts when restarting
|
||||
- Photo uploads are stored in `data/uploads` (configurable via `PHOTO_STORAGE_DIR` env var)
|
||||
- **DeepFace models download automatically on first use** (can take 5-10 minutes)
|
||||
- **If port 8000 is in use**, kill the process: `lsof -i :8000` then `kill <PID>` or `pkill -f "uvicorn.*app"`
|
||||
- **DeepFace models download automatically on first use** (can take 5-10 minutes, ~100MB)
|
||||
- First run is slower due to model downloads (subsequent runs are faster)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -184,6 +184,7 @@ export const facesApi = {
|
||||
sort_dir?: 'asc' | 'desc'
|
||||
tag_names?: string
|
||||
match_all?: boolean
|
||||
photo_ids?: string
|
||||
}): Promise<UnidentifiedFacesResponse> => {
|
||||
const response = await apiClient.get<UnidentifiedFacesResponse>('/api/v1/faces/unidentified', {
|
||||
params,
|
||||
|
||||
@ -52,6 +52,7 @@ export interface PhotoWithTagsItem {
|
||||
date_taken?: string | null
|
||||
date_added?: string | null
|
||||
face_count: number
|
||||
unidentified_face_count: number // Count of faces with person_id IS NULL
|
||||
tags: string // Comma-separated tags string
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import facesApi, { FaceItem, SimilarFaceItem } from '../api/faces'
|
||||
import peopleApi, { Person } from '../api/people'
|
||||
import { apiClient } from '../api/client'
|
||||
@ -10,6 +11,7 @@ type SortDir = 'asc' | 'desc'
|
||||
|
||||
export default function Identify() {
|
||||
const { isDeveloperMode } = useDeveloperMode()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [faces, setFaces] = useState<FaceItem[]>([])
|
||||
const [, setTotal] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(50)
|
||||
@ -20,6 +22,14 @@ export default function Identify() {
|
||||
const [dateTo, setDateTo] = useState<string>('')
|
||||
// dateProcessed filter is hidden, so state removed
|
||||
// const [dateProcessed, setDateProcessed] = useState<string>('')
|
||||
|
||||
// Parse photo_ids from URL parameter
|
||||
const photoIds = useMemo(() => {
|
||||
const photoIdsParam = searchParams.get('photo_ids')
|
||||
if (!photoIdsParam) return null
|
||||
const ids = photoIdsParam.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id))
|
||||
return ids.length > 0 ? ids : null
|
||||
}, [searchParams])
|
||||
|
||||
const [currentIdx, setCurrentIdx] = useState(0)
|
||||
const currentFace = faces[currentIdx]
|
||||
@ -48,6 +58,7 @@ export default function Identify() {
|
||||
const [availableTags, setAvailableTags] = useState<TagResponse[]>([])
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([])
|
||||
const [tagsExpanded, setTagsExpanded] = useState(false)
|
||||
const [selectKey, setSelectKey] = useState(0) // Key to force select re-render when clearing
|
||||
|
||||
// Store form data per face ID (matching desktop behavior)
|
||||
const [faceFormData, setFaceFormData] = useState<Record<number, {
|
||||
@ -74,7 +85,7 @@ export default function Identify() {
|
||||
return Boolean((personId && currentFace) || (firstName && lastName && dob && currentFace))
|
||||
}, [personId, firstName, lastName, dob, currentFace])
|
||||
|
||||
const loadFaces = async (clearState: boolean = false) => {
|
||||
const loadFaces = async (clearState: boolean = false, ignorePhotoIds: boolean = false) => {
|
||||
setLoadingFaces(true)
|
||||
|
||||
try {
|
||||
@ -95,6 +106,7 @@ export default function Identify() {
|
||||
sort_dir: sortDir,
|
||||
tag_names: selectedTags.length > 0 ? selectedTags.join(', ') : undefined,
|
||||
match_all: false, // Default to match any tag
|
||||
photo_ids: (ignorePhotoIds || !photoIds) ? undefined : photoIds.join(','), // Filter by photo IDs if provided and not ignored
|
||||
})
|
||||
|
||||
// Apply unique faces filter if enabled
|
||||
@ -258,8 +270,15 @@ export default function Identify() {
|
||||
}
|
||||
|
||||
// Load settings from sessionStorage on mount
|
||||
// If photoIds is provided, skip loading filter settings (we want to show all faces from those photos)
|
||||
useEffect(() => {
|
||||
try {
|
||||
// If photoIds is in URL, don't load filter settings - we'll use defaults to show all faces
|
||||
if (photoIds !== null) {
|
||||
setSettingsLoaded(true)
|
||||
return
|
||||
}
|
||||
|
||||
const saved = sessionStorage.getItem(SETTINGS_KEY)
|
||||
if (saved) {
|
||||
const settings = JSON.parse(saved)
|
||||
@ -281,11 +300,18 @@ export default function Identify() {
|
||||
setSettingsLoaded(true)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, [photoIds])
|
||||
|
||||
// Load state from sessionStorage on mount (faces, current index, similar, form data)
|
||||
// Skip state restoration if photoIds is provided in URL (we want fresh filtered data)
|
||||
useEffect(() => {
|
||||
try {
|
||||
// If photoIds is in URL, don't restore state - we need to load fresh filtered faces
|
||||
if (photoIds !== null) {
|
||||
setStateRestored(true)
|
||||
return
|
||||
}
|
||||
|
||||
const saved = sessionStorage.getItem(STATE_KEY)
|
||||
if (saved) {
|
||||
const state = JSON.parse(saved)
|
||||
@ -318,7 +344,7 @@ export default function Identify() {
|
||||
setStateRestored(true)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, [photoIds])
|
||||
|
||||
// Save state to sessionStorage whenever it changes (but only after initial restore)
|
||||
useEffect(() => {
|
||||
@ -395,12 +421,36 @@ export default function Identify() {
|
||||
}
|
||||
}, [pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo, uniqueFacesOnly, compareEnabled, selectedTags, settingsLoaded])
|
||||
|
||||
// Load tags and people on mount (always, regardless of other conditions)
|
||||
useEffect(() => {
|
||||
if (settingsLoaded) {
|
||||
loadPeople()
|
||||
loadTags()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settingsLoaded])
|
||||
|
||||
// Reset filters when photoIds is provided (to ensure all faces from those photos are shown)
|
||||
useEffect(() => {
|
||||
if (photoIds !== null && settingsLoaded) {
|
||||
// Reset filters to defaults to show all faces from the selected photos
|
||||
setMinQuality(0.0)
|
||||
setDateFrom('')
|
||||
setDateTo('')
|
||||
setSelectedTags([])
|
||||
// Keep uniqueFacesOnly as is (user preference)
|
||||
// Keep sortBy/sortDir as defaults (quality desc)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [photoIds, settingsLoaded])
|
||||
|
||||
// Initial load on mount (after settings and state are loaded)
|
||||
useEffect(() => {
|
||||
if (!initialLoadRef.current && settingsLoaded && stateRestored) {
|
||||
initialLoadRef.current = true
|
||||
// Only load if we didn't restore state (no faces means we need to load)
|
||||
if (faces.length === 0) {
|
||||
// Always load if photoIds is provided (from URL) - we need fresh filtered faces
|
||||
// Otherwise, only load if we didn't restore state (no faces means we need to load)
|
||||
if (photoIds !== null || faces.length === 0) {
|
||||
loadFaces()
|
||||
// If we're loading fresh, mark restoration as complete immediately
|
||||
restorationCompleteRef.current = true
|
||||
@ -413,11 +463,9 @@ export default function Identify() {
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
loadPeople()
|
||||
loadTags()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settingsLoaded, stateRestored])
|
||||
}, [settingsLoaded, stateRestored, photoIds])
|
||||
|
||||
// Reload when uniqueFacesOnly changes (immediate reload)
|
||||
// But only if restoration is complete (prevents reload during initial restoration)
|
||||
@ -589,7 +637,14 @@ export default function Identify() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Identify</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Identify
|
||||
{photoIds && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-600">
|
||||
(Filtered by {photoIds.length} photo{photoIds.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Left: Controls and current face */}
|
||||
@ -695,6 +750,7 @@ export default function Identify() {
|
||||
<div className="mt-2">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
key={selectKey}
|
||||
multiple
|
||||
value={selectedTags}
|
||||
onChange={(e) => {
|
||||
@ -711,7 +767,10 @@ export default function Identify() {
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setSelectedTags([])}
|
||||
onClick={() => {
|
||||
setSelectedTags([])
|
||||
setSelectKey(prev => prev + 1) // Force select to re-render and clear visual selection
|
||||
}}
|
||||
disabled={selectedTags.length === 0}
|
||||
className="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed text-sm self-start"
|
||||
title="Clear all tag selections"
|
||||
@ -734,14 +793,6 @@ export default function Identify() {
|
||||
>
|
||||
{loadingFaces ? 'Loading...' : 'Apply Filters'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => loadFaces(true)}
|
||||
disabled={loadingFaces}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
title="Refresh and start from beginning"
|
||||
>
|
||||
{loadingFaces ? 'Refreshing...' : '🔄 Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -756,7 +807,18 @@ export default function Identify() {
|
||||
<button className="px-2 py-1 text-sm border rounded" onClick={() => setCurrentIdx((i) => Math.min(faces.length - 1, i + 1))}>Next</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
onClick={() => loadFaces(true)}
|
||||
onClick={() => {
|
||||
// Clear photo_ids from URL if present
|
||||
if (photoIds !== null) {
|
||||
const newParams = new URLSearchParams(searchParams)
|
||||
newParams.delete('photo_ids')
|
||||
setSearchParams(newParams, { replace: true })
|
||||
// Load faces without photo_ids filter (ignorePhotoIds = true)
|
||||
loadFaces(true, true)
|
||||
} else {
|
||||
loadFaces(true)
|
||||
}
|
||||
}}
|
||||
disabled={loadingFaces}
|
||||
title="Refresh and start from beginning"
|
||||
>
|
||||
|
||||
@ -99,6 +99,11 @@ export default function Tags() {
|
||||
tagsApi.getPhotosWithTags(),
|
||||
tagsApi.list(),
|
||||
])
|
||||
// Debug: Check if unidentified_face_count is present
|
||||
if (photosRes.items.length > 0) {
|
||||
console.log('Sample photo data:', photosRes.items[0])
|
||||
console.log('unidentified_face_count:', photosRes.items[0].unidentified_face_count)
|
||||
}
|
||||
setPhotos(photosRes.items)
|
||||
setTags(tagsRes.items)
|
||||
} catch (error) {
|
||||
@ -139,6 +144,14 @@ export default function Tags() {
|
||||
return sortedFolders
|
||||
}, [photos])
|
||||
|
||||
// Count selected photos with unidentified faces (for Identify Faces button)
|
||||
const selectedPhotosWithFacesCount = useMemo(() => {
|
||||
return Array.from(selectedPhotoIds).filter(photoId => {
|
||||
const photo = photos.find(p => p.id === photoId)
|
||||
return photo && photo.processed && photo.unidentified_face_count > 0
|
||||
}).length
|
||||
}, [selectedPhotoIds, photos])
|
||||
|
||||
// Get tags for a photo (including pending changes)
|
||||
const getPhotoTags = (photoId: number): string => {
|
||||
const photo = photos.find(p => p.id === photoId)
|
||||
@ -174,6 +187,23 @@ export default function Tags() {
|
||||
}))
|
||||
}
|
||||
|
||||
// Handle identifying faces from selected photos
|
||||
const handleIdentifyFaces = (photoIds: number[]) => {
|
||||
// Filter to only processed photos with faces
|
||||
const processedPhotos = photos.filter(p =>
|
||||
photoIds.includes(p.id) && p.processed && p.unidentified_face_count > 0
|
||||
)
|
||||
|
||||
if (processedPhotos.length === 0) {
|
||||
alert('No processed photos with faces selected. Please select photos that have been processed and contain faces.')
|
||||
return
|
||||
}
|
||||
|
||||
// Navigate to Identify page with photo IDs in new tab
|
||||
const photoIdsStr = processedPhotos.map(p => p.id).join(',')
|
||||
window.open(`/identify?photo_ids=${photoIdsStr}`, '_blank')
|
||||
}
|
||||
|
||||
// Add tag to photo (with optional immediate save)
|
||||
const addTagToPhoto = async (photoId: number, tagName: string, linkageType: number = 0, saveImmediately: boolean = false) => {
|
||||
const tag = tags.find(t => t.tag_name.toLowerCase() === tagName.toLowerCase().trim())
|
||||
@ -384,43 +414,31 @@ export default function Tags() {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Photos tagging interface</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
{isDeveloperMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">View:</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
viewMode === 'list'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('icons')}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
viewMode === 'icons'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Icons
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('compact')}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
viewMode === 'compact'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Compact
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">View:</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
viewMode === 'list'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('icons')}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
viewMode === 'icons'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Icons
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowTagSelectedDialog(true)}
|
||||
disabled={selectedPhotoIds.size === 0}
|
||||
@ -428,6 +446,22 @@ export default function Tags() {
|
||||
>
|
||||
Tag Selected Photos ({selectedPhotoIds.size})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleIdentifyFaces(Array.from(selectedPhotoIds))}
|
||||
disabled={selectedPhotosWithFacesCount === 0}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
title="Open Identify page in new tab with faces from selected photos"
|
||||
>
|
||||
🔍 Identify Faces ({selectedPhotosWithFacesCount})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedPhotoIds(new Set())}
|
||||
disabled={selectedPhotoIds.size === 0}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed"
|
||||
title="Clear all selected photos"
|
||||
>
|
||||
Clear Selected
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowManageTags(true)}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
@ -484,6 +518,7 @@ export default function Tags() {
|
||||
<button
|
||||
onClick={() => setShowBulkTagDialog(folder.folderPath)}
|
||||
className="ml-2 px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
|
||||
title="Manage bulk tags for this folder"
|
||||
>
|
||||
🔗
|
||||
</button>
|
||||
@ -522,13 +557,26 @@ export default function Tags() {
|
||||
<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.date_taken || 'Unknown'}</td>
|
||||
<td className="p-2">{photo.face_count}</td>
|
||||
<td className="p-2">
|
||||
{photo.processed && (photo.unidentified_face_count ?? 0) > 0 ? (
|
||||
<button
|
||||
onClick={() => handleIdentifyFaces([photo.id])}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
|
||||
title="Identify faces in this photo"
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
) : (
|
||||
<span>{photo.face_count || 0}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{getPhotoTags(photo.id)}</span>
|
||||
<button
|
||||
onClick={() => setShowTagDialog(photo.id)}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
|
||||
title="Tag photos"
|
||||
>
|
||||
🔗
|
||||
</button>
|
||||
@ -545,15 +593,138 @@ export default function Tags() {
|
||||
|
||||
{viewMode === 'icons' && (
|
||||
<div className="p-4">
|
||||
<p className="text-gray-600">Icons view coming soon...</p>
|
||||
{folderGroups.map(folder => (
|
||||
<div key={folder.folderPath} className="mb-6">
|
||||
{/* Folder Header */}
|
||||
<div className="bg-gray-50 border-b p-3 mb-3 rounded-t">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggleFolder(folder.folderPath)}
|
||||
className="px-2 py-1 text-sm hover:bg-gray-200 rounded"
|
||||
>
|
||||
{folderStates[folder.folderPath] === true ? '▼' : '▶'}
|
||||
</button>
|
||||
<span className="font-semibold">
|
||||
📁 {folder.folderName} ({folder.photoCount} photos)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowBulkTagDialog(folder.folderPath)}
|
||||
className="ml-2 px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
|
||||
title="Manage bulk tags for this folder"
|
||||
>
|
||||
🔗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Photo Grid */}
|
||||
{folderStates[folder.folderPath] === true && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{folder.photos.map(photo => {
|
||||
const photoUrl = `/api/v1/photos/${photo.id}/image`
|
||||
const isSelected = selectedPhotoIds.has(photo.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative bg-white border border-gray-200 rounded-lg overflow-hidden hover:shadow-lg transition-shadow"
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
const newSet = new Set(selectedPhotoIds)
|
||||
if (e.target.checked) {
|
||||
newSet.add(photo.id)
|
||||
} else {
|
||||
newSet.delete(photo.id)
|
||||
}
|
||||
setSelectedPhotoIds(newSet)
|
||||
}}
|
||||
className="w-5 h-5 cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="absolute top-2 right-2 z-10 flex gap-1">
|
||||
{photo.processed && (photo.unidentified_face_count ?? 0) > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleIdentifyFaces([photo.id])
|
||||
}}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200 shadow"
|
||||
title="Identify faces in this photo"
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowTagDialog(photo.id)
|
||||
}}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200 shadow"
|
||||
title="Tag photos"
|
||||
>
|
||||
🔗
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="w-full aspect-square bg-gray-100 overflow-hidden">
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover cursor-pointer"
|
||||
onClick={() => window.open(photoUrl, '_blank')}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="150" height="150"%3E%3Crect fill="%23ddd" width="150" height="150"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999"%3ENo Image%3C/text%3E%3C/svg%3E'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Metadata Overlay */}
|
||||
<div className="p-2 bg-white">
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="font-semibold text-gray-900 truncate" title={`ID: ${photo.id}`}>
|
||||
ID: {photo.id}
|
||||
</div>
|
||||
<div className="text-gray-700 truncate" title={photo.filename}>
|
||||
{photo.filename}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<span className={photo.processed ? 'text-green-600' : 'text-orange-600'}>
|
||||
{photo.processed ? '✓ Processed' : '○ Unprocessed'}
|
||||
</span>
|
||||
</div>
|
||||
{photo.date_taken && (
|
||||
<div className="text-gray-600 truncate" title={photo.date_taken}>
|
||||
📅 {photo.date_taken}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-gray-600">
|
||||
👤 {photo.face_count} face{photo.face_count !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="text-gray-600 truncate" title={getPhotoTags(photo.id)}>
|
||||
🏷️ {getPhotoTags(photo.id)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'compact' && (
|
||||
<div className="p-4">
|
||||
<p className="text-gray-600">Compact view coming soon...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manage Tags Dialog */}
|
||||
|
||||
@ -113,6 +113,7 @@ def get_unidentified_faces(
|
||||
sort_dir: str = Query("desc"),
|
||||
tag_names: str | None = Query(None, description="Comma-separated tag names for filtering"),
|
||||
match_all: bool = Query(False, description="Match all tags (for tag filtering)"),
|
||||
photo_ids: str | None = Query(None, description="Comma-separated photo IDs for filtering"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnidentifiedFacesResponse:
|
||||
"""Get unidentified faces with filters and pagination."""
|
||||
@ -138,6 +139,14 @@ def get_unidentified_faces(
|
||||
if tag_names:
|
||||
tag_names_list = [t.strip() for t in tag_names.split(',') if t.strip()]
|
||||
|
||||
# Parse photo IDs
|
||||
photo_ids_list = None
|
||||
if photo_ids:
|
||||
try:
|
||||
photo_ids_list = [int(pid.strip()) for pid in photo_ids.split(',') if pid.strip()]
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid photo_ids format")
|
||||
|
||||
# Convert single date_processed to date_processed_from and date_processed_to (exact date match)
|
||||
date_processed_from = dp
|
||||
date_processed_to = dp
|
||||
@ -155,6 +164,7 @@ def get_unidentified_faces(
|
||||
sort_dir=sort_dir,
|
||||
tag_names=tag_names_list,
|
||||
match_all=match_all,
|
||||
photo_ids=photo_ids_list,
|
||||
)
|
||||
|
||||
items = [
|
||||
|
||||
@ -180,6 +180,7 @@ def get_photos_with_tags_endpoint(db: Session = Depends(get_db)) -> PhotosWithTa
|
||||
date_taken=p['date_taken'],
|
||||
date_added=p['date_added'],
|
||||
face_count=p['face_count'],
|
||||
unidentified_face_count=p['unidentified_face_count'],
|
||||
tags=p['tags'],
|
||||
)
|
||||
for p in photos_data
|
||||
|
||||
@ -87,6 +87,7 @@ class PhotoWithTagsItem(BaseModel):
|
||||
date_taken: Optional[str] = None
|
||||
date_added: Optional[str] = None
|
||||
face_count: int
|
||||
unidentified_face_count: int # Count of faces with person_id IS NULL
|
||||
tags: str # Comma-separated tags string (matching desktop)
|
||||
|
||||
|
||||
|
||||
@ -1206,6 +1206,7 @@ def list_unidentified_faces(
|
||||
sort_dir: str = "desc",
|
||||
tag_names: Optional[List[str]] = None,
|
||||
match_all: bool = False,
|
||||
photo_ids: Optional[List[int]] = None,
|
||||
) -> Tuple[List[Face], int]:
|
||||
"""Return paginated unidentified faces with filters.
|
||||
|
||||
@ -1214,6 +1215,7 @@ def list_unidentified_faces(
|
||||
- Date taken (date_taken_from, date_taken_to)
|
||||
- Date processed (date_processed_from, date_processed_to) - uses photo.date_added
|
||||
- Tags (tag_names, match_all)
|
||||
- Photo IDs (photo_ids)
|
||||
|
||||
Legacy parameters (date_from, date_to) are kept for backward compatibility
|
||||
and filter by date_taken when available, else date_added as fallback.
|
||||
@ -1252,6 +1254,10 @@ def list_unidentified_faces(
|
||||
# No matching tags found - return empty result
|
||||
return [], 0
|
||||
|
||||
# Photo ID filtering
|
||||
if photo_ids:
|
||||
query = query.filter(Face.photo_id.in_(photo_ids))
|
||||
|
||||
# Min quality (stored 0.0-1.0)
|
||||
if min_quality is not None:
|
||||
query = query.filter(Face.quality_score >= min_quality)
|
||||
|
||||
@ -252,13 +252,21 @@ def get_photos_with_tags(db: Session) -> List[dict]:
|
||||
|
||||
result = []
|
||||
for photo in photos:
|
||||
# Get face count
|
||||
# Get face count (all faces)
|
||||
face_count = (
|
||||
db.query(func.count(Face.id))
|
||||
.filter(Face.photo_id == photo.id)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
# Get unidentified face count (only faces with person_id IS NULL)
|
||||
unidentified_face_count = (
|
||||
db.query(func.count(Face.id))
|
||||
.filter(Face.photo_id == photo.id)
|
||||
.filter(Face.person_id.is_(None))
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
# Get tags as comma-separated string (matching desktop GROUP_CONCAT)
|
||||
tags_query = (
|
||||
db.query(Tag.tag_name)
|
||||
@ -277,6 +285,7 @@ def get_photos_with_tags(db: Session) -> List[dict]:
|
||||
'date_taken': photo.date_taken.isoformat() if photo.date_taken else None,
|
||||
'date_added': photo.date_added.isoformat() if photo.date_added else None,
|
||||
'face_count': face_count,
|
||||
'unidentified_face_count': unidentified_face_count,
|
||||
'tags': tags,
|
||||
})
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user