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:
tanyar09 2025-12-05 12:38:34 -05:00
parent d2852fbf1e
commit 7973dfadd2
8 changed files with 194 additions and 66 deletions

View File

@ -48,3 +48,4 @@ module.exports = {
}

View File

@ -35,3 +35,4 @@ export const rolePermissionsApi = {
}

View File

@ -121,3 +121,4 @@ export const videosApi = {
export default videosApi

View File

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

View File

@ -67,3 +67,4 @@ def update_role_permissions(
return RolePermissionsResponse(features=features, permissions=permissions)

View File

@ -336,3 +336,4 @@ def get_video_file(
return response

View File

@ -41,3 +41,4 @@ class RolePermissionsUpdateRequest(BaseModel):
return [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES]

View File

@ -89,3 +89,4 @@ class RemoveVideoPersonResponse(BaseModel):
message: str