From e48b614b23ae1ed3d0b312a7a6bdf1616771db4e Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Thu, 4 Dec 2025 12:29:38 -0500 Subject: [PATCH] feat: Update Layout and AutoMatch components with enhanced functionality and UI improvements This commit modifies the Layout component to change the page title from 'Dashboard' to 'Home Page' for better clarity. In the AutoMatch component, new state variables and effects are added to manage a dropdown for selecting people, improving user interaction. The search functionality is enhanced to filter people based on the search query, and the save button now reflects the action of saving matches instead of changes. Additionally, the Scan component's input field is adjusted for better responsiveness, and the Search component's dropdowns are resized for improved usability. Documentation has been updated to reflect these changes. --- frontend/src/components/Layout.tsx | 2 +- frontend/src/pages/AutoMatch.tsx | 180 +++++++++++++++-- frontend/src/pages/Dashboard.tsx | 303 ++++++++++++++++++++++++++++- frontend/src/pages/Scan.tsx | 4 +- frontend/src/pages/Search.tsx | 4 +- 5 files changed, 460 insertions(+), 33 deletions(-) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 5c629a5..ec382ec 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -77,7 +77,7 @@ export default function Layout() { // Get page title based on route const getPageTitle = () => { const route = location.pathname - if (route === '/') return 'Dashboard' + if (route === '/') return 'Home Page' if (route === '/scan') return 'πŸ—‚οΈ Scan Photos' if (route === '/process') return 'βš™οΈ Process Faces' if (route === '/search') return 'πŸ” Search Photos' diff --git a/frontend/src/pages/AutoMatch.tsx b/frontend/src/pages/AutoMatch.tsx index 1315578..e475450 100644 --- a/frontend/src/pages/AutoMatch.tsx +++ b/frontend/src/pages/AutoMatch.tsx @@ -3,7 +3,7 @@ import facesApi, { AutoMatchPersonSummary, AutoMatchFaceItem } from '../api/faces' -import peopleApi from '../api/people' +import peopleApi, { Person } from '../api/people' import { apiClient } from '../api/client' import { useDeveloperMode } from '../context/DeveloperModeContext' @@ -20,6 +20,11 @@ export default function AutoMatch() { const [matchesCache, setMatchesCache] = useState>({}) const [currentIndex, setCurrentIndex] = useState(0) const [searchQuery, setSearchQuery] = useState('') + const [allPeople, setAllPeople] = useState([]) + const [loadingPeople, setLoadingPeople] = useState(false) + const [showPeopleDropdown, setShowPeopleDropdown] = useState(false) + const searchInputRef = useRef(null) + const dropdownRef = useRef(null) const [selectedFaces, setSelectedFaces] = useState>({}) const [originalSelectedFaces, setOriginalSelectedFaces] = useState>({}) const [busy, setBusy] = useState(false) @@ -50,6 +55,11 @@ export default function AutoMatch() { return matchesCache[currentPerson.person_id] || [] }, [currentPerson, matchesCache]) + // Check if any matches are selected + const hasSelectedMatches = useMemo(() => { + return currentMatches.some(match => selectedFaces[match.id] === true) + }, [currentMatches, selectedFaces]) + // Load matches for a specific person (lazy loading) const loadPersonMatches = async (personId: number) => { // Skip if already cached @@ -301,6 +311,23 @@ export default function AutoMatch() { } }, [tolerance, autoAcceptThreshold, settingsLoaded]) + // Load all people for dropdown + useEffect(() => { + const loadAllPeople = async () => { + try { + setLoadingPeople(true) + const response = await peopleApi.list() + setAllPeople(response.items || []) + } catch (error) { + console.error('Failed to load people:', error) + setAllPeople([]) + } finally { + setLoadingPeople(false) + } + } + loadAllPeople() + }, []) + // Initial load on mount (after settings and state are loaded) useEffect(() => { if (!initialLoadRef.current && settingsLoaded && stateRestored) { @@ -463,10 +490,10 @@ export default function AutoMatch() { }) setOriginalSelectedFaces(prev => ({ ...prev, ...newOriginal })) - alert(`βœ… Saved ${faceIds.length} change(s)`) + alert(`βœ… Saved ${faceIds.length} match(es)`) } catch (error) { console.error('Save failed:', error) - alert('Failed to save changes. Please try again.') + alert('Failed to save matches. Please try again.') } finally { setSaving(false) } @@ -517,8 +544,96 @@ export default function AutoMatch() { setSearchQuery('') setFilteredPeople([]) setCurrentIndex(0) + setShowPeopleDropdown(false) } + const formatPersonName = (person: Person): string => { + const parts: string[] = [] + + // Last name with comma + if (person.last_name) { + parts.push(`${person.last_name},`) + } + + // Middle name between last and first + if (person.middle_name) { + parts.push(person.middle_name) + } + + // First name + if (person.first_name) { + parts.push(person.first_name) + } + + // Maiden name in parentheses + if (person.maiden_name) { + parts.push(`(${person.maiden_name})`) + } + + if (parts.length === 0) { + return person.first_name || person.last_name || 'Unknown' + } + + // Format as "Last, Middle First (Maiden)" + return parts.join(' ') + } + + const formatFullPersonName = (personId: number): string => { + const person = allPeople.find(p => p.id === personId) + if (!person) { + // Fallback to person_name if person not found in allPeople + const currentPersonData = people.find(p => p.person_id === personId) || + filteredPeople.find(p => p.person_id === personId) + return currentPersonData?.person_name || 'Unknown' + } + + return formatPersonName(person) + } + + const handlePersonSelect = (personId: number) => { + const person = allPeople.find(p => p.id === personId) + if (person) { + // Extract last name and set as search query + const lastName = person.last_name || '' + setSearchQuery(lastName) + setShowPeopleDropdown(false) + } + } + + // Filter people based on search query for dropdown + const filteredPeopleForDropdown = useMemo(() => { + if (!searchQuery.trim()) { + return allPeople + } + const query = searchQuery.trim().toLowerCase() + return allPeople.filter(person => { + const lastName = (person.last_name || '').toLowerCase() + const firstName = (person.first_name || '').toLowerCase() + const middleName = (person.middle_name || '').toLowerCase() + const fullName = `${lastName}, ${firstName}${middleName ? ` ${middleName}` : ''}`.toLowerCase() + return fullName.includes(query) || lastName.includes(query) || firstName.includes(query) + }) + }, [searchQuery, allPeople]) + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + searchInputRef.current && + !searchInputRef.current.contains(event.target as Node) + ) { + setShowPeopleDropdown(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, []) + const activePeople = filteredPeople.length > 0 ? filteredPeople : people const canGoBack = currentIndex > 0 const canGoNext = currentIndex < activePeople.length - 1 @@ -595,15 +710,38 @@ export default function AutoMatch() { {/* Search controls */}
-
- setSearchQuery(e.target.value)} - disabled={people.length === 1} - className="flex-1 px-3 py-2 border border-gray-300 rounded text-sm" - /> +
+
+ { + setSearchQuery(e.target.value) + setShowPeopleDropdown(true) + }} + onFocus={() => setShowPeopleDropdown(true)} + disabled={people.length === 1 || loadingPeople} + className="w-full px-3 py-2 border border-gray-300 rounded text-sm" + /> + {showPeopleDropdown && filteredPeopleForDropdown.length > 0 && !loadingPeople && ( +
+ {filteredPeopleForDropdown.map((person) => ( +
handlePersonSelect(person.id)} + className="px-3 py-2 hover:bg-blue-50 cursor-pointer text-sm" + > + {formatPersonName(person)} +
+ ))} +
+ )} +
-

πŸ‘€ Person: {currentPerson.person_name}

+

πŸ‘€ Person: {formatFullPersonName(currentPerson.person_id)}

πŸ“ Photo: {currentPerson.reference_photo_filename}

@@ -677,13 +815,15 @@ export default function AutoMatch() {
{/* Save button */} - +
+ +
)} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 6428ab1..911d247 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,13 +1,300 @@ +import { useEffect, useState } from 'react' +import { useAuth } from '../context/AuthContext' +import { photosApi, PhotoSearchResult } from '../api/photos' +import apiClient from '../api/client' + export default function Dashboard() { + const { username } = useAuth() + const [samplePhotos, setSamplePhotos] = useState([]) + const [loadingPhotos, setLoadingPhotos] = useState(true) + + useEffect(() => { + loadSamplePhotos() + }, []) + + const loadSamplePhotos = async () => { + try { + setLoadingPhotos(true) + // Try to get some recent photos to display + const result = await photosApi.searchPhotos({ + search_type: 'processed', + page: 1, + page_size: 6, + }) + setSamplePhotos(result.items || []) + } catch (error) { + console.error('Failed to load sample photos:', error) + setSamplePhotos([]) + } finally { + setLoadingPhotos(false) + } + } + + const getPhotoImageUrl = (photoId: number): string => { + return `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image` + } + return ( -
-
-

Welcome to PunimTag!

-

- This is the dashboard. Content will be added in Phase 2. -

-
+
+ {/* Hero Section */} +
+
+
+
+
+
+
+

+ Welcome to PunimTag +

+

+ Your Intelligent Photo Management System +

+

+ Organize, identify, and search through your photo collection like never before. +

+
+
+
+ + {/* Feature Showcase 1 - AI Recognition */} +
+
+
+
+
πŸ€–
+

+ Recognize Faces Automatically +

+

+ Never lose track of who's in your photos again. Our smart system + automatically finds and recognizes faces in all your pictures. Just + tell it who someone is once, and it will find them in thousands of + photosβ€”even from years ago. +

+
    +
  • + βœ“ + Automatically finds faces in all your photos +
  • +
  • + βœ“ + Recognizes the same person across different photos +
  • +
  • + βœ“ + Works even with photos taken years apart +
  • +
+
+
+
+
+
+
πŸ‘₯
+

+ Face recognition in action +

+
+
+
+
+
+
+
+ + {/* Feature Showcase 2 - Smart Search */} +
+
+
+
+
+
+
+
πŸ”
+

+ Powerful search interface +

+
+
+
+
+
+
πŸ”
+

+ Find Anything, Instantly +

+

+ Search your entire photo collection by people, dates, tags, or + folders. Our advanced filtering system makes it easy to find + exactly what you're looking for, no matter how large your + collection grows. +

+
    +
  • + βœ“ + Search by person name across all photos +
  • +
  • + βœ“ + Filter by date ranges and folders +
  • +
  • + βœ“ + Tag-based organization and filtering +
  • +
+
+
+
+
+ + {/* Feature Showcase 3 - Batch Processing */} +
+
+
+
+
⚑
+

+ Process Thousands at Once +

+

+ Don't let a large photo collection overwhelm you. Our batch + processing system efficiently handles thousands of photos with + real-time progress tracking. Watch as your photos are organized + automatically. +

+
    +
  • + βœ“ + Batch face detection and recognition +
  • +
  • + βœ“ + Real-time progress updates +
  • +
  • + βœ“ + Background job processing +
  • +
+
+
+
+
+
+
πŸ“Š
+

+ Batch processing dashboard +

+
+
+
+
+
+
+
+ + {/* Visual Gallery Section */} +
+
+

+ Organize Your Memories +

+

+ Transform your photo collection into an organized, searchable library + of memories. Find any moment, any person, any time. +

+ {loadingPhotos ? ( +
+ {[1, 2, 3].map((i) => ( +
+
+
πŸ“Έ
+

Loading...

+
+
+ ))} +
+ ) : samplePhotos.length > 0 ? ( +
+ {samplePhotos.slice(0, 6).map((photo) => ( +
+ {photo.filename} { + const target = e.target as HTMLImageElement + target.style.display = 'none' + const parent = target.parentElement + if (parent && !parent.querySelector('.error-fallback')) { + const fallback = document.createElement('div') + fallback.className = + 'w-full h-full flex items-center justify-center error-fallback' + fallback.innerHTML = + '
πŸ“Έ

Photo

' + parent.appendChild(fallback) + } + }} + /> +
+ ))} +
+ ) : ( +
+ {[1, 2, 3].map((i) => ( +
+
+
πŸ“Έ
+

Your photos

+
+
+ ))} +
+ )} +
+
+ + {/* CTA Section */} +
+
+

+ Ready to Get Started? +

+

+ Begin organizing your photo collection today. Use the navigation menu + to explore all the powerful features PunimTag has to offer. +

+
+
+ πŸ—‚οΈ Scan Photos +
+
+ βš™οΈ Process Faces +
+
+ πŸ‘€ Identify People +
+
+ πŸ€– Auto-Match +
+
+ πŸ” Search Photos +
+
+
+
) } - diff --git a/frontend/src/pages/Scan.tsx b/frontend/src/pages/Scan.tsx index ea366bb..1f8e978 100644 --- a/frontend/src/pages/Scan.tsx +++ b/frontend/src/pages/Scan.tsx @@ -246,7 +246,7 @@ export default function Scan() { value={folderPath} onChange={(e) => setFolderPath(e.target.value)} placeholder="/path/to/photos" - className="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + className="w-1/2 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" disabled={isImporting} />

- Enter the full path to the folder containing photos. + Enter the full path to the folder containing photos / videos. Click Browse to open a native folder picker. The full path will be automatically filled. If the native picker is unavailable, a browser fallback will be used (may require manual path completion). diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx index c321bab..a7b9dbf 100644 --- a/frontend/src/pages/Search.tsx +++ b/frontend/src/pages/Search.tsx @@ -596,7 +596,7 @@ export default function Search() { setMediaType(e.target.value)} - className="w-full border rounded px-3 py-2" + className="w-48 border rounded px-3 py-2" >