From c6055737fb0ff85356460abc6c6d94c22a1b71d8 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Tue, 2 Dec 2025 13:30:51 -0500 Subject: [PATCH] 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. --- frontend/src/components/Layout.tsx | 66 +++++++++++----- frontend/src/pages/ApproveIdentified.tsx | 1 - frontend/src/pages/AutoMatch.tsx | 1 - frontend/src/pages/FacesMaintenance.tsx | 1 - frontend/src/pages/Help.tsx | 1 - frontend/src/pages/Identify.tsx | 98 +++++++++++++++++------- frontend/src/pages/ManagePhotos.tsx | 1 - frontend/src/pages/ManageUsers.tsx | 3 +- frontend/src/pages/Modify.tsx | 1 - frontend/src/pages/PendingPhotos.tsx | 1 - frontend/src/pages/Process.tsx | 1 - frontend/src/pages/ReportedPhotos.tsx | 1 - frontend/src/pages/Scan.tsx | 1 - frontend/src/pages/Search.tsx | 48 +++++++++++- frontend/src/pages/Settings.tsx | 1 - frontend/src/pages/Tags.tsx | 3 +- frontend/src/pages/UserTaggedPhotos.tsx | 1 - src/web/api/photos.py | 17 ++-- src/web/services/search_service.py | 52 ++++++++++++- 19 files changed, 225 insertions(+), 74 deletions(-) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index c89e85f..5c629a5 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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 (
{/* Top bar */}
-
-
-
- - 🏠 -

PunimTag

- -
-
- {username} - +
+ {/* Left sidebar - fixed position */} +
+ + 🏠 +

PunimTag

+ +
+ {/* Header content - aligned with main content */} +
+
+
+

{getPageTitle()}

+
+
+ {username} + +
@@ -101,7 +131,7 @@ export default function Layout() {
{/* Left sidebar - fixed position */} -
+
{/* Main content - with left margin to account for fixed sidebar */} -
+
diff --git a/frontend/src/pages/ApproveIdentified.tsx b/frontend/src/pages/ApproveIdentified.tsx index db0f2ff..e159051 100644 --- a/frontend/src/pages/ApproveIdentified.tsx +++ b/frontend/src/pages/ApproveIdentified.tsx @@ -207,7 +207,6 @@ export default function ApproveIdentified() { return (
-

Approve Identified

{loading && (
diff --git a/frontend/src/pages/AutoMatch.tsx b/frontend/src/pages/AutoMatch.tsx index c137198..1315578 100644 --- a/frontend/src/pages/AutoMatch.tsx +++ b/frontend/src/pages/AutoMatch.tsx @@ -525,7 +525,6 @@ export default function AutoMatch() { return (
-

🔗 Auto-Match Faces

{/* Configuration */}
diff --git a/frontend/src/pages/FacesMaintenance.tsx b/frontend/src/pages/FacesMaintenance.tsx index f58954a..a076ece 100644 --- a/frontend/src/pages/FacesMaintenance.tsx +++ b/frontend/src/pages/FacesMaintenance.tsx @@ -129,7 +129,6 @@ export default function FacesMaintenance() { return (
-

Faces Maintenance

{/* Controls */}
diff --git a/frontend/src/pages/Help.tsx b/frontend/src/pages/Help.tsx index d80bc27..b9a1268 100644 --- a/frontend/src/pages/Help.tsx +++ b/frontend/src/pages/Help.tsx @@ -32,7 +32,6 @@ export default function Help() { return (
-

📚 Help

{renderPageContent()}
) diff --git a/frontend/src/pages/Identify.tsx b/frontend/src/pages/Identify.tsx index 9ac3af4..9dc35d9 100644 --- a/frontend/src/pages/Identify.tsx +++ b/frontend/src/pages/Identify.tsx @@ -73,6 +73,9 @@ export default function Identify() { const [statsDateFrom, setStatsDateFrom] = useState('') const [statsDateTo, setStatsDateTo] = useState('') + // Tab state + const [activeTab, setActiveTab] = useState<'faces' | 'videos'>('faces') + // Store form data per face ID (matching desktop behavior) const [faceFormData, setFaceFormData] = useState -
-

- Identify - {photoIds && ( - - (Filtered by {photoIds.length} photo{photoIds.length !== 1 ? 's' : ''}) - + {photoIds && ( +
+ + (Filtered by {photoIds.length} photo{photoIds.length !== 1 ? 's' : ''}) + +
+ )} + + {/* Tabs */} +
+
+ + {isAdmin && activeTab === 'faces' && ( + )} -

- {isAdmin && ( - - )} +
- -
+ + {/* Tab Content */} + {activeTab === 'faces' && ( + <> +
{/* Left: Controls and current face */}
{/* Unique Faces Checkbox and Batch Size - Outside Filters */} @@ -1043,12 +1074,7 @@ export default function Identify() {
-
- setCompareEnabled(e.target.checked)} /> - -
-
+
@@ -1056,6 +1082,11 @@ export default function Identify() { onClick={() => setSelectedSimilar({})} disabled={!compareEnabled || similar.length === 0}>Clear All
+
+ setCompareEnabled(e.target.checked)} /> + +
{!compareEnabled ? (
Enable 'Compare similar faces' to see similar faces
@@ -1155,8 +1186,8 @@ export default function Identify() {
- {/* Identification statistics modal */} - {showStats && ( + {/* Identification statistics modal */} + {showStats && (
@@ -1293,6 +1324,19 @@ export default function Identify() {
+ )} + + )} + + {activeTab === 'videos' && ( +
+

+ Identify People in Videos +

+

+ This functionality will be available in a future update. +

+
)}
) diff --git a/frontend/src/pages/ManagePhotos.tsx b/frontend/src/pages/ManagePhotos.tsx index c2506a3..74ad53f 100644 --- a/frontend/src/pages/ManagePhotos.tsx +++ b/frontend/src/pages/ManagePhotos.tsx @@ -1,7 +1,6 @@ export default function ManagePhotos() { return (
-

Manage Photos

Photo management functionality coming soon...

diff --git a/frontend/src/pages/ManageUsers.tsx b/frontend/src/pages/ManageUsers.tsx index 57804b5..7bacc58 100644 --- a/frontend/src/pages/ManageUsers.tsx +++ b/frontend/src/pages/ManageUsers.tsx @@ -760,8 +760,7 @@ const getDisplayRoleLabel = (user: UserResponse): string => { return (
-
-

Manage Users

+
{activeTab !== 'roles' && ( +
+ {filtersExpanded && ( +
+
+ + +
+
+ )} +
{/* 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
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 92a1f69..933cb8a 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -5,7 +5,6 @@ export default function Settings() { return (
-

Settings

Developer Options

diff --git a/frontend/src/pages/Tags.tsx b/frontend/src/pages/Tags.tsx index 91816fd..8a0a8a2 100644 --- a/frontend/src/pages/Tags.tsx +++ b/frontend/src/pages/Tags.tsx @@ -393,8 +393,7 @@ export default function Tags() { return (
-
-

Photos tagging interface

+
diff --git a/frontend/src/pages/UserTaggedPhotos.tsx b/frontend/src/pages/UserTaggedPhotos.tsx index fad4334..9aa6272 100644 --- a/frontend/src/pages/UserTaggedPhotos.tsx +++ b/frontend/src/pages/UserTaggedPhotos.tsx @@ -294,7 +294,6 @@ export default function UserTaggedPhotos() {
-

User Tagged Photos

Review tags suggested by users. Approving creates/links the tag to the selected photo.

diff --git a/src/web/api/photos.py b/src/web/api/photos.py index 467e728..c206497 100644 --- a/src/web/api/photos.py +++ b/src/web/api/photos.py @@ -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) diff --git a/src/web/services/search_service.py b/src/web/services/search_service.py index 9d1111e..d262cb0 100644 --- a/src/web/services/search_service.py +++ b/src/web/services/search_service.py @@ -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()