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:
parent
84c4f7ca73
commit
e48b614b23
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
photos—even 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user