feat: Enhance Layout and Search components with dynamic page titles and media type filtering
This commit introduces a new function in the Layout component to dynamically set page titles based on the current route, improving user navigation. Additionally, the Search component has been updated to include a media type filter, allowing users to filter results by images or videos. The UI has been enhanced with collapsible filters for better organization. Documentation has been updated to reflect these changes.
This commit is contained in:
parent
9c6a2ff05e
commit
c6055737fb
@ -74,26 +74,56 @@ export default function Layout() {
|
||||
const visibleMaintenance = filterNavItems(maintenanceNavItems)
|
||||
const visibleFooter = filterNavItems(footerNavItems)
|
||||
|
||||
// Get page title based on route
|
||||
const getPageTitle = () => {
|
||||
const route = location.pathname
|
||||
if (route === '/') return 'Dashboard'
|
||||
if (route === '/scan') return '🗂️ Scan Photos'
|
||||
if (route === '/process') return '⚙️ Process Faces'
|
||||
if (route === '/search') return '🔍 Search Photos'
|
||||
if (route === '/identify') return '👤 Identify'
|
||||
if (route === '/auto-match') return '🤖 Auto-Match Faces'
|
||||
if (route === '/modify') return '✏️ Modify Identified'
|
||||
if (route === '/tags') return '🏷️ Photos tagging interface'
|
||||
if (route === '/manage-photos') return 'Manage Photos'
|
||||
if (route === '/faces-maintenance') return '🔧 Faces Maintenance'
|
||||
if (route === '/approve-identified') return '✅ Approve Identified'
|
||||
if (route === '/manage-users') return '👥 Manage Users'
|
||||
if (route === '/reported-photos') return '🚩 Reported Photos'
|
||||
if (route === '/pending-linkages') return '🔖 User Tagged Photos'
|
||||
if (route === '/pending-photos') return '📤 Manage User Uploaded Photos'
|
||||
if (route === '/settings') return 'Settings'
|
||||
if (route === '/help') return '📚 Help'
|
||||
return 'PunimTag'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Top bar */}
|
||||
<div className="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-2 sm:px-4 lg:px-6">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||
<span className="text-2xl">🏠</span>
|
||||
<h1 className="text-xl font-bold text-gray-900">PunimTag</h1>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">{username}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
<div className="flex">
|
||||
{/* Left sidebar - fixed position */}
|
||||
<div className="fixed left-0 top-0 w-64 bg-white border-r border-gray-200 h-12 flex items-center px-4 z-10">
|
||||
<Link to="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||
<span className="text-2xl">🏠</span>
|
||||
<h2 className="text-xl font-bold text-gray-900">PunimTag</h2>
|
||||
</Link>
|
||||
</div>
|
||||
{/* Header content - aligned with main content */}
|
||||
<div className="ml-64 flex-1 px-4">
|
||||
<div className="flex justify-between items-center h-12">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-lg font-bold text-gray-900">{getPageTitle()}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">{username}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -101,7 +131,7 @@ export default function Layout() {
|
||||
|
||||
<div className="flex relative">
|
||||
{/* Left sidebar - fixed position */}
|
||||
<div className="fixed left-0 top-16 w-64 bg-white border-r border-gray-200 h-[calc(100vh-4rem)] overflow-y-auto">
|
||||
<div className="fixed left-0 top-12 w-64 bg-white border-r border-gray-200 h-[calc(100vh-3rem)] overflow-y-auto">
|
||||
<nav className="p-4 space-y-1">
|
||||
{visiblePrimary.map((item) => renderNavLink(item))}
|
||||
|
||||
@ -132,7 +162,7 @@ export default function Layout() {
|
||||
</div>
|
||||
|
||||
{/* Main content - with left margin to account for fixed sidebar */}
|
||||
<div className="flex-1 ml-64 p-6">
|
||||
<div className="flex-1 ml-64 p-4">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -207,7 +207,6 @@ export default function ApproveIdentified() {
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Approve Identified</h1>
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-8">
|
||||
|
||||
@ -525,7 +525,6 @@ export default function AutoMatch() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">🔗 Auto-Match Faces</h1>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
|
||||
@ -129,7 +129,6 @@ export default function FacesMaintenance() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Faces Maintenance</h1>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="bg-white rounded-lg shadow mb-4 p-4">
|
||||
|
||||
@ -32,7 +32,6 @@ export default function Help() {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">📚 Help</h1>
|
||||
{renderPageContent()}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -73,6 +73,9 @@ export default function Identify() {
|
||||
const [statsDateFrom, setStatsDateFrom] = useState<string>('')
|
||||
const [statsDateTo, setStatsDateTo] = useState<string>('')
|
||||
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState<'faces' | 'videos'>('faces')
|
||||
|
||||
// Store form data per face ID (matching desktop behavior)
|
||||
const [faceFormData, setFaceFormData] = useState<Record<number, {
|
||||
personId?: number
|
||||
@ -693,27 +696,55 @@ export default function Identify() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Identify
|
||||
{photoIds && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-600">
|
||||
(Filtered by {photoIds.length} photo{photoIds.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
{photoIds && (
|
||||
<div className="mb-4">
|
||||
<span className="text-sm font-normal text-gray-600">
|
||||
(Filtered by {photoIds.length} photo{photoIds.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<nav className="flex space-x-8" aria-label="Tabs">
|
||||
<button
|
||||
onClick={() => setActiveTab('faces')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'faces'
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Identify Faces
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('videos')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'videos'
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Identify People in Videos
|
||||
</button>
|
||||
</nav>
|
||||
{isAdmin && activeTab === 'faces' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenStats}
|
||||
className="px-3 py-1.5 text-sm bg-green-100 text-green-700 rounded-md hover:bg-green-200 font-medium"
|
||||
>
|
||||
📊 Statistics
|
||||
</button>
|
||||
)}
|
||||
</h1>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenStats}
|
||||
className="px-3 py-1.5 text-sm bg-green-100 text-green-700 rounded-md hover:bg-green-200 font-medium"
|
||||
>
|
||||
📊 Statistics
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'faces' && (
|
||||
<>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Left: Controls and current face */}
|
||||
<div className="col-span-4">
|
||||
{/* Unique Faces Checkbox and Batch Size - Outside Filters */}
|
||||
@ -1043,12 +1074,7 @@ export default function Identify() {
|
||||
<div className="col-span-8">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input id="compare" type="checkbox" checked={compareEnabled}
|
||||
onChange={(e) => setCompareEnabled(e.target.checked)} />
|
||||
<label htmlFor="compare" className="text-sm text-gray-700">Compare with similar faces</label>
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<div className="flex space-x-2">
|
||||
<button className="px-2 py-1 text-sm border rounded"
|
||||
onClick={() => setSelectedSimilar(Object.fromEntries(similar.map(s => [s.id, true])))}
|
||||
disabled={!compareEnabled || similar.length === 0}>Select All</button>
|
||||
@ -1056,6 +1082,11 @@ export default function Identify() {
|
||||
onClick={() => setSelectedSimilar({})}
|
||||
disabled={!compareEnabled || similar.length === 0}>Clear All</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input id="compare" type="checkbox" checked={compareEnabled}
|
||||
onChange={(e) => setCompareEnabled(e.target.checked)} />
|
||||
<label htmlFor="compare" className="text-sm text-gray-700">Compare with similar faces</label>
|
||||
</div>
|
||||
</div>
|
||||
{!compareEnabled ? (
|
||||
<div className="text-gray-500 py-4 text-center">Enable 'Compare similar faces' to see similar faces</div>
|
||||
@ -1155,8 +1186,8 @@ export default function Identify() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identification statistics modal */}
|
||||
{showStats && (
|
||||
{/* Identification statistics modal */}
|
||||
{showStats && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
@ -1293,6 +1324,19 @@ export default function Identify() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'videos' && (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-gray-700 mb-2">
|
||||
Identify People in Videos
|
||||
</h2>
|
||||
<p className="text-gray-500">
|
||||
This functionality will be available in a future update.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
export default function ManagePhotos() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Manage Photos</h1>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-gray-600">Photo management functionality coming soon...</p>
|
||||
|
||||
@ -760,8 +760,7 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Manage Users</h1>
|
||||
<div className="flex justify-end items-center mb-6">
|
||||
{activeTab !== 'roles' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@ -437,7 +437,6 @@ export default function Modify() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold">✏️ Modify Identified</h1>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded text-red-700">
|
||||
|
||||
@ -452,7 +452,6 @@ export default function PendingPhotos() {
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Manage User Uploaded Photos</h1>
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-8">
|
||||
|
||||
@ -221,7 +221,6 @@ export default function Process() {
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Process Faces</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Configuration Section */}
|
||||
|
||||
@ -211,7 +211,6 @@ export default function ReportedPhotos() {
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Reported Photos</h1>
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-8">
|
||||
|
||||
@ -223,7 +223,6 @@ export default function Scan() {
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Scan Photos</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Folder Scan Section */}
|
||||
|
||||
@ -27,6 +27,7 @@ export default function Search() {
|
||||
const canDeletePhotos = hasPermission('faces_maintenance')
|
||||
const [searchType, setSearchType] = useState<SearchType>('name')
|
||||
const [tagsExpanded, setTagsExpanded] = useState(true) // Default to expanded
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(false) // Default to collapsed
|
||||
|
||||
// Search inputs
|
||||
const [personName, setPersonName] = useState('')
|
||||
@ -34,6 +35,7 @@ export default function Search() {
|
||||
const [matchAll, setMatchAll] = useState(false)
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [mediaType, setMediaType] = useState<string>('all') // Default to 'all'
|
||||
|
||||
// Results
|
||||
const [results, setResults] = useState<PhotoSearchResult[]>([])
|
||||
@ -91,6 +93,11 @@ export default function Search() {
|
||||
page_size: pageSize,
|
||||
}
|
||||
|
||||
// Add media type filter if not 'all'
|
||||
if (mediaType && mediaType !== 'all') {
|
||||
params.media_type = mediaType
|
||||
}
|
||||
|
||||
if (searchType === 'name') {
|
||||
if (!personName.trim()) {
|
||||
alert('Please enter at least one name to search.')
|
||||
@ -520,6 +527,11 @@ export default function Search() {
|
||||
page_size: maxPageSize,
|
||||
}
|
||||
|
||||
// Add media type filter if not 'all'
|
||||
if (mediaType && mediaType !== 'all') {
|
||||
baseParams.media_type = mediaType
|
||||
}
|
||||
|
||||
if (searchType === 'name') {
|
||||
baseParams.person_name = personName.trim()
|
||||
} else if (searchType === 'date') {
|
||||
@ -572,8 +584,6 @@ export default function Search() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">🔎 Search Photos</h1>
|
||||
|
||||
{/* Search Type Selector */}
|
||||
<div className="bg-white rounded-lg shadow py-2 px-4 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@ -592,7 +602,37 @@ export default function Search() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters removed: folder location filter is no longer supported */}
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow py-2 px-4 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-medium text-gray-700">Filters</h2>
|
||||
<button
|
||||
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||
className="text-lg text-gray-600 hover:text-gray-800"
|
||||
title={filtersExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{filtersExpanded ? '▼' : '▶'}
|
||||
</button>
|
||||
</div>
|
||||
{filtersExpanded && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Media Type:
|
||||
</label>
|
||||
<select
|
||||
value={mediaType}
|
||||
onChange={(e) => setMediaType(e.target.value)}
|
||||
className="w-full border rounded px-3 py-2"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="image">Photos</option>
|
||||
<option value="video">Videos</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Inputs */}
|
||||
{(searchType !== 'no_faces' && searchType !== 'no_tags' && searchType !== 'processed' && searchType !== 'unprocessed') && (
|
||||
@ -782,7 +822,7 @@ export default function Search() {
|
||||
disabled={selectedPhotos.size === 0}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
>
|
||||
Clear all selected
|
||||
Unselect All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,6 @@ export default function Settings() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Settings</h1>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Developer Options</h2>
|
||||
|
||||
@ -393,8 +393,7 @@ export default function Tags() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<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 justify-end mb-4">
|
||||
<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>
|
||||
|
||||
@ -294,7 +294,6 @@ export default function UserTaggedPhotos() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">User Tagged Photos</h1>
|
||||
<p className="text-gray-600">
|
||||
Review tags suggested by users. Approving creates/links the tag to the selected photo.
|
||||
</p>
|
||||
|
||||
@ -66,6 +66,7 @@ def search_photos(
|
||||
date_from: Optional[str] = Query(None, description="Date from (YYYY-MM-DD)"),
|
||||
date_to: Optional[str] = Query(None, description="Date to (YYYY-MM-DD)"),
|
||||
folder_path: Optional[str] = Query(None, description="Filter by folder path"),
|
||||
media_type: Optional[str] = Query(None, description="Filter by media type: 'all', 'image', or 'video'"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
@ -105,7 +106,7 @@ def search_photos(
|
||||
detail="person_name is required for name search",
|
||||
)
|
||||
results, total = search_photos_by_name(
|
||||
db, person_name, folder_path, page, page_size
|
||||
db, person_name, folder_path, media_type, page, page_size
|
||||
)
|
||||
for photo, full_name in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
@ -135,7 +136,7 @@ def search_photos(
|
||||
)
|
||||
df = date.fromisoformat(date_from) if date_from else None
|
||||
dt = date.fromisoformat(date_to) if date_to else None
|
||||
results, total = search_photos_by_date(db, df, dt, folder_path, page, page_size)
|
||||
results, total = search_photos_by_date(db, df, dt, folder_path, media_type, page, page_size)
|
||||
for photo in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
face_count = get_photo_face_count(db, photo.id)
|
||||
@ -170,7 +171,7 @@ def search_photos(
|
||||
detail="At least one tag name is required",
|
||||
)
|
||||
results, total = search_photos_by_tags(
|
||||
db, tag_list, match_all, folder_path, page, page_size
|
||||
db, tag_list, match_all, folder_path, media_type, page, page_size
|
||||
)
|
||||
for photo in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
@ -194,7 +195,7 @@ def search_photos(
|
||||
)
|
||||
)
|
||||
elif search_type == "no_faces":
|
||||
results, total = get_photos_without_faces(db, folder_path, page, page_size)
|
||||
results, total = get_photos_without_faces(db, folder_path, media_type, page, page_size)
|
||||
for photo in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
# Convert datetime to date for date_added
|
||||
@ -215,7 +216,7 @@ def search_photos(
|
||||
)
|
||||
)
|
||||
elif search_type == "no_tags":
|
||||
results, total = get_photos_without_tags(db, folder_path, page, page_size)
|
||||
results, total = get_photos_without_tags(db, folder_path, media_type, page, page_size)
|
||||
for photo in results:
|
||||
face_count = get_photo_face_count(db, photo.id)
|
||||
person_name_val = get_photo_person(db, photo.id)
|
||||
@ -237,7 +238,7 @@ def search_photos(
|
||||
)
|
||||
)
|
||||
elif search_type == "processed":
|
||||
results, total = get_processed_photos(db, folder_path, page, page_size)
|
||||
results, total = get_processed_photos(db, folder_path, media_type, page, page_size)
|
||||
for photo in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
face_count = get_photo_face_count(db, photo.id)
|
||||
@ -260,7 +261,7 @@ def search_photos(
|
||||
)
|
||||
)
|
||||
elif search_type == "unprocessed":
|
||||
results, total = get_unprocessed_photos(db, folder_path, page, page_size)
|
||||
results, total = get_unprocessed_photos(db, folder_path, media_type, page, page_size)
|
||||
for photo in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
face_count = get_photo_face_count(db, photo.id)
|
||||
@ -288,7 +289,7 @@ def search_photos(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required for favorites search",
|
||||
)
|
||||
results, total = get_favorite_photos(db, username, folder_path, page, page_size)
|
||||
results, total = get_favorite_photos(db, username, folder_path, media_type, page, page_size)
|
||||
for photo in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
face_count = get_photo_face_count(db, photo.id)
|
||||
|
||||
@ -15,6 +15,7 @@ def search_photos_by_name(
|
||||
db: Session,
|
||||
person_name: str,
|
||||
folder_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Tuple[Photo, str]], int]:
|
||||
@ -26,6 +27,7 @@ def search_photos_by_name(
|
||||
- Searches first_name, last_name, middle_name, maiden_name
|
||||
- Returns (photo, full_name) tuples
|
||||
- Filters by folder_path if provided
|
||||
- Filters by media_type if provided ("image" or "video", None/"all" for all)
|
||||
- Multiple names: comma-separated, searches for photos with ANY matching person
|
||||
"""
|
||||
search_name = (person_name or "").strip()
|
||||
@ -75,6 +77,10 @@ def search_photos_by_name(
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Apply media type filter if provided
|
||||
if media_type and media_type.lower() != "all":
|
||||
query = query.filter(Photo.media_type == media_type.lower())
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
@ -95,6 +101,7 @@ def search_photos_by_date(
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
folder_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
@ -103,6 +110,7 @@ def search_photos_by_date(
|
||||
Matches desktop behavior exactly:
|
||||
- Filters by date_taken
|
||||
- Requires at least one date
|
||||
- Filters by media_type if provided ("image" or "video", None/"all" for all)
|
||||
- Returns photos ordered by date_taken DESC
|
||||
"""
|
||||
query = db.query(Photo).filter(Photo.date_taken.is_not(None))
|
||||
@ -117,6 +125,10 @@ def search_photos_by_date(
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Apply media type filter if provided
|
||||
if media_type and media_type.lower() != "all":
|
||||
query = query.filter(Photo.media_type == media_type.lower())
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
@ -131,6 +143,7 @@ def search_photos_by_tags(
|
||||
tag_names: List[str],
|
||||
match_all: bool = False,
|
||||
folder_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
@ -140,6 +153,7 @@ def search_photos_by_tags(
|
||||
- match_all=True: photos must have ALL tags
|
||||
- match_all=False: photos with ANY tag
|
||||
- Case-insensitive tag matching
|
||||
- Filters by media_type if provided ("image" or "video", None/"all" for all)
|
||||
"""
|
||||
if not tag_names:
|
||||
return [], 0
|
||||
@ -178,6 +192,10 @@ def search_photos_by_tags(
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Apply media type filter if provided
|
||||
if media_type and media_type.lower() != "all":
|
||||
query = query.filter(Photo.media_type == media_type.lower())
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
@ -190,12 +208,14 @@ def search_photos_by_tags(
|
||||
def get_photos_without_faces(
|
||||
db: Session,
|
||||
folder_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Get photos that have no detected faces.
|
||||
|
||||
Only includes processed photos (photos that have been processed for face detection).
|
||||
Filters by media_type if provided ("image" or "video", None/"all" for all).
|
||||
Matches desktop behavior exactly.
|
||||
"""
|
||||
query = (
|
||||
@ -210,6 +230,10 @@ def get_photos_without_faces(
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Apply media type filter if provided
|
||||
if media_type and media_type.lower() != "all":
|
||||
query = query.filter(Photo.media_type == media_type.lower())
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
@ -222,11 +246,13 @@ def get_photos_without_faces(
|
||||
def get_photos_without_tags(
|
||||
db: Session,
|
||||
folder_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Get photos that have no tags.
|
||||
|
||||
Filters by media_type if provided ("image" or "video", None/"all" for all).
|
||||
Matches desktop behavior exactly.
|
||||
"""
|
||||
query = (
|
||||
@ -240,6 +266,10 @@ def get_photos_without_tags(
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Apply media type filter if provided
|
||||
if media_type and media_type.lower() != "all":
|
||||
query = query.filter(Photo.media_type == media_type.lower())
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
@ -281,11 +311,13 @@ def get_photo_face_count(db: Session, photo_id: int) -> int:
|
||||
def get_processed_photos(
|
||||
db: Session,
|
||||
folder_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Get photos that have been processed for face detection.
|
||||
|
||||
Filters by media_type if provided ("image" or "video", None/"all" for all).
|
||||
Matches desktop behavior exactly.
|
||||
"""
|
||||
query = db.query(Photo).filter(Photo.processed == True) # noqa: E712
|
||||
@ -295,6 +327,10 @@ def get_processed_photos(
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Apply media type filter if provided
|
||||
if media_type and media_type.lower() != "all":
|
||||
query = query.filter(Photo.media_type == media_type.lower())
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
@ -308,10 +344,14 @@ def get_favorite_photos(
|
||||
db: Session,
|
||||
username: str,
|
||||
folder_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Get all favorite photos for a user with pagination."""
|
||||
"""Get all favorite photos for a user with pagination.
|
||||
|
||||
Filters by media_type if provided ("image" or "video", None/"all" for all).
|
||||
"""
|
||||
from src.web.db.models import PhotoFavorite
|
||||
|
||||
# Join favorites with photos
|
||||
@ -324,6 +364,10 @@ def get_favorite_photos(
|
||||
if folder_path:
|
||||
query = query.filter(Photo.path.like(f"{folder_path}%"))
|
||||
|
||||
# Apply media type filter if provided
|
||||
if media_type and media_type.lower() != "all":
|
||||
query = query.filter(Photo.media_type == media_type.lower())
|
||||
|
||||
total = query.count()
|
||||
|
||||
# Order by favorite date (most recent first), then date_taken
|
||||
@ -342,11 +386,13 @@ def get_favorite_photos(
|
||||
def get_unprocessed_photos(
|
||||
db: Session,
|
||||
folder_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Get photos that have not been processed for face detection.
|
||||
|
||||
Filters by media_type if provided ("image" or "video", None/"all" for all).
|
||||
Matches desktop behavior exactly.
|
||||
"""
|
||||
query = db.query(Photo).filter(Photo.processed == False) # noqa: E712
|
||||
@ -356,6 +402,10 @@ def get_unprocessed_photos(
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Apply media type filter if provided
|
||||
if media_type and media_type.lower() != "all":
|
||||
query = query.filter(Photo.media_type == media_type.lower())
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user