From a41e30b10188fbc3d26643bbd0cc48a525c4b199 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Thu, 4 Dec 2025 14:26:32 -0500 Subject: [PATCH] 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. --- frontend/src/pages/Search.tsx | 327 ++++++++++++++++++++++++++++------ 1 file changed, 273 insertions(+), 54 deletions(-) diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx index 330ae55..c265a12 100644 --- a/frontend/src/pages/Search.tsx +++ b/frontend/src/pages/Search.tsx @@ -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('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('all') // Default to 'all' + // Person autocomplete + const [allPeople, setAllPeople] = useState([]) + const [selectedPeople, setSelectedPeople] = useState([]) + const [showPeopleDropdown, setShowPeopleDropdown] = useState(false) + const [inputValue, setInputValue] = useState('') // Current text being typed + const personInputRef = useRef(null) + const personDropdownRef = useRef(null) + // Results const [results, setResults] = useState([]) 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 (
- {/* Search Type Selector */} -
-
- - -
-
+ {/* Collapsible Configuration Area */} +
+ + + {configExpanded && ( +
+ {/* Search Type Selector */} +
+
+ + +
+
- {/* Search Inputs */} - {(searchType !== 'no_faces' && searchType !== 'no_tags' && searchType !== 'processed' && searchType !== 'unprocessed') && ( -
+ {/* Search Inputs */} + {(searchType !== 'no_faces' && searchType !== 'no_tags' && searchType !== 'processed' && searchType !== 'unprocessed') && ( +
{searchType === 'name' && ( -
+
- 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 && ( +
+
+ {selectedPeople.map((person) => ( + + {formatPersonName(person)} + + + ))} +
+ +
+ )} + + {/* Input with dropdown */} +
+ { + 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 && ( +
+ {filteredPeopleForDropdown.length > 0 ? ( + filteredPeopleForDropdown.map((person) => ( +
handleSelectPerson(person)} + className="px-3 py-2 hover:bg-blue-50 cursor-pointer" + > +
+ {formatPersonName(person)} + {person.date_of_birth && ( + Born: {person.date_of_birth} + )} +
+
+ )) + ) : ( +
+ {inputValue.trim() ? 'No matching people found' : 'No people available'} +
+ )} +
+ )} +
+ +

+ Select people from dropdown or type names manually (comma-separated) +

)} @@ -704,12 +924,12 @@ export default function Search() {
)}
- )} -
- )} + )} +
+ )} - {/* Filters */} -
+ {/* Filters */} +

Filters

- )} -
+ )} +
- {/* Search Button */} -
- + {/* Search Button */} +
+ +
+
+ )}
{/* Results */} @@ -756,10 +979,6 @@ export default function Search() {
-
- Results: - ({total} items) -