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()