feat: Enhance Search component with person autocomplete and improved search functionality

This commit adds a person autocomplete feature to the Search component, allowing users to select individuals from a dropdown or type names manually. The search functionality is enhanced to combine selected people names with free text input, improving the accuracy of search results. Additionally, the layout is updated to include a collapsible configuration area for better organization of search options. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-12-04 14:26:32 -05:00
parent 6cc359f25a
commit a41e30b101

View File

@ -1,9 +1,10 @@
import { useEffect, useState, useMemo } from 'react'
import { useEffect, useState, useMemo, useRef } from 'react'
import { photosApi, PhotoSearchResult } from '../api/photos'
import tagsApi, { TagResponse, PhotoTagItem } from '../api/tags'
import { apiClient } from '../api/client'
import PhotoViewer from '../components/PhotoViewer'
import { useAuth } from '../context/AuthContext'
import peopleApi, { Person } from '../api/people'
type SearchType = 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed' | 'favorites'
@ -21,6 +22,16 @@ const SEARCH_TYPES: { value: SearchType; label: string }[] = [
type SortColumn = 'person' | 'tags' | 'processed' | 'path' | 'date_taken'
type SortDir = 'asc' | 'desc'
// Format person name for display
const formatPersonName = (person: Person): string => {
const parts: string[] = []
if (person.first_name) parts.push(person.first_name)
if (person.middle_name) parts.push(person.middle_name)
if (person.last_name) parts.push(person.last_name)
if (person.maiden_name) parts.push(`(${person.maiden_name})`)
return parts.join(' ') || 'Unknown'
}
export default function Search() {
const { hasPermission } = useAuth()
const canTagPhotos = hasPermission('tag_photos')
@ -28,6 +39,7 @@ export default function Search() {
const [searchType, setSearchType] = useState<SearchType>('name')
const [tagsExpanded, setTagsExpanded] = useState(true) // Default to expanded
const [filtersExpanded, setFiltersExpanded] = useState(false) // Default to collapsed
const [configExpanded, setConfigExpanded] = useState(true) // Default to expanded
// Search inputs
const [personName, setPersonName] = useState('')
@ -37,6 +49,14 @@ export default function Search() {
const [dateTo, setDateTo] = useState('')
const [mediaType, setMediaType] = useState<string>('all') // Default to 'all'
// Person autocomplete
const [allPeople, setAllPeople] = useState<Person[]>([])
const [selectedPeople, setSelectedPeople] = useState<Person[]>([])
const [showPeopleDropdown, setShowPeopleDropdown] = useState(false)
const [inputValue, setInputValue] = useState('') // Current text being typed
const personInputRef = useRef<HTMLInputElement>(null)
const personDropdownRef = useRef<HTMLDivElement>(null)
// Results
const [results, setResults] = useState<PhotoSearchResult[]>([])
const [total, setTotal] = useState(0)
@ -80,8 +100,18 @@ export default function Search() {
}
}
const loadAllPeople = async () => {
try {
const res = await peopleApi.list()
setAllPeople(res.items)
} catch (error) {
console.error('Error loading people:', error)
}
}
useEffect(() => {
loadTags()
loadAllPeople()
}, [])
const performSearch = async (pageNum: number = page) => {
@ -102,12 +132,25 @@ export default function Search() {
}
if (searchType === 'name') {
if (!personName.trim()) {
alert('Please enter at least one name to search.')
// Combine selected people names and free text input
// For selected people, use last name (most unique) or first+last if last name is empty
const selectedNames = selectedPeople.map(p => {
if (p.last_name) {
return p.last_name.trim()
} else if (p.first_name) {
return p.first_name.trim()
}
return formatPersonName(p).trim()
})
const freeText = inputValue.trim()
const allNames = [...selectedNames, freeText].filter(Boolean)
if (allNames.length === 0) {
alert('Please enter at least one name or select a person to search.')
setLoading(false)
return
}
params.person_name = personName.trim()
params.person_name = allNames.join(', ')
} else if (searchType === 'date') {
if (!dateFrom && !dateTo) {
alert('Please enter at least one date (from date or to date).')
@ -146,6 +189,64 @@ export default function Search() {
performSearch(1)
}
// Filter people for dropdown based on input
const filteredPeopleForDropdown = useMemo(() => {
if (!inputValue.trim()) {
return allPeople.filter(p => !selectedPeople.some(sp => sp.id === p.id))
}
const query = inputValue.trim().toLowerCase()
return allPeople.filter(person => {
// Don't show already selected people
if (selectedPeople.some(sp => sp.id === person.id)) return false
const lastName = (person.last_name || '').toLowerCase()
const firstName = (person.first_name || '').toLowerCase()
const middleName = (person.middle_name || '').toLowerCase()
const maidenName = (person.maiden_name || '').toLowerCase()
const fullName = formatPersonName(person).toLowerCase()
return fullName.includes(query) ||
lastName.includes(query) ||
firstName.includes(query) ||
middleName.includes(query) ||
maidenName.includes(query)
})
}, [inputValue, allPeople, selectedPeople])
// Handle selecting a person from dropdown
const handleSelectPerson = (person: Person) => {
if (!selectedPeople.some(sp => sp.id === person.id)) {
setSelectedPeople([...selectedPeople, person])
}
setInputValue('')
setShowPeopleDropdown(false)
personInputRef.current?.focus()
}
// Handle removing a selected person
const handleRemovePerson = (personId: number) => {
setSelectedPeople(selectedPeople.filter(p => p.id !== personId))
}
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
personDropdownRef.current &&
!personDropdownRef.current.contains(event.target as Node) &&
personInputRef.current &&
!personInputRef.current.contains(event.target as Node)
) {
setShowPeopleDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
useEffect(() => {
// Clear results from previous search when search type changes
setResults([])
@ -160,6 +261,12 @@ export default function Search() {
if (searchType !== 'tags') {
setSelectedTags([])
}
// Clear person autocomplete when switching away from name search
if (searchType !== 'name') {
setSelectedPeople([])
setInputValue('')
setPersonName('')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchType])
@ -536,7 +643,21 @@ export default function Search() {
}
if (searchType === 'name') {
baseParams.person_name = personName.trim()
// Combine selected people names and free text input
// For selected people, use last name (most unique) or first+last if last name is empty
const selectedNames = selectedPeople.map(p => {
if (p.last_name) {
return p.last_name.trim()
} else if (p.first_name) {
return p.first_name.trim()
}
return formatPersonName(p).trim()
})
const freeText = inputValue.trim()
const allNames = [...selectedNames, freeText].filter(Boolean)
if (allNames.length > 0) {
baseParams.person_name = allNames.join(', ')
}
} else if (searchType === 'date') {
baseParams.date_from = dateFrom || undefined
baseParams.date_to = dateTo || undefined
@ -587,40 +708,139 @@ export default function Search() {
return (
<div>
{/* Search Type Selector */}
<div className="bg-white rounded-lg shadow py-2 px-4 mb-2 w-1/3">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">
Search type:
</label>
<select
value={searchType}
onChange={(e) => setSearchType(e.target.value as SearchType)}
className="w-64 border rounded px-3 py-2"
>
{SEARCH_TYPES.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
</div>
{/* Collapsible Configuration Area */}
<div className="bg-white rounded-lg shadow mb-2">
<button
onClick={() => setConfigExpanded(!configExpanded)}
className="w-full flex items-center gap-2 p-2 hover:bg-gray-50 rounded-t-lg"
>
<span className="text-lg font-bold text-indigo-600">{configExpanded ? '' : '+'}</span>
<span className="text-sm font-medium text-gray-700">Search Configuration</span>
</button>
{configExpanded && (
<div className="p-2 space-y-2">
{/* Search Type Selector */}
<div className="bg-gray-50 rounded-lg shadow py-2 px-4 w-1/3">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">
Search type:
</label>
<select
value={searchType}
onChange={(e) => setSearchType(e.target.value as SearchType)}
className="w-64 border rounded px-3 py-2"
>
{SEARCH_TYPES.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
</div>
{/* Search Inputs */}
{(searchType !== 'no_faces' && searchType !== 'no_tags' && searchType !== 'processed' && searchType !== 'unprocessed') && (
<div className="bg-white rounded-lg shadow py-2 px-4 mb-2 w-1/3">
{/* Search Inputs */}
{(searchType !== 'no_faces' && searchType !== 'no_tags' && searchType !== 'processed' && searchType !== 'unprocessed') && (
<div className={`bg-gray-50 rounded-lg shadow py-2 px-4 ${searchType === 'name' ? 'w-full' : 'w-1/3'}`}>
{searchType === 'name' && (
<div className="flex items-center gap-2">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Person name(s):
</label>
<input
type="text"
value={personName}
onChange={(e) => setPersonName(e.target.value)}
className="w-96 border rounded px-3 py-2"
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Enter person name(s), separated by commas (e.g., John, Jane, Bob)"
/>
{/* Selected people chips */}
{selectedPeople.length > 0 && (
<div className="mb-2">
<div className="flex flex-wrap gap-2 mb-2">
{selectedPeople.map((person) => (
<span
key={person.id}
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm"
>
{formatPersonName(person)}
<button
type="button"
onClick={() => handleRemovePerson(person.id)}
className="hover:text-blue-600 font-bold"
title="Remove"
>
×
</button>
</span>
))}
</div>
<button
type="button"
onClick={() => {
setSelectedPeople([])
setResults([])
setTotal(0)
setPage(1)
}}
className="text-xs text-gray-600 hover:text-gray-800 underline"
title="Clear all selected people and results"
>
Clear all
</button>
</div>
)}
{/* Input with dropdown */}
<div className="relative">
<input
ref={personInputRef}
type="text"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
setShowPeopleDropdown(true)
}}
onFocus={() => setShowPeopleDropdown(true)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSearch()
} else if (e.key === 'Escape') {
setShowPeopleDropdown(false)
}
}}
className="w-96 border rounded px-3 py-2"
placeholder="Type to search or select people..."
/>
{/* Dropdown */}
{showPeopleDropdown && (
<div
ref={personDropdownRef}
className="absolute z-50 w-1/2 mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto"
style={{ top: '100%' }}
>
{filteredPeopleForDropdown.length > 0 ? (
filteredPeopleForDropdown.map((person) => (
<div
key={person.id}
onClick={() => handleSelectPerson(person)}
className="px-3 py-2 hover:bg-blue-50 cursor-pointer"
>
<div className="flex items-center gap-2">
<span className="font-medium">{formatPersonName(person)}</span>
{person.date_of_birth && (
<span className="text-xs text-gray-500">Born: {person.date_of_birth}</span>
)}
</div>
</div>
))
) : (
<div className="px-3 py-2 text-sm text-gray-500">
{inputValue.trim() ? 'No matching people found' : 'No people available'}
</div>
)}
</div>
)}
</div>
<p className="text-xs text-gray-500">
Select people from dropdown or type names manually (comma-separated)
</p>
</div>
)}
@ -704,12 +924,12 @@ export default function Search() {
</div>
)}
</div>
)}
</div>
)}
)}
</div>
)}
{/* Filters */}
<div className="bg-white rounded-lg shadow py-2 px-4 mb-2 w-1/3">
{/* Filters */}
<div className="bg-gray-50 rounded-lg shadow py-2 px-4 w-1/3">
<div className="flex items-center gap-2">
<h2 className="text-sm font-medium text-gray-700">Filters</h2>
<button
@ -737,18 +957,21 @@ export default function Search() {
</select>
</div>
</div>
)}
</div>
)}
</div>
{/* Search Button */}
<div className="mb-4">
<button
onClick={handleSearch}
disabled={loading}
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:bg-gray-400"
>
{loading ? 'Searching...' : 'Search'}
</button>
{/* Search Button */}
<div>
<button
onClick={handleSearch}
disabled={loading}
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:bg-gray-400"
>
{loading ? 'Searching...' : 'Search'}
</button>
</div>
</div>
)}
</div>
{/* Results */}
@ -756,10 +979,6 @@ export default function Search() {
<div className="bg-white rounded-lg shadow p-4">
<div className="sticky top-0 z-10 bg-white pb-4 mb-4 border-b">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-gray-700">Results:</span>
<span className="text-sm text-gray-500 ml-2">({total} items)</span>
</div>
<div className="flex gap-2">
<button
onClick={loadAllPhotos}