feat: Enhance Search component with date taken filters and tag management
This commit adds new date taken filters to the Search component, allowing users to specify a date range for photos based on when they were taken. Additionally, the tagging functionality is improved with options to filter by tags during searches, including a match mode for tag selection. The UI is updated to accommodate these features, enhancing user experience and search capabilities. Documentation has been updated to reflect these changes.
This commit is contained in:
+399
-42
@@ -46,6 +46,8 @@ export default function Search() {
|
||||
const [matchAll, setMatchAll] = useState(false)
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [dateTakenFrom, setDateTakenFrom] = useState('')
|
||||
const [dateTakenTo, setDateTakenTo] = useState('')
|
||||
const [mediaType, setMediaType] = useState<string>('all') // Default to 'all'
|
||||
|
||||
// Person autocomplete
|
||||
@@ -136,6 +138,8 @@ export default function Search() {
|
||||
if (state.matchAll !== undefined) setMatchAll(state.matchAll)
|
||||
if (state.dateFrom) setDateFrom(state.dateFrom)
|
||||
if (state.dateTo) setDateTo(state.dateTo)
|
||||
if (state.dateTakenFrom) setDateTakenFrom(state.dateTakenFrom)
|
||||
if (state.dateTakenTo) setDateTakenTo(state.dateTakenTo)
|
||||
if (state.mediaType) setMediaType(state.mediaType)
|
||||
if (state.selectedPeople) setSelectedPeople(state.selectedPeople)
|
||||
if (state.inputValue) setInputValue(state.inputValue)
|
||||
@@ -195,6 +199,8 @@ export default function Search() {
|
||||
matchAll,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
dateTakenFrom,
|
||||
dateTakenTo,
|
||||
mediaType,
|
||||
selectedPeople,
|
||||
inputValue,
|
||||
@@ -211,7 +217,7 @@ export default function Search() {
|
||||
} 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])
|
||||
}, [searchType, selectedTags, matchAll, dateFrom, dateTo, dateTakenFrom, dateTakenTo, mediaType, selectedPeople, inputValue, tagsExpanded, filtersExpanded, configExpanded, sortColumn, sortDir, page, results, total])
|
||||
|
||||
const performSearch = async (pageNum: number = page, showValidationErrors: boolean = true, searchTypeOverride?: SearchType) => {
|
||||
setLoading(true)
|
||||
@@ -254,6 +260,11 @@ export default function Search() {
|
||||
return
|
||||
}
|
||||
params.person_name = allNames.join(', ')
|
||||
// Add tags filter if provided
|
||||
if (selectedTags.length > 0) {
|
||||
params.tag_names = selectedTags.join(', ')
|
||||
params.match_all = matchAll
|
||||
}
|
||||
} else if (currentSearchType === 'date') {
|
||||
if (!dateFrom && !dateTo) {
|
||||
if (showValidationErrors) {
|
||||
@@ -264,7 +275,20 @@ export default function Search() {
|
||||
}
|
||||
params.date_from = dateFrom || undefined
|
||||
params.date_to = dateTo || undefined
|
||||
} else if (currentSearchType === 'tags') {
|
||||
// Add tags filter if provided
|
||||
if (selectedTags.length > 0) {
|
||||
params.tag_names = selectedTags.join(', ')
|
||||
params.match_all = matchAll
|
||||
}
|
||||
} else {
|
||||
// For non-date search types, apply date taken filter if provided
|
||||
if (dateTakenFrom || dateTakenTo) {
|
||||
params.date_from = dateTakenFrom || undefined
|
||||
params.date_to = dateTakenTo || undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSearchType === 'tags') {
|
||||
if (selectedTags.length === 0) {
|
||||
if (showValidationErrors) {
|
||||
alert('Please select at least one tag to search for.')
|
||||
@@ -369,8 +393,8 @@ export default function Search() {
|
||||
if (searchType === 'no_faces' || searchType === 'no_tags' || searchType === 'processed' || searchType === 'unprocessed' || searchType === 'favorites') {
|
||||
handleSearch()
|
||||
}
|
||||
// Clear selected tags when switching away from tag search
|
||||
if (searchType !== 'tags') {
|
||||
// Clear selected tags when switching away from tag/name/date search types
|
||||
if (searchType !== 'tags' && searchType !== 'name' && searchType !== 'date') {
|
||||
setSelectedTags([])
|
||||
}
|
||||
// Clear person autocomplete when switching away from name search
|
||||
@@ -808,10 +832,28 @@ export default function Search() {
|
||||
if (allNames.length > 0) {
|
||||
baseParams.person_name = allNames.join(', ')
|
||||
}
|
||||
// Add tags filter if provided
|
||||
if (selectedTags.length > 0) {
|
||||
baseParams.tag_names = selectedTags.join(', ')
|
||||
baseParams.match_all = matchAll
|
||||
}
|
||||
} else if (searchType === 'date') {
|
||||
baseParams.date_from = dateFrom || undefined
|
||||
baseParams.date_to = dateTo || undefined
|
||||
} else if (searchType === 'tags') {
|
||||
// Add tags filter if provided
|
||||
if (selectedTags.length > 0) {
|
||||
baseParams.tag_names = selectedTags.join(', ')
|
||||
baseParams.match_all = matchAll
|
||||
}
|
||||
} else {
|
||||
// For non-date search types, apply date taken filter if provided
|
||||
if (dateTakenFrom || dateTakenTo) {
|
||||
baseParams.date_from = dateTakenFrom || undefined
|
||||
baseParams.date_to = dateTakenTo || undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (searchType === 'tags') {
|
||||
baseParams.tag_names = selectedTags.join(', ')
|
||||
baseParams.match_all = matchAll
|
||||
}
|
||||
@@ -994,34 +1036,301 @@ export default function Search() {
|
||||
<p className="text-xs text-gray-500">
|
||||
Select people from the dropdown or type names manually. Separate multiple names with commas.
|
||||
</p>
|
||||
|
||||
{/* Tags filter for name search */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-medium text-gray-700">Filter by tags (optional)</h2>
|
||||
<button
|
||||
onClick={() => setTagsExpanded(!tagsExpanded)}
|
||||
className="text-lg text-gray-600 hover:text-gray-800"
|
||||
title={tagsExpanded ? 'Collapse tags section' : 'Expand tags section'}
|
||||
aria-label={tagsExpanded ? 'Collapse tags section' : 'Expand tags section'}
|
||||
>
|
||||
{tagsExpanded ? '▼' : '▶'}
|
||||
</button>
|
||||
</div>
|
||||
{tagsExpanded && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div>
|
||||
<label htmlFor="tag-search-input-name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Add tags
|
||||
</label>
|
||||
{/* Selected tags display */}
|
||||
{selectedTags.length > 0 && (
|
||||
<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"
|
||||
>
|
||||
{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-name"
|
||||
ref={tagInputRef}
|
||||
type="text"
|
||||
value={tagSearchInput}
|
||||
onChange={(e) => {
|
||||
setTagSearchInput(e.target.value)
|
||||
setShowTagDropdown(true)
|
||||
}}
|
||||
onFocus={() => setShowTagDropdown(true)}
|
||||
onBlur={(e) => {
|
||||
setTimeout(() => {
|
||||
if (!tagDropdownRef.current?.contains(e.relatedTarget as Node)) {
|
||||
setShowTagDropdown(false)
|
||||
}
|
||||
}, 200)
|
||||
}}
|
||||
placeholder="Type to search and select tags..."
|
||||
className="w-full border rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
{showTagDropdown && (
|
||||
<div
|
||||
ref={tagDropdownRef}
|
||||
className="absolute z-10 w-full mt-1 bg-white border rounded shadow-lg max-h-60 overflow-auto"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{(() => {
|
||||
const filtered = availableTags.filter(tag =>
|
||||
tag.tag_name.toLowerCase().includes(tagSearchInput.toLowerCase()) &&
|
||||
!selectedTags.includes(tag.tag_name)
|
||||
)
|
||||
return filtered.length > 0 ? (
|
||||
filtered.map(tag => (
|
||||
<div
|
||||
key={tag.id}
|
||||
onClick={() => {
|
||||
if (!selectedTags.includes(tag.tag_name)) {
|
||||
setSelectedTags([...selectedTags, tag.tag_name])
|
||||
}
|
||||
setTagSearchInput('')
|
||||
setTimeout(() => {
|
||||
setShowTagDropdown(true)
|
||||
tagInputRef.current?.focus()
|
||||
}, 0)
|
||||
}}
|
||||
className="px-3 py-2 hover:bg-gray-100 cursor-pointer text-sm"
|
||||
>
|
||||
{tag.tag_name}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-2 text-gray-500 text-sm">
|
||||
{tagSearchInput.trim() ? 'No matching tags found' : 'Start typing to search for tags'}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="match-mode-select-name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Match mode
|
||||
</label>
|
||||
<select
|
||||
id="match-mode-select-name"
|
||||
value={matchAll ? 'ALL' : 'ANY'}
|
||||
onChange={(e) => setMatchAll(e.target.value === 'ALL')}
|
||||
className="border rounded px-3 py-2"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchType === 'date' && (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<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"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-end gap-4">
|
||||
<div>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<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"
|
||||
/>
|
||||
{/* Tags filter for date search */}
|
||||
<div className="w-1/2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-medium text-gray-700">Filter by tags (optional)</h2>
|
||||
<button
|
||||
onClick={() => setTagsExpanded(!tagsExpanded)}
|
||||
className="text-lg text-gray-600 hover:text-gray-800"
|
||||
title={tagsExpanded ? 'Collapse tags section' : 'Expand tags section'}
|
||||
aria-label={tagsExpanded ? 'Collapse tags section' : 'Expand tags section'}
|
||||
>
|
||||
{tagsExpanded ? '▼' : '▶'}
|
||||
</button>
|
||||
</div>
|
||||
{tagsExpanded && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div>
|
||||
<label htmlFor="tag-search-input-date" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Add tags
|
||||
</label>
|
||||
{/* Selected tags display */}
|
||||
{selectedTags.length > 0 && (
|
||||
<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"
|
||||
>
|
||||
{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-date"
|
||||
ref={tagInputRef}
|
||||
type="text"
|
||||
value={tagSearchInput}
|
||||
onChange={(e) => {
|
||||
setTagSearchInput(e.target.value)
|
||||
setShowTagDropdown(true)
|
||||
}}
|
||||
onFocus={() => setShowTagDropdown(true)}
|
||||
onBlur={(e) => {
|
||||
setTimeout(() => {
|
||||
if (!tagDropdownRef.current?.contains(e.relatedTarget as Node)) {
|
||||
setShowTagDropdown(false)
|
||||
}
|
||||
}, 200)
|
||||
}}
|
||||
placeholder="Type to search and select tags..."
|
||||
className="w-full border rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
{showTagDropdown && (
|
||||
<div
|
||||
ref={tagDropdownRef}
|
||||
className="absolute z-10 w-full mt-1 bg-white border rounded shadow-lg max-h-60 overflow-auto"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{(() => {
|
||||
const filtered = availableTags.filter(tag =>
|
||||
tag.tag_name.toLowerCase().includes(tagSearchInput.toLowerCase()) &&
|
||||
!selectedTags.includes(tag.tag_name)
|
||||
)
|
||||
return filtered.length > 0 ? (
|
||||
filtered.map(tag => (
|
||||
<div
|
||||
key={tag.id}
|
||||
onClick={() => {
|
||||
if (!selectedTags.includes(tag.tag_name)) {
|
||||
setSelectedTags([...selectedTags, tag.tag_name])
|
||||
}
|
||||
setTagSearchInput('')
|
||||
setTimeout(() => {
|
||||
setShowTagDropdown(true)
|
||||
tagInputRef.current?.focus()
|
||||
}, 0)
|
||||
}}
|
||||
className="px-3 py-2 hover:bg-gray-100 cursor-pointer text-sm"
|
||||
>
|
||||
{tag.tag_name}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-2 text-gray-500 text-sm">
|
||||
{tagSearchInput.trim() ? 'No matching tags found' : 'Start typing to search for tags'}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="match-mode-select-date" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Match mode
|
||||
</label>
|
||||
<select
|
||||
id="match-mode-select-date"
|
||||
value={matchAll ? 'ALL' : 'ANY'}
|
||||
onChange={(e) => setMatchAll(e.target.value === 'ALL')}
|
||||
className="border rounded px-3 py-2"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1179,20 +1488,68 @@ export default function Search() {
|
||||
</div>
|
||||
{filtersExpanded && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div>
|
||||
<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 media types</option>
|
||||
<option value="image">Photos only</option>
|
||||
<option value="video">Videos only</option>
|
||||
</select>
|
||||
<div className="flex items-end gap-4">
|
||||
<div>
|
||||
<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 media types</option>
|
||||
<option value="image">Photos only</option>
|
||||
<option value="video">Videos only</option>
|
||||
</select>
|
||||
</div>
|
||||
{searchType !== 'date' && (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="date-taken-from-input" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Date Taken - From
|
||||
</label>
|
||||
<input
|
||||
id="date-taken-from-input"
|
||||
type="date"
|
||||
value={dateTakenFrom}
|
||||
onChange={(e) => setDateTakenFrom(e.target.value)}
|
||||
className="w-48 border rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="date-taken-to-input" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Date Taken - To <span className="text-gray-500 font-normal">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="date-taken-to-input"
|
||||
type="date"
|
||||
value={dateTakenTo}
|
||||
onChange={(e) => setDateTakenTo(e.target.value)}
|
||||
className="w-48 border rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
{(dateTakenFrom || dateTakenTo) && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDateTakenFrom('')
|
||||
setDateTakenTo('')
|
||||
}}
|
||||
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-800 border rounded hover:bg-gray-50"
|
||||
title="Clear date filters"
|
||||
>
|
||||
Clear dates
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user