feat: Enhance Search component with session state management and UI improvements
This commit introduces session storage functionality in the Search component, allowing users to persist their search state across page reloads. The UI has been updated for better clarity, including improved labels and placeholders for input fields. Additionally, the search options have been reorganized for a more intuitive user experience. Documentation has been updated to reflect these changes.
This commit is contained in:
@@ -48,3 +48,4 @@ module.exports = {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -35,3 +35,4 @@ export const rolePermissionsApi = {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -121,3 +121,4 @@ export const videosApi = {
|
||||
export default videosApi
|
||||
|
||||
|
||||
|
||||
|
||||
+187
-66
@@ -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 date' },
|
||||
{ value: 'tags', label: 'Search photos by tags' },
|
||||
{ value: 'name', label: 'By person name' },
|
||||
{ value: 'date', label: 'By date range' },
|
||||
{ value: 'tags', label: 'By tags' },
|
||||
{ value: 'no_faces', label: 'Photos without faces' },
|
||||
{ value: 'no_tags', label: 'Photos without tags' },
|
||||
{ value: 'processed', label: 'Search processed photos' },
|
||||
{ value: 'unprocessed', label: 'Search un-processed photos' },
|
||||
{ value: 'processed', label: 'Processed photos' },
|
||||
{ value: 'unprocessed', label: 'Unprocessed 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 names
|
||||
</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 available'}
|
||||
{inputValue.trim() ? 'No matching people found' : 'Start typing to search for people'}
|
||||
</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">Tags</h2>
|
||||
<h2 className="text-sm font-medium text-gray-700">Select tags</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">Filters</h2>
|
||||
<h2 className="text-sm font-medium text-gray-700">Additional filters</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>
|
||||
|
||||
Reference in New Issue
Block a user