@ -9,13 +9,13 @@ import peopleApi, { Person } from '../api/people'
type SearchType = 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed' | 'favorites'
const SEARCH_TYPES : { value : SearchType ; label : string } [ ] = [
{ value : 'name' , label : ' Search photos by name' } ,
{ value : 'date' , label : ' Search photos by dat e' } ,
{ value : 'tags' , label : ' Search photos b y tags' } ,
{ value : 'name' , label : ' By person name' } ,
{ value : 'date' , label : ' By date rang e' } ,
{ value : 'tags' , label : ' B y tags' } ,
{ value : 'no_faces' , label : 'Photos without faces' } ,
{ value : 'no_tags' , label : 'Photos without tags' } ,
{ value : 'processed' , label : ' Search p rocessed photos' } ,
{ value : 'unprocessed' , label : ' Search un- processed photos' } ,
{ value : 'processed' , label : ' P rocessed photos' } ,
{ value : 'unprocessed' , label : ' Un processed photos' } ,
{ value : 'favorites' , label : '⭐ Favorite photos' } ,
]
@ -42,7 +42,6 @@ export default function Search() {
const [ configExpanded , setConfigExpanded ] = useState ( true ) // Default to expanded
// Search inputs
const [ personName , setPersonName ] = useState ( '' )
const [ selectedTags , setSelectedTags ] = useState < string [ ] > ( [ ] )
const [ matchAll , setMatchAll ] = useState ( false )
const [ dateFrom , setDateFrom ] = useState ( '' )
@ -78,6 +77,12 @@ export default function Search() {
const tagInputRef = useRef < HTMLInputElement > ( null )
const tagDropdownRef = useRef < HTMLDivElement > ( null )
// SessionStorage key for persisting search state (clears when tab/window closes)
const SEARCH_STATE_KEY = 'search_page_state'
// Track if we're restoring state to prevent clearing during restoration
const isRestoringState = useRef ( false )
// Tag modal
const [ showTagModal , setShowTagModal ] = useState ( false )
const [ selectedTagName , setSelectedTagName ] = useState ( '' )
@ -113,11 +118,99 @@ export default function Search() {
}
}
// Load state from sessionStorage on mount
useEffect ( ( ) = > {
let restoredSearchType : SearchType | null = null
try {
isRestoringState . current = true
const savedState = sessionStorage . getItem ( SEARCH_STATE_KEY )
if ( savedState ) {
const state = JSON . parse ( savedState )
// Restore all state values
if ( state . searchType ) {
restoredSearchType = state . searchType
setSearchType ( state . searchType )
}
if ( state . selectedTags ) setSelectedTags ( state . selectedTags )
if ( state . matchAll !== undefined ) setMatchAll ( state . matchAll )
if ( state . dateFrom ) setDateFrom ( state . dateFrom )
if ( state . dateTo ) setDateTo ( state . dateTo )
if ( state . mediaType ) setMediaType ( state . mediaType )
if ( state . selectedPeople ) setSelectedPeople ( state . selectedPeople )
if ( state . inputValue ) setInputValue ( state . inputValue )
if ( state . tagsExpanded !== undefined ) setTagsExpanded ( state . tagsExpanded )
if ( state . filtersExpanded !== undefined ) setFiltersExpanded ( state . filtersExpanded )
if ( state . configExpanded !== undefined ) setConfigExpanded ( state . configExpanded )
if ( state . sortColumn ) setSortColumn ( state . sortColumn )
if ( state . sortDir ) setSortDir ( state . sortDir )
if ( state . page ) setPage ( state . page )
// Restore results if they exist
if ( state . results && Array . isArray ( state . results ) ) {
setResults ( state . results )
}
if ( state . total !== undefined ) {
setTotal ( state . total )
}
}
} catch ( error ) {
console . error ( 'Error loading saved search state:' , error )
} finally {
// Mark restoration as complete after a short delay to allow all state updates to process
setTimeout ( ( ) = > {
isRestoringState . current = false
// Re-run auto-searches after restoration completes
if ( restoredSearchType &&
( restoredSearchType === 'no_faces' ||
restoredSearchType === 'no_tags' ||
restoredSearchType === 'processed' ||
restoredSearchType === 'unprocessed' ||
restoredSearchType === 'favorites' ) ) {
// Use a small delay to ensure state is fully restored
setTimeout ( ( ) = > {
performSearch ( )
} , 150 )
}
} , 100 )
}
loadTags ( )
loadAllPeople ( )
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ ] )
// Save state to sessionStorage whenever relevant values change
useEffect ( ( ) = > {
// Don't save during state restoration
if ( isRestoringState . current ) {
return
}
try {
const stateToSave = {
searchType ,
selectedTags ,
matchAll ,
dateFrom ,
dateTo ,
mediaType ,
selectedPeople ,
inputValue ,
tagsExpanded ,
filtersExpanded ,
configExpanded ,
sortColumn ,
sortDir ,
page ,
results ,
total ,
}
sessionStorage . setItem ( SEARCH_STATE_KEY , JSON . stringify ( stateToSave ) )
} catch ( error ) {
console . error ( 'Error saving search state:' , error )
}
} , [ searchType , selectedTags , matchAll , dateFrom , dateTo , mediaType , selectedPeople , inputValue , tagsExpanded , filtersExpanded , configExpanded , sortColumn , sortDir , page , results , total ] )
const performSearch = async ( pageNum : number = page ) = > {
setLoading ( true )
try {
@ -252,6 +345,11 @@ export default function Search() {
} , [ ] )
useEffect ( ( ) = > {
// Don't clear state during restoration
if ( isRestoringState . current ) {
return
}
// Clear results from previous search when search type changes
setResults ( [ ] )
setTotal ( 0 )
@ -269,7 +367,6 @@ export default function Search() {
if ( searchType !== 'name' ) {
setSelectedPeople ( [ ] )
setInputValue ( '' )
setPersonName ( '' )
}
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ searchType ] )
@ -719,21 +816,22 @@ export default function Search() {
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 >
< span className = "text-sm font-medium text-gray-700" > Search options < / 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 = "bg-gray-50 rounded-lg shadow py-2 px-4 ">
< div className = "flex items-center gap-2" >
< label className= "text-sm font-medium text-gray-700 ">
Search type :
< label htmlFor= "search-type-select" className= "text-sm font-medium text-gray-700 whitespace-nowrap ">
Search by
< / label >
< select
id = "search-type-select"
value = { searchType }
onChange = { ( e ) = > setSearchType ( e . target . value as SearchType ) }
className = " w-64 border rounded px-3 py-2"
className = " border rounded px-3 py-2"
>
{ SEARCH_TYPES . map ( type = > (
< option key = { type . value } value = { type . value } > { type . label } < / option >
@ -744,11 +842,11 @@ export default function Search() {
{ /* 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' } ` } >
< div className = "bg-gray-50 rounded-lg shadow py-2 px-4" >
{ searchType === 'name' && (
< div className = "space-y-2" >
< label className= "text-sm font-medium text-gray-700" >
Person name ( s) :
< label htmlFor= "person-name-input" className= "text-sm font-medium text-gray-700" >
Person name s
< / label >
{ /* Selected people chips */ }
@ -765,7 +863,8 @@ export default function Search() {
type = "button"
onClick = { ( ) = > handleRemovePerson ( person . id ) }
className = "hover:text-blue-600 font-bold"
title = "Remove"
title = "Remove person"
aria - label = { ` Remove ${ formatPersonName ( person ) } ` }
>
×
< / button >
@ -781,7 +880,7 @@ export default function Search() {
setPage ( 1 )
} }
className = "text-xs text-gray-600 hover:text-gray-800 underline"
title = "Clear all selected people and results"
title = "Clear all selected people and search results"
>
Clear all
< / button >
@ -791,6 +890,7 @@ export default function Search() {
{ /* Input with dropdown */ }
< div className = "relative" >
< input
id = "person-name-input"
ref = { personInputRef }
type = "text"
value = { inputValue }
@ -808,7 +908,7 @@ export default function Search() {
}
} }
className = "w-96 border rounded px-3 py-2"
placeholder = "Type to search or select people ..."
placeholder = "Type a name or select from list ..."
/ >
{ /* Dropdown */ }
@ -835,7 +935,7 @@ export default function Search() {
) )
) : (
< div className = "px-3 py-2 text-sm text-gray-500" >
{ inputValue . trim ( ) ? 'No matching people found' : ' No people availab le'}
{ inputValue . trim ( ) ? 'No matching people found' : ' Start typing to search for peop le'}
< / div >
) }
< / div >
@ -843,7 +943,7 @@ export default function Search() {
< / div >
< p className = "text-xs text-gray-500" >
Select people from dropdown or type names manually ( comma - separated )
Select people from the dropdown or type names manually . Separate multiple names with commas .
< / p >
< / div >
) }
@ -851,40 +951,41 @@ export default function Search() {
{ searchType === 'date' && (
< div className = "space-y-2" >
< div >
< label className= "block text-sm font-medium text-gray-700 mb-1" >
From date :
< label htmlFor= "date-from-input" className= "block text-sm font-medium text-gray-700 mb-1" >
Start date
< / label >
< input
id = "date-from-input"
type = "date"
value = { dateFrom }
onChange = { ( e ) = > setDateFrom ( e . target . value ) }
className = "w-48 border rounded px-3 py-2"
placeholder = "YYYY-MM-DD"
/ >
< / div >
< div >
< label className= "block text-sm font-medium text-gray-700 mb-1" >
To date :
< label htmlFor= "date-to-input" className= "block text-sm font-medium text-gray-700 mb-1" >
End date < span className = "text-gray-500 font-normal" > ( optional ) < / span >
< / label >
< input
id = "date-to-input"
type = "date"
value = { dateTo }
onChange = { ( e ) = > setDateTo ( e . target . value ) }
className = "w-48 border rounded px-3 py-2"
placeholder = "YYYY-MM-DD (optional)"
/ >
< / div >
< / div >
) }
{ searchType === 'tags' && (
< div >
< div className = "w-1/2" >
< div className = "flex items-center gap-2" >
< h2 className = "text-sm font-medium text-gray-700" > T ags< / h2 >
< h2 className = "text-sm font-medium text-gray-700" > Select t ags< / h2 >
< button
onClick = { ( ) = > setTagsExpanded ( ! tagsExpanded ) }
className = "text-lg text-gray-600 hover:text-gray-800"
title = { tagsExpanded ? 'Collapse' : 'Expand' }
title = { tagsExpanded ? 'Collapse tags section' : 'Expand tags section' }
aria - label = { tagsExpanded ? 'Collapse tags section' : 'Expand tags section' }
>
{ tagsExpanded ? '▼' : '▶' }
< / button >
@ -892,34 +993,47 @@ export default function Search() {
{ tagsExpanded && (
< div className = "mt-3 space-y-2" >
< div >
< label className= "block text-sm font-medium text-gray-700 mb-1" >
Select Tags :
< label htmlFor= "tag-search-input" className= "block text-sm font-medium text-gray-700 mb-1" >
Add tags
< / label >
{ /* Selected tags display */ }
{ selectedTags . length > 0 && (
< div className = "flex flex-wrap gap-2 mb-2" >
{ selectedTags . map ( tag = > (
< span
key = { tag }
className = "inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm"
>
{ tag }
< button
onClick = { ( ) = > {
setSelectedTags ( selectedTags . filter ( t = > t !== tag ) )
} }
className = "hover:text-blue-600"
type = "button"
< div className = "mb-2" >
< div className = "flex flex-wrap gap-2 mb-2" >
{ selectedTags . map ( tag = > (
< span
key = { tag }
className = "inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm"
>
×
< / button >
< / span >
) ) }
{ tag }
< button
onClick = { ( ) = > {
setSelectedTags ( selectedTags . filter ( t = > t !== tag ) )
} }
className = "hover:text-blue-600"
type = "button"
>
×
< / button >
< / span >
) ) }
< / div >
< button
type = "button"
onClick = { ( ) = > {
setSelectedTags ( [ ] )
} }
className = "text-xs text-gray-600 hover:text-gray-800 underline"
title = "Clear all selected tags"
>
Clear all
< / button >
< / div >
) }
{ /* Tag input and dropdown */ }
< div className = "relative" >
< input
id = "tag-search-input"
ref = { tagInputRef }
type = "text"
value = { tagSearchInput }
@ -936,7 +1050,7 @@ export default function Search() {
}
} , 200 )
} }
placeholder = "Type to search tags..."
placeholder = "Type to search and select tags..."
className = "w-full border rounded px-3 py-2 text-sm"
/ >
{ showTagDropdown && (
@ -959,8 +1073,11 @@ export default function Search() {
setSelectedTags ( [ . . . selectedTags , tag . tag_name ] )
}
setTagSearchInput ( '' )
setShowTagDropdown ( false )
tagInputRef . current ? . focus ( )
// Keep dropdown open and refocus input for continuous selection
setTimeout ( ( ) = > {
setShowTagDropdown ( true )
tagInputRef . current ? . focus ( )
} , 0 )
} }
className = "px-3 py-2 hover:bg-gray-100 cursor-pointer text-sm"
>
@ -969,7 +1086,7 @@ export default function Search() {
) )
) : (
< div className = "px-3 py-2 text-gray-500 text-sm" >
No tags found
{ tagSearchInput . trim ( ) ? 'No matching tags found' : 'Start typing to search for tags' }
< / div >
)
} ) ( ) }
@ -978,16 +1095,17 @@ export default function Search() {
< / div >
< / div >
< div >
< label className= "block text-sm font-medium text-gray-700 mb-1" >
Match mode :
< label htmlFor= "match-mode-select" className= "block text-sm font-medium text-gray-700 mb-1" >
Match mode
< / label >
< select
id = "match-mode-select"
value = { matchAll ? 'ALL' : 'ANY' }
onChange = { ( e ) = > setMatchAll ( e . target . value === 'ALL' ) }
className = "border rounded px-3 py-2"
>
< option value = "ANY" > ANY ( photos with any tag ) < / option >
< option value = "ALL" > ALL ( photos with all tags ) < / option >
< option value = "ANY" > Match any tag ( photos with at least one selected tag ) < / option >
< option value = "ALL" > Match all tags ( photos with all selected tags ) < / option >
< / select >
< / div >
< / div >
@ -998,13 +1116,14 @@ export default function Search() {
) }
{ /* Filters */ }
< div className = "bg-gray-50 rounded-lg shadow py-2 px-4 w-1/3 ">
< div className = "bg-gray-50 rounded-lg shadow py-2 px-4 ">
< div className = "flex items-center gap-2" >
< h2 className = "text-sm font-medium text-gray-700" > F ilters< / h2 >
< h2 className = "text-sm font-medium text-gray-700" > Additional f ilters< / h2 >
< button
onClick = { ( ) = > setFiltersExpanded ( ! filtersExpanded ) }
className = "text-lg text-gray-600 hover:text-gray-800"
title = { filtersExpanded ? 'Collapse' : 'Expand' }
title = { filtersExpanded ? 'Collapse filters section' : 'Expand filters section' }
aria - label = { filtersExpanded ? 'Collapse filters section' : 'Expand filters section' }
>
{ filtersExpanded ? '▼' : '▶' }
< / button >
@ -1012,17 +1131,18 @@ export default function Search() {
{ filtersExpanded && (
< div className = "mt-3 space-y-2" >
< div >
< label className= "block text-sm font-medium text-gray-700 mb-1" >
Media Type :
< label htmlFor= "media-type-select" className= "block text-sm font-medium text-gray-700 mb-1" >
Media type
< / label >
< select
id = "media-type-select"
value = { mediaType }
onChange = { ( e ) = > setMediaType ( e . target . value ) }
className = "w-48 border rounded px-3 py-2"
>
< option value = "all" > All < / option >
< option value = "image" > Photos < / option >
< option value = "video" > Videos < / option >
< option value = "all" > All media types < / option >
< option value = "image" > Photos only < / option >
< option value = "video" > Videos only < / option >
< / select >
< / div >
< / div >
@ -1034,9 +1154,10 @@ export default function Search() {
< button
onClick = { handleSearch }
disabled = { loading }
className = "px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:bg-gray-400"
className = "px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
aria - label = "Search photos"
>
{ loading ? 'Searching...' : 'Search '}
{ loading ? 'Searching...' : 'Search photos '}
< / button >
< / div >
< / div >