mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-14 15:15:22 +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 {
|
||||
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<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
|
||||
|
||||
const { suggestions, updateSuggestions } = useSuggestions(
|
||||
@ -342,6 +413,7 @@ export default function InputWithTags({
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Command shouldFilter={false} ref={commandRef} className="rounded-md">
|
||||
<div className="relative">
|
||||
<CommandInput
|
||||
@ -369,6 +441,20 @@ export default function InputWithTags({
|
||||
</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">
|
||||
@ -484,25 +570,49 @@ export default function InputWithTags({
|
||||
</div>
|
||||
</CommandGroup>
|
||||
)}
|
||||
{!currentFilterType && !inputValue && searchHistory.length > 0 && (
|
||||
|
||||
{!currentFilterType &&
|
||||
!inputValue &&
|
||||
(searchHistory?.length ?? 0) > 0 && (
|
||||
<CommandGroup heading="Previous Searches">
|
||||
{searchHistory.map((suggestion, index) => (
|
||||
{searchHistory?.map((suggestion, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
className="cursor-pointer"
|
||||
onSelect={() => handleSuggestionClick(suggestion)}
|
||||
className="flex cursor-pointer items-center justify-between"
|
||||
onSelect={() => handleLoadSavedSearch(suggestion.name)}
|
||||
>
|
||||
{suggestion}
|
||||
<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"}>
|
||||
<CommandGroup
|
||||
heading={currentFilterType ? "Filter Values" : "Filters"}
|
||||
>
|
||||
{filterSuggestions(suggestions)
|
||||
.filter((item) => !searchHistory.includes(item))
|
||||
.filter(
|
||||
(item) =>
|
||||
!searchHistory?.some((history) => history.name === item),
|
||||
)
|
||||
.map((suggestion, index) => (
|
||||
<CommandItem
|
||||
key={index + searchHistory.length}
|
||||
key={index + (searchHistory?.length ?? 0)}
|
||||
className="cursor-pointer"
|
||||
onSelect={() => handleSuggestionClick(suggestion)}
|
||||
>
|
||||
@ -512,5 +622,17 @@ export default function InputWithTags({
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<SaveSearchDialog
|
||||
isOpen={isSaveDialogOpen}
|
||||
onClose={() => setIsSaveDialogOpen(false)}
|
||||
onSave={handleSaveSearch}
|
||||
/>
|
||||
<DeleteSearchDialog
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onClose={() => setIsDeleteDialogOpen(false)}
|
||||
onConfirm={confirmDeleteSearch}
|
||||
searchName={searchToDelete || ""}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
// Custom hook for managing suggestions
|
||||
export type UseSuggestionsType = (
|
||||
filters: SearchFilter,
|
||||
allSuggestions: { [K in keyof SearchFilter]: string[] },
|
||||
searchHistory: string[],
|
||||
searchHistory: SavedSearchQuery[],
|
||||
) => ReturnType<typeof useSuggestions>;
|
||||
|
||||
// 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<string[]>([]);
|
||||
|
||||
@ -46,7 +46,7 @@ export default function useSuggestions(
|
||||
},
|
||||
);
|
||||
setSuggestions([
|
||||
...searchHistory,
|
||||
...(searchHistory?.map((search) => search.name) ?? []),
|
||||
...availableFilters,
|
||||
"before",
|
||||
"after",
|
||||
|
||||
@ -56,3 +56,9 @@ export type SearchQueryParams = {
|
||||
|
||||
export type SearchQuery = [string, SearchQueryParams] | null;
|
||||
export type FilterType = keyof SearchFilter;
|
||||
|
||||
export type SavedSearchQuery = {
|
||||
name: string;
|
||||
search: string;
|
||||
filter: SearchFilter | undefined;
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user