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) -