diff --git a/web/src/components/input/DeleteSearchDialog.tsx b/web/src/components/input/DeleteSearchDialog.tsx new file mode 100644 index 000000000..c307c723b --- /dev/null +++ b/web/src/components/input/DeleteSearchDialog.tsx @@ -0,0 +1,41 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +type DeleteSearchDialogProps = { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + searchName: string; +}; + +export function DeleteSearchDialog({ + isOpen, + onClose, + onConfirm, + searchName, +}: DeleteSearchDialogProps) { + return ( + + + + Are you sure? + + This will permanently delete the saved search "{searchName}". + + + + Cancel + Delete + + + + ); +} diff --git a/web/src/components/input/InputWithTags.tsx b/web/src/components/input/InputWithTags.tsx index 864cf26d8..44d5ed5d1 100644 --- a/web/src/components/input/InputWithTags.tsx +++ b/web/src/components/input/InputWithTags.tsx @@ -1,12 +1,19 @@ -import { useState, useRef, useEffect, useMemo, useCallback } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; import { LuX, LuFilter, LuImage, LuChevronDown, LuChevronUp, + LuSave, + LuTrash2, } from "react-icons/lu"; -import { FilterType, SearchFilter, SearchSource } from "@/types/search"; +import { + FilterType, + SavedSearchQuery, + SearchFilter, + SearchSource, +} from "@/types/search"; import useSuggestions from "@/hooks/use-suggestions"; import { Command, @@ -18,6 +25,9 @@ import { import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip"; +import { usePersistence } from "@/hooks/use-persistence"; +import { SaveSearchDialog } from "./SaveSearchDialog"; +import { DeleteSearchDialog } from "./DeleteSearchDialog"; const convertMMDDYYToTimestamp = (dateString: string): number => { const match = dateString.match(/^(\d{2})(\d{2})(\d{2})$/); @@ -28,6 +38,14 @@ const convertMMDDYYToTimestamp = (dateString: string): number => { return date.getTime(); }; +const emptyObject = Object.freeze([ + { + name: "", + search: "", + filter: undefined, + }, +]) as SavedSearchQuery[]; + type InputWithTagsProps = { filters: SearchFilter; setFilters: (filter: SearchFilter) => void; @@ -56,11 +74,64 @@ export default function InputWithTags({ // TODO: search history from browser storage - const searchHistory = useMemo( - () => ["previous search 1", "previous search 2"], - [], + const [searchHistory, setSearchHistory, searchHistoryLoaded] = usePersistence< + SavedSearchQuery[] + >("frigate-search-history", emptyObject); + + const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [searchToDelete, setSearchToDelete] = useState(null); + + const handleSetSearchHistory = useCallback(() => { + setIsSaveDialogOpen(true); + }, []); + + const handleSaveSearch = useCallback( + (name: string) => { + if (searchHistoryLoaded) { + setSearchHistory([ + ...(searchHistory ?? []), + { + name: name, + search: search, + filter: filters, + }, + ]); + } + }, + [search, filters, searchHistory, setSearchHistory, searchHistoryLoaded], ); + const handleLoadSavedSearch = useCallback( + (name: string) => { + if (searchHistoryLoaded) { + const savedSearchEntry = searchHistory?.find( + (entry) => entry.name === name, + ); + if (savedSearchEntry) { + setFilters(savedSearchEntry.filter!); + setSearch(savedSearchEntry.search); + } + } + }, + [searchHistory, searchHistoryLoaded, setFilters, setSearch], + ); + + const handleDeleteSearch = useCallback((name: string) => { + setSearchToDelete(name); + setIsDeleteDialogOpen(true); + }, []); + + const confirmDeleteSearch = useCallback(() => { + if (searchToDelete && searchHistory) { + setSearchHistory( + searchHistory.filter((item) => item.name !== searchToDelete) ?? [], + ); + setSearchToDelete(null); + setIsDeleteDialogOpen(false); + } + }, [searchToDelete, searchHistory, setSearchHistory]); + // suggestions const { suggestions, updateSuggestions } = useSuggestions( @@ -342,175 +413,226 @@ export default function InputWithTags({ }, [search]); return ( - -
- -
- {(search || Object.keys(filters).length > 0) && ( - - - - - - Clear search - - - )} + <> + +
+ +
+ {(search || Object.keys(filters).length > 0) && ( + + + + + + Clear search + + + )} + + {(search || Object.keys(filters).length > 0) && ( + + + + + + Save search + + + )} + + {isSimilaritySearch && ( + + + + + + Similarity search active + + + )} - {isSimilaritySearch && ( - 0 + ? "text-selected" + : "text-secondary-foreground", + )} /> - Similarity search active + + Filters{" "} + {Object.keys(filters).length > 0 ? "active" : "inactive"} + - )} - - - 0 - ? "text-selected" - : "text-secondary-foreground", - )} + {inputFocused ? ( + setInputFocused(false)} + className="size-4 cursor-pointer text-secondary-foreground" /> - - - - Filters{" "} - {Object.keys(filters).length > 0 ? "active" : "inactive"} - - - - - {inputFocused ? ( - setInputFocused(false)} - className="size-4 cursor-pointer text-secondary-foreground" - /> - ) : ( - setInputFocused(true)} - className="size-4 cursor-pointer text-secondary-foreground" - /> - )} + ) : ( + setInputFocused(true)} + className="size-4 cursor-pointer text-secondary-foreground" + /> + )} +
-
- - {(Object.keys(filters).length > 0 || isSimilaritySearch) && ( - -
- {isSimilaritySearch && ( - - Similarity Search - - - )} - {Object.entries(filters).map(([filterType, filterValues]) => - Array.isArray(filterValues) ? ( - filterValues.map((value, index) => ( - - {filterType}:{value} - - - )) - ) : ( - - {filterType}: - {filterType === "before" || filterType === "after" - ? new Date(filterValues as number).toLocaleDateString() - : filterValues} + + {(Object.keys(filters).length > 0 || isSimilaritySearch) && ( + +
+ {isSimilaritySearch && ( + + Similarity Search - ), - )} -
+ )} + {Object.entries(filters).map(([filterType, filterValues]) => + Array.isArray(filterValues) ? ( + filterValues.map((value, index) => ( + + {filterType}:{value} + + + )) + ) : ( + + {filterType}: + {filterType === "before" || filterType === "after" + ? new Date(filterValues as number).toLocaleDateString() + : filterValues} + + + ), + )} +
+
+ )} + + {!currentFilterType && + !inputValue && + (searchHistory?.length ?? 0) > 0 && ( + + {searchHistory?.map((suggestion, index) => ( + handleLoadSavedSearch(suggestion.name)} + > + {suggestion.name} + + + + + + Delete saved search + + + + ))} + + )} + + {filterSuggestions(suggestions) + .filter( + (item) => + !searchHistory?.some((history) => history.name === item), + ) + .map((suggestion, index) => ( + handleSuggestionClick(suggestion)} + > + {suggestion} + + ))} - )} - {!currentFilterType && !inputValue && searchHistory.length > 0 && ( - - {searchHistory.map((suggestion, index) => ( - handleSuggestionClick(suggestion)} - > - {suggestion} - - ))} - - )} - - {filterSuggestions(suggestions) - .filter((item) => !searchHistory.includes(item)) - .map((suggestion, index) => ( - handleSuggestionClick(suggestion)} - > - {suggestion} - - ))} - -
- + + + setIsSaveDialogOpen(false)} + onSave={handleSaveSearch} + /> + setIsDeleteDialogOpen(false)} + onConfirm={confirmDeleteSearch} + searchName={searchToDelete || ""} + /> + ); } diff --git a/web/src/components/input/SaveSearchDialog.tsx b/web/src/components/input/SaveSearchDialog.tsx new file mode 100644 index 000000000..4aff2ff7e --- /dev/null +++ b/web/src/components/input/SaveSearchDialog.tsx @@ -0,0 +1,58 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useState } from "react"; + +type SaveSearchDialogProps = { + isOpen: boolean; + onClose: () => void; + onSave: (name: string) => void; +}; + +export function SaveSearchDialog({ + isOpen, + onClose, + onSave, +}: SaveSearchDialogProps) { + const [searchName, setSearchName] = useState(""); + + const handleSave = () => { + if (searchName.trim()) { + onSave(searchName.trim()); + setSearchName(""); + onClose(); + } + }; + + return ( + + + + Save Search + + Provide a name for this saved search. + + + setSearchName(e.target.value)} + placeholder="Enter a name for your search" + /> + + + + + + + ); +} diff --git a/web/src/hooks/use-suggestions.ts b/web/src/hooks/use-suggestions.ts index 702bb1711..9222c866c 100644 --- a/web/src/hooks/use-suggestions.ts +++ b/web/src/hooks/use-suggestions.ts @@ -1,18 +1,18 @@ -import { FilterType, SearchFilter } from "@/types/search"; +import { FilterType, SavedSearchQuery, SearchFilter } from "@/types/search"; import { useCallback, useState } from "react"; // Custom hook for managing suggestions export type UseSuggestionsType = ( filters: SearchFilter, allSuggestions: { [K in keyof SearchFilter]: string[] }, - searchHistory: string[], + searchHistory: SavedSearchQuery[], ) => ReturnType; // Define and export the useSuggestions hook export default function useSuggestions( filters: SearchFilter, allSuggestions: { [K in keyof SearchFilter]: string[] }, - searchHistory: string[], + searchHistory?: SavedSearchQuery[], ) { const [suggestions, setSuggestions] = useState([]); @@ -46,7 +46,7 @@ export default function useSuggestions( }, ); setSuggestions([ - ...searchHistory, + ...(searchHistory?.map((search) => search.name) ?? []), ...availableFilters, "before", "after", diff --git a/web/src/types/search.ts b/web/src/types/search.ts index 63daf445d..141d3a72e 100644 --- a/web/src/types/search.ts +++ b/web/src/types/search.ts @@ -55,4 +55,10 @@ export type SearchQueryParams = { }; export type SearchQuery = [string, SearchQueryParams] | null; -export type FilterType = keyof SearchFilter; \ No newline at end of file +export type FilterType = keyof SearchFilter; + +export type SavedSearchQuery = { + name: string; + search: string; + filter: SearchFilter | undefined; +};