mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-14 23:25:25 +03:00
saved searches with confirmation dialogs
This commit is contained in:
parent
176dc0cf3a
commit
eb6d2c8983
41
web/src/components/input/DeleteSearchDialog.tsx
Normal file
41
web/src/components/input/DeleteSearchDialog.tsx
Normal file
@ -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 (
|
||||||
|
<AlertDialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete the saved search "{searchName}".
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={onClose}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={onConfirm}>Delete</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +1,19 @@
|
|||||||
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
LuX,
|
LuX,
|
||||||
LuFilter,
|
LuFilter,
|
||||||
LuImage,
|
LuImage,
|
||||||
LuChevronDown,
|
LuChevronDown,
|
||||||
LuChevronUp,
|
LuChevronUp,
|
||||||
|
LuSave,
|
||||||
|
LuTrash2,
|
||||||
} from "react-icons/lu";
|
} 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 useSuggestions from "@/hooks/use-suggestions";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@ -18,6 +25,9 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import { TooltipPortal } from "@radix-ui/react-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 convertMMDDYYToTimestamp = (dateString: string): number => {
|
||||||
const match = dateString.match(/^(\d{2})(\d{2})(\d{2})$/);
|
const match = dateString.match(/^(\d{2})(\d{2})(\d{2})$/);
|
||||||
@ -28,6 +38,14 @@ const convertMMDDYYToTimestamp = (dateString: string): number => {
|
|||||||
return date.getTime();
|
return date.getTime();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptyObject = Object.freeze([
|
||||||
|
{
|
||||||
|
name: "",
|
||||||
|
search: "",
|
||||||
|
filter: undefined,
|
||||||
|
},
|
||||||
|
]) as SavedSearchQuery[];
|
||||||
|
|
||||||
type InputWithTagsProps = {
|
type InputWithTagsProps = {
|
||||||
filters: SearchFilter;
|
filters: SearchFilter;
|
||||||
setFilters: (filter: SearchFilter) => void;
|
setFilters: (filter: SearchFilter) => void;
|
||||||
@ -56,11 +74,64 @@ export default function InputWithTags({
|
|||||||
|
|
||||||
// TODO: search history from browser storage
|
// TODO: search history from browser storage
|
||||||
|
|
||||||
const searchHistory = useMemo(
|
const [searchHistory, setSearchHistory, searchHistoryLoaded] = usePersistence<
|
||||||
() => ["previous search 1", "previous search 2"],
|
SavedSearchQuery[]
|
||||||
[],
|
>("frigate-search-history", emptyObject);
|
||||||
|
|
||||||
|
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [searchToDelete, setSearchToDelete] = useState<string | null>(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
|
// suggestions
|
||||||
|
|
||||||
const { suggestions, updateSuggestions } = useSuggestions(
|
const { suggestions, updateSuggestions } = useSuggestions(
|
||||||
@ -342,175 +413,226 @@ export default function InputWithTags({
|
|||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Command shouldFilter={false} ref={commandRef} className="rounded-md">
|
<>
|
||||||
<div className="relative">
|
<Command shouldFilter={false} ref={commandRef} className="rounded-md">
|
||||||
<CommandInput
|
<div className="relative">
|
||||||
ref={inputRef}
|
<CommandInput
|
||||||
value={inputValue}
|
ref={inputRef}
|
||||||
onValueChange={handleInputChange}
|
value={inputValue}
|
||||||
onFocus={handleInputFocus}
|
onValueChange={handleInputChange}
|
||||||
onBlur={handleInputBlur}
|
onFocus={handleInputFocus}
|
||||||
onKeyDown={handleInputKeyDown}
|
onBlur={handleInputBlur}
|
||||||
className="text-md h-10 pr-24"
|
onKeyDown={handleInputKeyDown}
|
||||||
placeholder="Search..."
|
className="text-md h-10 pr-24"
|
||||||
/>
|
placeholder="Search..."
|
||||||
<div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5">
|
/>
|
||||||
{(search || Object.keys(filters).length > 0) && (
|
<div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5">
|
||||||
<Tooltip>
|
{(search || Object.keys(filters).length > 0) && (
|
||||||
<TooltipTrigger>
|
<Tooltip>
|
||||||
<LuX
|
<TooltipTrigger>
|
||||||
className="size-4 cursor-pointer text-secondary-foreground"
|
<LuX
|
||||||
onClick={handleClearInput}
|
className="size-4 cursor-pointer text-secondary-foreground"
|
||||||
/>
|
onClick={handleClearInput}
|
||||||
</TooltipTrigger>
|
/>
|
||||||
<TooltipPortal>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Clear search</TooltipContent>
|
<TooltipPortal>
|
||||||
</TooltipPortal>
|
<TooltipContent>Clear search</TooltipContent>
|
||||||
</Tooltip>
|
</TooltipPortal>
|
||||||
)}
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(search || Object.keys(filters).length > 0) && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<LuSave
|
||||||
|
className="size-4 cursor-pointer text-secondary-foreground"
|
||||||
|
onClick={handleSetSearchHistory}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent>Save search</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSimilaritySearch && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger className="cursor-default">
|
||||||
|
<LuImage
|
||||||
|
aria-label="Similarity search active"
|
||||||
|
className="size-4 text-selected"
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent>Similarity search active</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
{isSimilaritySearch && (
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger className="cursor-default">
|
<TooltipTrigger className="cursor-default">
|
||||||
<LuImage
|
<LuFilter
|
||||||
aria-label="Similarity search active"
|
aria-label="Filters active"
|
||||||
className="size-4 text-selected"
|
className={cn(
|
||||||
|
"size-4",
|
||||||
|
Object.keys(filters).length > 0
|
||||||
|
? "text-selected"
|
||||||
|
: "text-secondary-foreground",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent>Similarity search active</TooltipContent>
|
<TooltipContent>
|
||||||
|
Filters{" "}
|
||||||
|
{Object.keys(filters).length > 0 ? "active" : "inactive"}
|
||||||
|
</TooltipContent>
|
||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
|
||||||
|
|
||||||
<Tooltip>
|
{inputFocused ? (
|
||||||
<TooltipTrigger className="cursor-default">
|
<LuChevronUp
|
||||||
<LuFilter
|
onClick={() => setInputFocused(false)}
|
||||||
aria-label="Filters active"
|
className="size-4 cursor-pointer text-secondary-foreground"
|
||||||
className={cn(
|
|
||||||
"size-4",
|
|
||||||
Object.keys(filters).length > 0
|
|
||||||
? "text-selected"
|
|
||||||
: "text-secondary-foreground",
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
) : (
|
||||||
<TooltipPortal>
|
<LuChevronDown
|
||||||
<TooltipContent>
|
onClick={() => setInputFocused(true)}
|
||||||
Filters{" "}
|
className="size-4 cursor-pointer text-secondary-foreground"
|
||||||
{Object.keys(filters).length > 0 ? "active" : "inactive"}
|
/>
|
||||||
</TooltipContent>
|
)}
|
||||||
</TooltipPortal>
|
</div>
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{inputFocused ? (
|
|
||||||
<LuChevronUp
|
|
||||||
onClick={() => setInputFocused(false)}
|
|
||||||
className="size-4 cursor-pointer text-secondary-foreground"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LuChevronDown
|
|
||||||
onClick={() => setInputFocused(true)}
|
|
||||||
className="size-4 cursor-pointer text-secondary-foreground"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<CommandList
|
<CommandList
|
||||||
className={cn(
|
className={cn(
|
||||||
"scrollbar-container border-t duration-200 animate-in fade-in",
|
"scrollbar-container border-t duration-200 animate-in fade-in",
|
||||||
inputFocused ? "visible" : "hidden",
|
inputFocused ? "visible" : "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{(Object.keys(filters).length > 0 || isSimilaritySearch) && (
|
{(Object.keys(filters).length > 0 || isSimilaritySearch) && (
|
||||||
<CommandGroup heading="Active Filters">
|
<CommandGroup heading="Active Filters">
|
||||||
<div className="my-2 flex flex-wrap gap-2 px-2">
|
<div className="my-2 flex flex-wrap gap-2 px-2">
|
||||||
{isSimilaritySearch && (
|
{isSimilaritySearch && (
|
||||||
<span className="inline-flex items-center whitespace-nowrap rounded-full bg-blue-100 px-2 py-0.5 text-sm text-blue-800">
|
<span className="inline-flex items-center whitespace-nowrap rounded-full bg-blue-100 px-2 py-0.5 text-sm text-blue-800">
|
||||||
Similarity Search
|
Similarity Search
|
||||||
<button
|
|
||||||
onClick={handleClearInput}
|
|
||||||
className="ml-1 focus:outline-none"
|
|
||||||
aria-label="Clear similarity search"
|
|
||||||
>
|
|
||||||
<LuX className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{Object.entries(filters).map(([filterType, filterValues]) =>
|
|
||||||
Array.isArray(filterValues) ? (
|
|
||||||
filterValues.map((value, index) => (
|
|
||||||
<span
|
|
||||||
key={`${filterType}-${index}`}
|
|
||||||
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800"
|
|
||||||
>
|
|
||||||
{filterType}:{value}
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
removeFilter(filterType as FilterType, value)
|
|
||||||
}
|
|
||||||
className="ml-1 focus:outline-none"
|
|
||||||
aria-label={`Remove ${filterType}:${value} filter`}
|
|
||||||
>
|
|
||||||
<LuX className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
key={filterType}
|
|
||||||
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800"
|
|
||||||
>
|
|
||||||
{filterType}:
|
|
||||||
{filterType === "before" || filterType === "after"
|
|
||||||
? new Date(filterValues as number).toLocaleDateString()
|
|
||||||
: filterValues}
|
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={handleClearInput}
|
||||||
removeFilter(
|
|
||||||
filterType as FilterType,
|
|
||||||
filterValues as string | number,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="ml-1 focus:outline-none"
|
className="ml-1 focus:outline-none"
|
||||||
aria-label={`Remove ${filterType}:${filterValues} filter`}
|
aria-label="Clear similarity search"
|
||||||
>
|
>
|
||||||
<LuX className="h-3 w-3" />
|
<LuX className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
),
|
)}
|
||||||
)}
|
{Object.entries(filters).map(([filterType, filterValues]) =>
|
||||||
</div>
|
Array.isArray(filterValues) ? (
|
||||||
|
filterValues.map((value, index) => (
|
||||||
|
<span
|
||||||
|
key={`${filterType}-${index}`}
|
||||||
|
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800"
|
||||||
|
>
|
||||||
|
{filterType}:{value}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
removeFilter(filterType as FilterType, value)
|
||||||
|
}
|
||||||
|
className="ml-1 focus:outline-none"
|
||||||
|
aria-label={`Remove ${filterType}:${value} filter`}
|
||||||
|
>
|
||||||
|
<LuX className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
key={filterType}
|
||||||
|
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800"
|
||||||
|
>
|
||||||
|
{filterType}:
|
||||||
|
{filterType === "before" || filterType === "after"
|
||||||
|
? new Date(filterValues as number).toLocaleDateString()
|
||||||
|
: filterValues}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
removeFilter(
|
||||||
|
filterType as FilterType,
|
||||||
|
filterValues as string | number,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="ml-1 focus:outline-none"
|
||||||
|
aria-label={`Remove ${filterType}:${filterValues} filter`}
|
||||||
|
>
|
||||||
|
<LuX className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!currentFilterType &&
|
||||||
|
!inputValue &&
|
||||||
|
(searchHistory?.length ?? 0) > 0 && (
|
||||||
|
<CommandGroup heading="Previous Searches">
|
||||||
|
{searchHistory?.map((suggestion, index) => (
|
||||||
|
<CommandItem
|
||||||
|
key={index}
|
||||||
|
className="flex cursor-pointer items-center justify-between"
|
||||||
|
onSelect={() => handleLoadSavedSearch(suggestion.name)}
|
||||||
|
>
|
||||||
|
<span>{suggestion.name}</span>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteSearch(suggestion.name);
|
||||||
|
}}
|
||||||
|
className="focus:outline-none"
|
||||||
|
>
|
||||||
|
<LuTrash2 className="h-4 w-4 text-secondary-foreground" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent>Delete saved search</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</Tooltip>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
<CommandGroup
|
||||||
|
heading={currentFilterType ? "Filter Values" : "Filters"}
|
||||||
|
>
|
||||||
|
{filterSuggestions(suggestions)
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
!searchHistory?.some((history) => history.name === item),
|
||||||
|
)
|
||||||
|
.map((suggestion, index) => (
|
||||||
|
<CommandItem
|
||||||
|
key={index + (searchHistory?.length ?? 0)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onSelect={() => handleSuggestionClick(suggestion)}
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
</CommandList>
|
||||||
{!currentFilterType && !inputValue && searchHistory.length > 0 && (
|
</Command>
|
||||||
<CommandGroup heading="Previous Searches">
|
<SaveSearchDialog
|
||||||
{searchHistory.map((suggestion, index) => (
|
isOpen={isSaveDialogOpen}
|
||||||
<CommandItem
|
onClose={() => setIsSaveDialogOpen(false)}
|
||||||
key={index}
|
onSave={handleSaveSearch}
|
||||||
className="cursor-pointer"
|
/>
|
||||||
onSelect={() => handleSuggestionClick(suggestion)}
|
<DeleteSearchDialog
|
||||||
>
|
isOpen={isDeleteDialogOpen}
|
||||||
{suggestion}
|
onClose={() => setIsDeleteDialogOpen(false)}
|
||||||
</CommandItem>
|
onConfirm={confirmDeleteSearch}
|
||||||
))}
|
searchName={searchToDelete || ""}
|
||||||
</CommandGroup>
|
/>
|
||||||
)}
|
</>
|
||||||
<CommandGroup heading={currentFilterType ? "Filter Values" : "Filters"}>
|
|
||||||
{filterSuggestions(suggestions)
|
|
||||||
.filter((item) => !searchHistory.includes(item))
|
|
||||||
.map((suggestion, index) => (
|
|
||||||
<CommandItem
|
|
||||||
key={index + searchHistory.length}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onSelect={() => handleSuggestionClick(suggestion)}
|
|
||||||
>
|
|
||||||
{suggestion}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
58
web/src/components/input/SaveSearchDialog.tsx
Normal file
58
web/src/components/input/SaveSearchDialog.tsx
Normal file
@ -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 (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Save Search</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
Provide a name for this saved search.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
value={searchName}
|
||||||
|
onChange={(e) => setSearchName(e.target.value)}
|
||||||
|
placeholder="Enter a name for your search"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={onClose} variant="select">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>Save</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,18 +1,18 @@
|
|||||||
import { FilterType, SearchFilter } from "@/types/search";
|
import { FilterType, SavedSearchQuery, SearchFilter } from "@/types/search";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
// Custom hook for managing suggestions
|
// Custom hook for managing suggestions
|
||||||
export type UseSuggestionsType = (
|
export type UseSuggestionsType = (
|
||||||
filters: SearchFilter,
|
filters: SearchFilter,
|
||||||
allSuggestions: { [K in keyof SearchFilter]: string[] },
|
allSuggestions: { [K in keyof SearchFilter]: string[] },
|
||||||
searchHistory: string[],
|
searchHistory: SavedSearchQuery[],
|
||||||
) => ReturnType<typeof useSuggestions>;
|
) => ReturnType<typeof useSuggestions>;
|
||||||
|
|
||||||
// Define and export the useSuggestions hook
|
// Define and export the useSuggestions hook
|
||||||
export default function useSuggestions(
|
export default function useSuggestions(
|
||||||
filters: SearchFilter,
|
filters: SearchFilter,
|
||||||
allSuggestions: { [K in keyof SearchFilter]: string[] },
|
allSuggestions: { [K in keyof SearchFilter]: string[] },
|
||||||
searchHistory: string[],
|
searchHistory?: SavedSearchQuery[],
|
||||||
) {
|
) {
|
||||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
|
|
||||||
@ -46,7 +46,7 @@ export default function useSuggestions(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
setSuggestions([
|
setSuggestions([
|
||||||
...searchHistory,
|
...(searchHistory?.map((search) => search.name) ?? []),
|
||||||
...availableFilters,
|
...availableFilters,
|
||||||
"before",
|
"before",
|
||||||
"after",
|
"after",
|
||||||
|
|||||||
@ -56,3 +56,9 @@ export type SearchQueryParams = {
|
|||||||
|
|
||||||
export type SearchQuery = [string, SearchQueryParams] | null;
|
export type SearchQuery = [string, SearchQueryParams] | null;
|
||||||
export type FilterType = keyof SearchFilter;
|
export type FilterType = keyof SearchFilter;
|
||||||
|
|
||||||
|
export type SavedSearchQuery = {
|
||||||
|
name: string;
|
||||||
|
search: string;
|
||||||
|
filter: SearchFilter | undefined;
|
||||||
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user