forked from Github/frigate
Improved search input (#13815)
* create input with tags component * tweaks * only show filters pane when there are actual filters * special case for similarity searches * similarity search tweaks * populate suggestions values * scrollbar on outer div * clean up * separate custom hook * use command component * tooltips * regex tweaks * saved searches with confirmation dialogs * better date handling * fix filters * filter capitalization * filter instructions * replace underscore in filter type * alert dialog button color * toaster on success
This commit is contained in:
@@ -109,11 +109,8 @@ export default function SearchFilterGroup({
|
||||
return;
|
||||
}
|
||||
const cameraConfig = config.cameras[camera];
|
||||
cameraConfig.review.alerts.required_zones.forEach((zone) => {
|
||||
zones.add(zone);
|
||||
});
|
||||
cameraConfig.review.detections.required_zones.forEach((zone) => {
|
||||
zones.add(zone);
|
||||
Object.entries(cameraConfig.zones).map(([name, _]) => {
|
||||
zones.add(name);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
46
web/src/components/input/DeleteSearchDialog.tsx
Normal file
46
web/src/components/input/DeleteSearchDialog.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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}
|
||||
className="bg-destructive text-white"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
704
web/src/components/input/InputWithTags.tsx
Normal file
704
web/src/components/input/InputWithTags.tsx
Normal file
@@ -0,0 +1,704 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import {
|
||||
LuX,
|
||||
LuFilter,
|
||||
LuImage,
|
||||
LuChevronDown,
|
||||
LuChevronUp,
|
||||
LuTrash2,
|
||||
LuStar,
|
||||
} from "react-icons/lu";
|
||||
import {
|
||||
FilterType,
|
||||
SavedSearchQuery,
|
||||
SearchFilter,
|
||||
SearchSource,
|
||||
} from "@/types/search";
|
||||
import useSuggestions from "@/hooks/use-suggestions";
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
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";
|
||||
import {
|
||||
convertLocalDateToTimestamp,
|
||||
getIntlDateFormat,
|
||||
} from "@/utils/dateUtil";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type InputWithTagsProps = {
|
||||
filters: SearchFilter;
|
||||
setFilters: (filter: SearchFilter) => void;
|
||||
search: string;
|
||||
setSearch: (search: string) => void;
|
||||
allSuggestions: {
|
||||
[K in keyof SearchFilter]: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export default function InputWithTags({
|
||||
filters,
|
||||
setFilters,
|
||||
search,
|
||||
setSearch,
|
||||
allSuggestions,
|
||||
}: InputWithTagsProps) {
|
||||
const [inputValue, setInputValue] = useState(search || "");
|
||||
const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>(
|
||||
null,
|
||||
);
|
||||
const [inputFocused, setInputFocused] = useState(false);
|
||||
const [isSimilaritySearch, setIsSimilaritySearch] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const commandRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// TODO: search history from browser storage
|
||||
|
||||
const [searchHistory, setSearchHistory, searchHistoryLoaded] = usePersistence<
|
||||
SavedSearchQuery[]
|
||||
>("frigate-search-history");
|
||||
|
||||
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(
|
||||
filters,
|
||||
allSuggestions,
|
||||
searchHistory,
|
||||
);
|
||||
|
||||
const resetSuggestions = useCallback(
|
||||
(value: string) => {
|
||||
setCurrentFilterType(null);
|
||||
updateSuggestions(value, null);
|
||||
},
|
||||
[updateSuggestions],
|
||||
);
|
||||
|
||||
const filterSuggestions = useCallback(
|
||||
(current_suggestions: string[]) => {
|
||||
if (!inputValue || currentFilterType) return suggestions;
|
||||
const words = inputValue.split(/[\s,]+/);
|
||||
const lastNonEmptyWordIndex = words
|
||||
.map((word) => word.trim())
|
||||
.lastIndexOf(words.filter((word) => word.trim() !== "").pop() || "");
|
||||
const currentWord = words[lastNonEmptyWordIndex];
|
||||
return current_suggestions.filter((suggestion) =>
|
||||
suggestion.toLowerCase().includes(currentWord.toLowerCase()),
|
||||
);
|
||||
},
|
||||
[inputValue, suggestions, currentFilterType],
|
||||
);
|
||||
|
||||
const removeFilter = useCallback(
|
||||
(filterType: FilterType, filterValue: string | number) => {
|
||||
const newFilters = { ...filters };
|
||||
if (Array.isArray(newFilters[filterType])) {
|
||||
(newFilters[filterType] as string[]) = (
|
||||
newFilters[filterType] as string[]
|
||||
).filter((v) => v !== filterValue);
|
||||
if ((newFilters[filterType] as string[]).length === 0) {
|
||||
delete newFilters[filterType];
|
||||
}
|
||||
} else if (filterType === "before" || filterType === "after") {
|
||||
if (newFilters[filterType] === filterValue) {
|
||||
delete newFilters[filterType];
|
||||
}
|
||||
} else {
|
||||
delete newFilters[filterType];
|
||||
}
|
||||
setFilters(newFilters as SearchFilter);
|
||||
},
|
||||
[filters, setFilters],
|
||||
);
|
||||
|
||||
const createFilter = useCallback(
|
||||
(type: FilterType, value: string) => {
|
||||
if (
|
||||
allSuggestions[type as keyof SearchFilter]?.includes(value) ||
|
||||
type === "before" ||
|
||||
type === "after"
|
||||
) {
|
||||
const newFilters = { ...filters };
|
||||
let timestamp = 0;
|
||||
|
||||
switch (type) {
|
||||
case "before":
|
||||
case "after":
|
||||
timestamp = convertLocalDateToTimestamp(value);
|
||||
if (timestamp > 0) {
|
||||
// Check for conflicts with existing before/after filters
|
||||
if (
|
||||
type === "before" &&
|
||||
filters.after &&
|
||||
timestamp <= filters.after * 1000
|
||||
) {
|
||||
toast.error(
|
||||
"The 'before' date must be later than the 'after' date.",
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
type === "after" &&
|
||||
filters.before &&
|
||||
timestamp >= filters.before * 1000
|
||||
) {
|
||||
toast.error(
|
||||
"The 'after' date must be earlier than the 'before' date.",
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (type === "before") {
|
||||
timestamp -= 1;
|
||||
}
|
||||
newFilters[type] = timestamp / 1000;
|
||||
}
|
||||
break;
|
||||
case "search_type":
|
||||
if (!newFilters.search_type) newFilters.search_type = [];
|
||||
if (
|
||||
!(newFilters.search_type as SearchSource[]).includes(
|
||||
value as SearchSource,
|
||||
)
|
||||
) {
|
||||
(newFilters.search_type as SearchSource[]).push(
|
||||
value as SearchSource,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "event_id":
|
||||
newFilters.event_id = value;
|
||||
break;
|
||||
default:
|
||||
// Handle array types (cameras, labels, subLabels, zones)
|
||||
if (!newFilters[type]) newFilters[type] = [];
|
||||
if (Array.isArray(newFilters[type])) {
|
||||
if (!(newFilters[type] as string[]).includes(value)) {
|
||||
(newFilters[type] as string[]).push(value);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
setFilters(newFilters);
|
||||
setInputValue((prev) => prev.replace(`${type}:${value}`, "").trim());
|
||||
setCurrentFilterType(null);
|
||||
}
|
||||
},
|
||||
[filters, setFilters, allSuggestions],
|
||||
);
|
||||
|
||||
// handlers
|
||||
|
||||
const handleFilterCreation = useCallback(
|
||||
(filterType: FilterType, filterValue: string) => {
|
||||
const trimmedValue = filterValue.trim();
|
||||
if (
|
||||
allSuggestions[filterType as keyof SearchFilter]?.includes(
|
||||
trimmedValue,
|
||||
) ||
|
||||
((filterType === "before" || filterType === "after") &&
|
||||
trimmedValue.match(/^\d{8}$/))
|
||||
) {
|
||||
createFilter(filterType, trimmedValue);
|
||||
setInputValue((prev) => {
|
||||
const regex = new RegExp(
|
||||
`${filterType}:${filterValue.trim()}[,\\s]*`,
|
||||
);
|
||||
const newValue = prev.replace(regex, "").trim();
|
||||
return newValue.endsWith(",")
|
||||
? newValue.slice(0, -1).trim()
|
||||
: newValue;
|
||||
});
|
||||
setCurrentFilterType(null);
|
||||
}
|
||||
},
|
||||
[allSuggestions, createFilter],
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(value: string) => {
|
||||
setInputValue(value);
|
||||
|
||||
const words = value.split(/[\s,]+/);
|
||||
const lastNonEmptyWordIndex = words
|
||||
.map((word) => word.trim())
|
||||
.lastIndexOf(words.filter((word) => word.trim() !== "").pop() || "");
|
||||
const currentWord = words[lastNonEmptyWordIndex];
|
||||
const isLastCharSpaceOrComma = value.endsWith(" ") || value.endsWith(",");
|
||||
|
||||
// Check if the current word is a filter type
|
||||
const filterTypeMatch = currentWord.match(/^(\w+):(.*)$/);
|
||||
if (filterTypeMatch) {
|
||||
const [_, filterType, filterValue] = filterTypeMatch as [
|
||||
string,
|
||||
FilterType,
|
||||
string,
|
||||
];
|
||||
|
||||
// Check if filter type is valid
|
||||
if (
|
||||
filterType in allSuggestions ||
|
||||
filterType === "before" ||
|
||||
filterType === "after"
|
||||
) {
|
||||
setCurrentFilterType(filterType);
|
||||
|
||||
if (filterType === "before" || filterType === "after") {
|
||||
// For before and after, we don't need to update suggestions
|
||||
if (filterValue.match(/^\d{8}$/)) {
|
||||
handleFilterCreation(filterType, filterValue);
|
||||
}
|
||||
} else {
|
||||
updateSuggestions(filterValue, filterType);
|
||||
|
||||
// Check if the last character is a space or comma
|
||||
if (isLastCharSpaceOrComma) {
|
||||
handleFilterCreation(filterType, filterValue);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resetSuggestions(value);
|
||||
}
|
||||
} else {
|
||||
resetSuggestions(value);
|
||||
}
|
||||
},
|
||||
[updateSuggestions, resetSuggestions, allSuggestions, handleFilterCreation],
|
||||
);
|
||||
|
||||
const handleInputFocus = useCallback(() => {
|
||||
setInputFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleClearInput = useCallback(() => {
|
||||
setInputFocused(false);
|
||||
setInputValue("");
|
||||
resetSuggestions("");
|
||||
setSearch("");
|
||||
inputRef?.current?.blur();
|
||||
setFilters({});
|
||||
setCurrentFilterType(null);
|
||||
setIsSimilaritySearch(false);
|
||||
}, [setFilters, resetSuggestions, setSearch]);
|
||||
|
||||
const handleInputBlur = useCallback((e: React.FocusEvent) => {
|
||||
if (
|
||||
commandRef.current &&
|
||||
!commandRef.current.contains(e.relatedTarget as Node)
|
||||
) {
|
||||
setInputFocused(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSuggestionClick = useCallback(
|
||||
(suggestion: string) => {
|
||||
if (currentFilterType) {
|
||||
// Apply the selected suggestion to the current filter type
|
||||
createFilter(currentFilterType, suggestion);
|
||||
setInputValue((prev) => {
|
||||
const regex = new RegExp(`${currentFilterType}:[^\\s,]*`, "g");
|
||||
return prev.replace(regex, "").trim();
|
||||
});
|
||||
} else if (suggestion in allSuggestions) {
|
||||
// Set the suggestion as a new filter type
|
||||
setCurrentFilterType(suggestion as FilterType);
|
||||
setInputValue((prev) => {
|
||||
// Remove any partial match of the filter type, including incomplete matches
|
||||
const words = prev.split(/\s+/);
|
||||
const lastWord = words[words.length - 1];
|
||||
if (lastWord && suggestion.startsWith(lastWord.toLowerCase())) {
|
||||
words[words.length - 1] = suggestion + ":";
|
||||
} else {
|
||||
words.push(suggestion + ":");
|
||||
}
|
||||
return words.join(" ").trim();
|
||||
});
|
||||
} else {
|
||||
// Add the suggestion as a standalone word
|
||||
setInputValue((prev) => `${prev}${suggestion} `);
|
||||
}
|
||||
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
[createFilter, currentFilterType, allSuggestions],
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
setInputFocused(false);
|
||||
inputRef?.current?.blur();
|
||||
},
|
||||
[setSearch],
|
||||
);
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
inputValue.trim() !== "" &&
|
||||
filterSuggestions(suggestions).length == 0
|
||||
) {
|
||||
e.preventDefault();
|
||||
|
||||
handleSearch(inputValue);
|
||||
}
|
||||
},
|
||||
[inputValue, handleSearch, filterSuggestions, suggestions],
|
||||
);
|
||||
|
||||
// effects
|
||||
|
||||
useEffect(() => {
|
||||
updateSuggestions(inputValue, currentFilterType);
|
||||
}, [currentFilterType, inputValue, updateSuggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (search?.startsWith("similarity:")) {
|
||||
setIsSimilaritySearch(true);
|
||||
setInputValue("");
|
||||
} else {
|
||||
setIsSimilaritySearch(false);
|
||||
setInputValue(search || "");
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
ref={commandRef}
|
||||
className="rounded-md border"
|
||||
>
|
||||
<div className="relative">
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onValueChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
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) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<LuX
|
||||
className="size-4 cursor-pointer text-secondary-foreground"
|
||||
onClick={handleClearInput}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>Clear search</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{(search || Object.keys(filters).length > 0) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<LuStar
|
||||
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>
|
||||
)}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="focus:outline-none"
|
||||
aria-label="Filter information"
|
||||
>
|
||||
<LuFilter
|
||||
aria-label="Filters active"
|
||||
className={cn(
|
||||
"size-4",
|
||||
Object.keys(filters).length > 0
|
||||
? "text-selected"
|
||||
: "text-secondary-foreground",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">How to use text filters</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Filters help you narrow down your search results. Here's how
|
||||
to use them:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 text-sm text-primary-variant">
|
||||
<li>
|
||||
Type a filter name followed by a colon (e.g., "cameras:").
|
||||
</li>
|
||||
<li>
|
||||
Select a value from the suggestions or type your own.
|
||||
</li>
|
||||
<li>
|
||||
Use multiple filters by adding them one after another.
|
||||
</li>
|
||||
<li>
|
||||
Date filters (before: and after:) use{" "}
|
||||
{getIntlDateFormat()} format.
|
||||
</li>
|
||||
<li>Remove filters by clicking the 'x' next to them.</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Example:{" "}
|
||||
<code className="text-primary">
|
||||
cameras:front_door label:person before:01012024
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{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>
|
||||
|
||||
<CommandList
|
||||
className={cn(
|
||||
"scrollbar-container border-t duration-200 animate-in fade-in",
|
||||
inputFocused ? "visible" : "hidden",
|
||||
)}
|
||||
>
|
||||
{(Object.keys(filters).length > 0 || isSimilaritySearch) && (
|
||||
<CommandGroup heading="Active Filters">
|
||||
<div className="my-2 flex flex-wrap gap-2 px-2">
|
||||
{isSimilaritySearch && (
|
||||
<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
|
||||
<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 capitalize text-green-800"
|
||||
>
|
||||
{filterType.replaceAll("_", " ")}:{" "}
|
||||
{value.replaceAll("_", " ")}
|
||||
<button
|
||||
onClick={() =>
|
||||
removeFilter(filterType as FilterType, value)
|
||||
}
|
||||
className="ml-1 focus:outline-none"
|
||||
aria-label={`Remove ${filterType}:${value.replaceAll("_", " ")} 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 capitalize text-green-800"
|
||||
>
|
||||
{filterType}:
|
||||
{filterType === "before" || filterType === "after"
|
||||
? new Date(
|
||||
(filterType === "before"
|
||||
? (filterValues as number) + 1
|
||||
: (filterValues as number)) * 1000,
|
||||
).toLocaleDateString(
|
||||
window.navigator?.language || "en-US",
|
||||
)
|
||||
: 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 &&
|
||||
searchHistoryLoaded &&
|
||||
(searchHistory?.length ?? 0) > 0 && (
|
||||
<CommandGroup heading="Saved 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>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<SaveSearchDialog
|
||||
isOpen={isSaveDialogOpen}
|
||||
onClose={() => setIsSaveDialogOpen(false)}
|
||||
onSave={handleSaveSearch}
|
||||
/>
|
||||
<DeleteSearchDialog
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onClose={() => setIsDeleteDialogOpen(false)}
|
||||
onConfirm={confirmDeleteSearch}
|
||||
searchName={searchToDelete || ""}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
web/src/components/input/SaveSearchDialog.tsx
Normal file
74
web/src/components/input/SaveSearchDialog.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
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";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { toast } from "sonner";
|
||||
|
||||
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("");
|
||||
toast.success(`Search (${searchName.trim()}) has been saved.`, {
|
||||
position: "top-center",
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
onOpenAutoFocus={(e) => {
|
||||
if (isMobile) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save Search</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Provide a name for this saved search.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={searchName}
|
||||
className="text-md"
|
||||
onChange={(e) => setSearchName(e.target.value)}
|
||||
placeholder="Enter a name for your search"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="select"
|
||||
className="mb-2 md:mb-0"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
153
web/src/components/ui/command.tsx
Normal file
153
web/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import * as React from "react";
|
||||
import { type DialogProps } from "@radix-ui/react-dialog";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
interface CommandDialogProps extends DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
CommandShortcut.displayName = "CommandShortcut";
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
Reference in New Issue
Block a user