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:
parent
6cc359f25a
commit
a41e30b101
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user