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" >