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.
This commit is contained in:
tanyar09 2025-12-04 12:29:38 -05:00
parent 84c4f7ca73
commit e48b614b23
5 changed files with 460 additions and 33 deletions

View File

@ -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'

View File

@ -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<Record<number, AutoMatchFaceItem[]>>({})
const [currentIndex, setCurrentIndex] = useState(0)
const [searchQuery, setSearchQuery] = useState('')
const [allPeople, setAllPeople] = useState<Person[]>([])
const [loadingPeople, setLoadingPeople] = useState(false)
const [showPeopleDropdown, setShowPeopleDropdown] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const [selectedFaces, setSelectedFaces] = useState<Record<number, boolean>>({})
const [originalSelectedFaces, setOriginalSelectedFaces] = useState<Record<number, boolean>>({})
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 */}
<div className="mb-4">
<div className="flex gap-2 mb-2">
<input
type="text"
placeholder="Type Last Name"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
disabled={people.length === 1}
className="flex-1 px-3 py-2 border border-gray-300 rounded text-sm"
/>
<div className="flex gap-2 mb-2 relative">
<div className="flex-1 relative">
<input
ref={searchInputRef}
type="text"
placeholder="Type Last Name or Select Person"
value={searchQuery}
onChange={(e) => {
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 && (
<div
ref={dropdownRef}
className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded shadow-lg max-h-60 overflow-auto"
>
{filteredPeopleForDropdown.map((person) => (
<div
key={person.id}
onClick={() => handlePersonSelect(person.id)}
className="px-3 py-2 hover:bg-blue-50 cursor-pointer text-sm"
>
{formatPersonName(person)}
</div>
))}
</div>
)}
</div>
<button
onClick={clearSearch}
disabled={people.length === 1}
@ -644,7 +782,7 @@ export default function AutoMatch() {
</button>
</div>
</div>
<p className="font-semibold">👤 Person: {currentPerson.person_name}</p>
<p className="font-semibold">👤 Person: {formatFullPersonName(currentPerson.person_id)}</p>
<p className="text-sm text-gray-600">
📁 Photo: {currentPerson.reference_photo_filename}
</p>
@ -677,13 +815,15 @@ export default function AutoMatch() {
</div>
{/* Save button */}
<button
onClick={saveChanges}
disabled={saving}
className="w-full px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{saving ? '💾 Saving...' : `💾 Save changes for ${currentPerson.person_name}`}
</button>
<div className="flex justify-center">
<button
onClick={saveChanges}
disabled={saving || !hasSelectedMatches}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{saving ? '💾 Saving...' : `💾 Save matches for ${currentPerson.person_name}`}
</button>
</div>
</>
)}
</div>

View File

@ -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<PhotoSearchResult[]>([])
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 (
<div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-gray-600">Welcome to PunimTag!</p>
<p className="text-gray-600 mt-2">
This is the dashboard. Content will be added in Phase 2.
</p>
</div>
<div className="min-h-screen">
{/* Hero Section */}
<section className="relative bg-gradient-to-br from-blue-600 via-purple-600 to-pink-500 text-white py-12 px-4 overflow-hidden">
<div className="absolute inset-0 opacity-10">
<div className="absolute top-10 left-10 w-72 h-72 bg-white rounded-full blur-3xl"></div>
<div className="absolute bottom-10 right-10 w-96 h-96 bg-white rounded-full blur-3xl"></div>
</div>
<div className="max-w-6xl mx-auto relative z-10">
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold mb-4 leading-tight">
Welcome to PunimTag
</h1>
<p className="text-xl md:text-2xl text-blue-100 mb-3">
Your Intelligent Photo Management System
</p>
<p className="text-lg text-blue-50 max-w-2xl mx-auto">
Organize, identify, and search through your photo collection like never before.
</p>
</div>
</div>
</section>
{/* Feature Showcase 1 - AI Recognition */}
<section className="py-16 px-4 bg-white">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div>
<div className="text-5xl mb-4">🤖</div>
<h2 className="text-4xl font-bold text-gray-900 mb-4">
Recognize Faces Automatically
</h2>
<p className="text-lg text-gray-600 mb-6">
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
photoseven from years ago.
</p>
<ul className="space-y-3 text-gray-700">
<li className="flex items-start gap-3">
<span className="text-green-500 text-xl"></span>
<span>Automatically finds faces in all your photos</span>
</li>
<li className="flex items-start gap-3">
<span className="text-green-500 text-xl"></span>
<span>Recognizes the same person across different photos</span>
</li>
<li className="flex items-start gap-3">
<span className="text-green-500 text-xl"></span>
<span>Works even with photos taken years apart</span>
</li>
</ul>
</div>
<div className="relative">
<div className="bg-gradient-to-br from-blue-100 to-purple-100 rounded-2xl p-8 shadow-xl">
<div className="aspect-video bg-white rounded-lg shadow-lg flex items-center justify-center">
<div className="text-center">
<div className="text-6xl mb-4">👥</div>
<p className="text-gray-500 text-sm">
Face recognition in action
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Feature Showcase 2 - Smart Search */}
<section className="py-16 px-4 bg-gray-50">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div className="order-2 md:order-1 relative">
<div className="bg-gradient-to-br from-orange-100 to-pink-100 rounded-2xl p-8 shadow-xl">
<div className="aspect-video bg-white rounded-lg shadow-lg flex items-center justify-center">
<div className="text-center">
<div className="text-6xl mb-4">🔍</div>
<p className="text-gray-500 text-sm">
Powerful search interface
</p>
</div>
</div>
</div>
</div>
<div className="order-1 md:order-2">
<div className="text-5xl mb-4">🔍</div>
<h2 className="text-4xl font-bold text-gray-900 mb-4">
Find Anything, Instantly
</h2>
<p className="text-lg text-gray-600 mb-6">
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.
</p>
<ul className="space-y-3 text-gray-700">
<li className="flex items-start gap-3">
<span className="text-green-500 text-xl"></span>
<span>Search by person name across all photos</span>
</li>
<li className="flex items-start gap-3">
<span className="text-green-500 text-xl"></span>
<span>Filter by date ranges and folders</span>
</li>
<li className="flex items-start gap-3">
<span className="text-green-500 text-xl"></span>
<span>Tag-based organization and filtering</span>
</li>
</ul>
</div>
</div>
</div>
</section>
{/* Feature Showcase 3 - Batch Processing */}
<section className="py-16 px-4 bg-white">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div>
<div className="text-5xl mb-4"></div>
<h2 className="text-4xl font-bold text-gray-900 mb-4">
Process Thousands at Once
</h2>
<p className="text-lg text-gray-600 mb-6">
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.
</p>
<ul className="space-y-3 text-gray-700">
<li className="flex items-start gap-3">
<span className="text-green-500 text-xl"></span>
<span>Batch face detection and recognition</span>
</li>
<li className="flex items-start gap-3">
<span className="text-green-500 text-xl"></span>
<span>Real-time progress updates</span>
</li>
<li className="flex items-start gap-3">
<span className="text-green-500 text-xl"></span>
<span>Background job processing</span>
</li>
</ul>
</div>
<div className="relative">
<div className="bg-gradient-to-br from-green-100 to-teal-100 rounded-2xl p-8 shadow-xl">
<div className="aspect-video bg-white rounded-lg shadow-lg flex items-center justify-center">
<div className="text-center">
<div className="text-6xl mb-4">📊</div>
<p className="text-gray-500 text-sm">
Batch processing dashboard
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Visual Gallery Section */}
<section className="py-16 px-4 bg-white">
<div className="max-w-6xl mx-auto">
<h2 className="text-4xl font-bold text-center text-gray-900 mb-4">
Organize Your Memories
</h2>
<p className="text-center text-lg text-gray-600 mb-12 max-w-2xl mx-auto">
Transform your photo collection into an organized, searchable library
of memories. Find any moment, any person, any time.
</p>
{loadingPhotos ? (
<div className="grid md:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-gradient-to-br from-blue-50 to-purple-50 rounded-xl p-6 shadow-md aspect-square flex items-center justify-center animate-pulse"
>
<div className="text-center">
<div className="text-6xl mb-4">📸</div>
<p className="text-gray-500 text-sm">Loading...</p>
</div>
</div>
))}
</div>
) : samplePhotos.length > 0 ? (
<div className="grid md:grid-cols-3 gap-6">
{samplePhotos.slice(0, 6).map((photo) => (
<div
key={photo.id}
className="bg-gradient-to-br from-blue-50 to-purple-50 rounded-xl p-2 shadow-md aspect-square overflow-hidden group hover:shadow-lg transition-shadow"
>
<img
src={getPhotoImageUrl(photo.id)}
alt={photo.filename}
className="w-full h-full object-cover rounded-lg"
loading="lazy"
onError={(e) => {
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 =
'<div class="text-center"><div class="text-6xl mb-4">📸</div><p class="text-gray-500 text-sm">Photo</p></div>'
parent.appendChild(fallback)
}
}}
/>
</div>
))}
</div>
) : (
<div className="grid md:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-gradient-to-br from-blue-50 to-purple-50 rounded-xl p-6 shadow-md aspect-square flex items-center justify-center"
>
<div className="text-center">
<div className="text-6xl mb-4">📸</div>
<p className="text-gray-500 text-sm">Your photos</p>
</div>
</div>
))}
</div>
)}
</div>
</section>
{/* CTA Section */}
<section className="py-20 px-4 bg-gradient-to-r from-blue-600 to-purple-600 text-white">
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-4xl md:text-5xl font-bold mb-6">
Ready to Get Started?
</h2>
<p className="text-xl text-blue-100 mb-8 max-w-2xl mx-auto">
Begin organizing your photo collection today. Use the navigation menu
to explore all the powerful features PunimTag has to offer.
</p>
<div className="flex flex-wrap justify-center gap-4">
<div className="bg-white/10 backdrop-blur-sm rounded-lg px-6 py-3 text-sm">
<span className="font-semibold">🗂</span> Scan Photos
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-lg px-6 py-3 text-sm">
<span className="font-semibold"></span> Process Faces
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-lg px-6 py-3 text-sm">
<span className="font-semibold">👤</span> Identify People
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-lg px-6 py-3 text-sm">
<span className="font-semibold">🤖</span> Auto-Match
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-lg px-6 py-3 text-sm">
<span className="font-semibold">🔍</span> Search Photos
</div>
</div>
</div>
</section>
</div>
)
}

View File

@ -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}
/>
<button
@ -259,7 +259,7 @@ export default function Scan() {
</button>
</div>
<p className="mt-1 text-sm text-gray-500">
Enter the full path to the folder containing photos.
Enter the full path to the folder containing photos / videos.
<span className="text-xs text-gray-400 block mt-1">
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).

View File

@ -596,7 +596,7 @@ export default function Search() {
<select
value={searchType}
onChange={(e) => setSearchType(e.target.value as SearchType)}
className="flex-1 border rounded px-3 py-2"
className="w-64 border rounded px-3 py-2"
>
{SEARCH_TYPES.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
@ -626,7 +626,7 @@ export default function Search() {
<select
value={mediaType}
onChange={(e) => setMediaType(e.target.value)}
className="w-full border rounded px-3 py-2"
className="w-48 border rounded px-3 py-2"
>
<option value="all">All</option>
<option value="image">Photos</option>