mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-14 15:15:22 +03:00
tweaks
This commit is contained in:
parent
b51af669d4
commit
b75ea9cb45
@ -12,17 +12,27 @@ import { SearchFilter, SearchSource } from "@/types/search";
|
|||||||
|
|
||||||
type FilterType = keyof SearchFilter;
|
type FilterType = keyof SearchFilter;
|
||||||
|
|
||||||
|
const convertMMDDYYToTimestamp = (dateString: string): number => {
|
||||||
|
const match = dateString.match(/^(\d{2})(\d{2})(\d{2})$/);
|
||||||
|
if (!match) return 0;
|
||||||
|
|
||||||
|
const [, month, day, year] = match;
|
||||||
|
const date = new Date(`20${year}-${month}-${day}T00:00:00Z`);
|
||||||
|
return date.getTime();
|
||||||
|
};
|
||||||
|
|
||||||
// Custom hook for managing suggestions
|
// Custom hook for managing suggestions
|
||||||
const useSuggestions = (
|
const useSuggestions = (
|
||||||
filters: SearchFilter,
|
filters: SearchFilter,
|
||||||
allSuggestions: { [K in keyof SearchFilter]: string[] },
|
allSuggestions: { [K in keyof SearchFilter]: string[] },
|
||||||
|
searchHistory: string[],
|
||||||
) => {
|
) => {
|
||||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
||||||
|
|
||||||
const updateSuggestions = useCallback(
|
const updateSuggestions = useCallback(
|
||||||
(value: string, currentFilterType: FilterType | null) => {
|
(value: string, currentFilterType: FilterType | null) => {
|
||||||
if (currentFilterType) {
|
if (currentFilterType && currentFilterType in allSuggestions) {
|
||||||
const filterValue = value.split(":").pop() || "";
|
const filterValue = value.split(":").pop() || "";
|
||||||
const currentFilterValues = filters[currentFilterType] || [];
|
const currentFilterValues = filters[currentFilterType] || [];
|
||||||
setSuggestions(
|
setSuggestions(
|
||||||
@ -49,10 +59,15 @@ const useSuggestions = (
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
setSuggestions([...availableFilters]);
|
setSuggestions([
|
||||||
|
...searchHistory,
|
||||||
|
...availableFilters,
|
||||||
|
"before",
|
||||||
|
"after",
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[filters, allSuggestions],
|
[filters, allSuggestions, searchHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -66,6 +81,8 @@ const useSuggestions = (
|
|||||||
type InputWithTagsProps = {
|
type InputWithTagsProps = {
|
||||||
filters: SearchFilter;
|
filters: SearchFilter;
|
||||||
setFilters: (filter: SearchFilter) => void;
|
setFilters: (filter: SearchFilter) => void;
|
||||||
|
search: string;
|
||||||
|
setSearch: (search: string) => void;
|
||||||
allSuggestions: {
|
allSuggestions: {
|
||||||
[K in keyof SearchFilter]: string[];
|
[K in keyof SearchFilter]: string[];
|
||||||
};
|
};
|
||||||
@ -74,9 +91,11 @@ type InputWithTagsProps = {
|
|||||||
export default function InputWithTags({
|
export default function InputWithTags({
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
allSuggestions,
|
allSuggestions,
|
||||||
}: InputWithTagsProps) {
|
}: InputWithTagsProps) {
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState(search || "");
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>(
|
const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>(
|
||||||
@ -97,7 +116,7 @@ export default function InputWithTags({
|
|||||||
selectedSuggestionIndex,
|
selectedSuggestionIndex,
|
||||||
setSelectedSuggestionIndex,
|
setSelectedSuggestionIndex,
|
||||||
updateSuggestions,
|
updateSuggestions,
|
||||||
} = useSuggestions(filters, allSuggestions);
|
} = useSuggestions(filters, allSuggestions, searchHistory);
|
||||||
|
|
||||||
const resetSuggestions = useCallback(
|
const resetSuggestions = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
@ -108,7 +127,7 @@ export default function InputWithTags({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const removeFilter = useCallback(
|
const removeFilter = useCallback(
|
||||||
(filterType: keyof SearchFilter, filterValue: string | number) => {
|
(filterType: FilterType, filterValue: string | number) => {
|
||||||
const newFilters = { ...filters };
|
const newFilters = { ...filters };
|
||||||
if (Array.isArray(newFilters[filterType])) {
|
if (Array.isArray(newFilters[filterType])) {
|
||||||
(newFilters[filterType] as string[]) = (
|
(newFilters[filterType] as string[]) = (
|
||||||
@ -154,13 +173,21 @@ export default function InputWithTags({
|
|||||||
|
|
||||||
const createFilter = useCallback(
|
const createFilter = useCallback(
|
||||||
(type: FilterType, value: string) => {
|
(type: FilterType, value: string) => {
|
||||||
if (allSuggestions[type]?.includes(value)) {
|
if (
|
||||||
|
allSuggestions[type as keyof SearchFilter]?.includes(value) ||
|
||||||
|
type === "before" ||
|
||||||
|
type === "after"
|
||||||
|
) {
|
||||||
const newFilters = { ...filters };
|
const newFilters = { ...filters };
|
||||||
|
let timestamp = 0;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "before":
|
case "before":
|
||||||
case "after":
|
case "after":
|
||||||
newFilters[type] = parseFloat(value);
|
timestamp = convertMMDDYYToTimestamp(value);
|
||||||
|
if (timestamp > 0) {
|
||||||
|
newFilters[type] = timestamp;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "search_type":
|
case "search_type":
|
||||||
if (!newFilters.search_type) newFilters.search_type = [];
|
if (!newFilters.search_type) newFilters.search_type = [];
|
||||||
@ -197,13 +224,43 @@ export default function InputWithTags({
|
|||||||
[filters, setFilters, allSuggestions],
|
[filters, setFilters, allSuggestions],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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{6}$/))
|
||||||
|
) {
|
||||||
|
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(
|
const handleInputChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setInputValue(value);
|
setInputValue(value);
|
||||||
|
|
||||||
const words = value.split(" ");
|
const words = value.split(/[\s,]+/);
|
||||||
const currentWord = words[words.length - 1];
|
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
|
// Check if the current word is a filter type
|
||||||
const filterTypeMatch = currentWord.match(/^(\w+):(.*)$/);
|
const filterTypeMatch = currentWord.match(/^(\w+):(.*)$/);
|
||||||
@ -215,19 +272,25 @@ export default function InputWithTags({
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Check if filter type is valid
|
// Check if filter type is valid
|
||||||
if (filterType in allSuggestions) {
|
if (
|
||||||
|
filterType in allSuggestions ||
|
||||||
|
filterType === "before" ||
|
||||||
|
filterType === "after"
|
||||||
|
) {
|
||||||
setCurrentFilterType(filterType);
|
setCurrentFilterType(filterType);
|
||||||
updateSuggestions(filterValue, filterType);
|
|
||||||
|
|
||||||
// If filter value is valid, apply the filter
|
if (filterType === "before" || filterType === "after") {
|
||||||
if (allSuggestions[filterType]?.includes(filterValue.trim())) {
|
// For before and after, we don't need to update suggestions
|
||||||
createFilter(filterType, filterValue.trim());
|
if (filterValue.match(/^\d{6}$/)) {
|
||||||
|
handleFilterCreation(filterType, filterValue);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateSuggestions(filterValue, filterType);
|
||||||
|
|
||||||
// Remove the applied filter from the input
|
// Check if the last character is a space or comma
|
||||||
setInputValue((prev) =>
|
if (isLastCharSpaceOrComma) {
|
||||||
prev.replace(`${filterType}:${filterValue}`, "").trim(),
|
handleFilterCreation(filterType, filterValue);
|
||||||
);
|
}
|
||||||
setCurrentFilterType(null);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
resetSuggestions(value);
|
resetSuggestions(value);
|
||||||
@ -244,7 +307,7 @@ export default function InputWithTags({
|
|||||||
updateSuggestions,
|
updateSuggestions,
|
||||||
resetSuggestions,
|
resetSuggestions,
|
||||||
allSuggestions,
|
allSuggestions,
|
||||||
createFilter,
|
handleFilterCreation,
|
||||||
setSelectedSuggestionIndex,
|
setSelectedSuggestionIndex,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -253,13 +316,20 @@ export default function InputWithTags({
|
|||||||
setShowSuggestions(true);
|
setShowSuggestions(true);
|
||||||
setShowFilters(true);
|
setShowFilters(true);
|
||||||
updateSuggestions(inputValue, currentFilterType);
|
updateSuggestions(inputValue, currentFilterType);
|
||||||
}, [inputValue, currentFilterType, updateSuggestions]);
|
setSelectedSuggestionIndex(-1);
|
||||||
|
}, [
|
||||||
|
inputValue,
|
||||||
|
currentFilterType,
|
||||||
|
updateSuggestions,
|
||||||
|
setSelectedSuggestionIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleClearInput = useCallback(() => {
|
const handleClearInput = useCallback(() => {
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
setFilters({});
|
setFilters({});
|
||||||
setCurrentFilterType(null);
|
setCurrentFilterType(null);
|
||||||
updateSuggestions("", null);
|
updateSuggestions("", null);
|
||||||
|
setShowFilters(false);
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
}, [setFilters, updateSuggestions]);
|
}, [setFilters, updateSuggestions]);
|
||||||
|
|
||||||
@ -268,9 +338,10 @@ export default function InputWithTags({
|
|||||||
if (!containerRef.current?.contains(document.activeElement)) {
|
if (!containerRef.current?.contains(document.activeElement)) {
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
setShowFilters(false);
|
setShowFilters(false);
|
||||||
|
setSelectedSuggestionIndex(-1);
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
}, []);
|
}, [setSelectedSuggestionIndex]);
|
||||||
|
|
||||||
const toggleFilters = useCallback(() => {
|
const toggleFilters = useCallback(() => {
|
||||||
setShowFilters((prev) => !prev);
|
setShowFilters((prev) => !prev);
|
||||||
@ -313,10 +384,13 @@ export default function InputWithTags({
|
|||||||
handleSuggestionClick(suggestions[selectedSuggestionIndex]);
|
handleSuggestionClick(suggestions[selectedSuggestionIndex]);
|
||||||
} else if (e.key === "Enter" && currentFilterType) {
|
} else if (e.key === "Enter" && currentFilterType) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const currentWord = inputValue.split(" ").pop() || "";
|
const currentWord = inputValue.split(/[\s,]+/).pop() || "";
|
||||||
if (allSuggestions[currentFilterType]?.includes(currentWord)) {
|
handleFilterCreation(currentFilterType, currentWord);
|
||||||
createFilter(currentFilterType, currentWord);
|
} else if (e.key === "Enter" && !currentFilterType) {
|
||||||
}
|
e.preventDefault();
|
||||||
|
setSearch(inputValue);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
handleInputBlur();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@ -325,12 +399,17 @@ export default function InputWithTags({
|
|||||||
handleSuggestionClick,
|
handleSuggestionClick,
|
||||||
currentFilterType,
|
currentFilterType,
|
||||||
inputValue,
|
inputValue,
|
||||||
createFilter,
|
handleFilterCreation,
|
||||||
setSelectedSuggestionIndex,
|
setSelectedSuggestionIndex,
|
||||||
allSuggestions,
|
setSearch,
|
||||||
|
handleInputBlur,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInputValue(search || "");
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef}>
|
<div ref={containerRef}>
|
||||||
<div className="relative my-2">
|
<div className="relative my-2">
|
||||||
@ -378,7 +457,7 @@ export default function InputWithTags({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(showFilters || showSuggestions) && (
|
{(showFilters || showSuggestions) && (
|
||||||
<div className="absolute left-0 top-11 z-[100] w-full rounded-md border border-t-0 border-gray-200 bg-background p-2 text-primary shadow-md">
|
<div className="absolute left-0 top-12 z-[100] w-full rounded-md border border-t-0 border-secondary-foreground bg-background p-2 text-primary shadow-md">
|
||||||
{showFilters && Object.keys(filters).length > 0 && (
|
{showFilters && Object.keys(filters).length > 0 && (
|
||||||
<div ref={filterRef} className="my-2 flex flex-wrap gap-2">
|
<div ref={filterRef} className="my-2 flex flex-wrap gap-2">
|
||||||
{Object.entries(filters).map(([filterType, filterValues]) =>
|
{Object.entries(filters).map(([filterType, filterValues]) =>
|
||||||
@ -405,12 +484,15 @@ export default function InputWithTags({
|
|||||||
key={filterType}
|
key={filterType}
|
||||||
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800"
|
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800"
|
||||||
>
|
>
|
||||||
{filterType}:{filterValues}
|
{filterType}:
|
||||||
|
{filterType === "before" || filterType === "after"
|
||||||
|
? new Date(filterValues as number).toLocaleDateString()
|
||||||
|
: filterValues}
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
removeFilter(
|
removeFilter(
|
||||||
filterType as FilterType,
|
filterType as FilterType,
|
||||||
filterValues as string,
|
filterValues as string | number,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="ml-1 focus:outline-none"
|
className="ml-1 focus:outline-none"
|
||||||
@ -432,46 +514,48 @@ export default function InputWithTags({
|
|||||||
>
|
>
|
||||||
{!currentFilterType && searchHistory.length > 0 && (
|
{!currentFilterType && searchHistory.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<h3 className="px-2 py-1 text-xs font-semibold text-gray-500">
|
<h3 className="px-2 py-1 text-xs font-semibold text-secondary-foreground">
|
||||||
Previous Searches
|
Previous Searches
|
||||||
</h3>
|
</h3>
|
||||||
{searchHistory.map((suggestion, index) => (
|
{searchHistory.map((suggestion, index) => (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
className={`w-full rounded px-2 py-1 text-left text-sm ${
|
className={`w-full rounded px-2 py-1 text-left text-sm text-primary ${
|
||||||
index === selectedSuggestionIndex
|
index === selectedSuggestionIndex
|
||||||
? "bg-blue-100"
|
? "bg-accent text-accent-foreground"
|
||||||
: "hover:bg-gray-100"
|
: "hover:bg-accent hover:text-accent-foreground"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleSuggestionClick(suggestion)}
|
onClick={() => handleSuggestionClick(suggestion)}
|
||||||
|
onMouseEnter={() => setSelectedSuggestionIndex(index)}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={index === selectedSuggestionIndex}
|
aria-selected={index === selectedSuggestionIndex}
|
||||||
>
|
>
|
||||||
{suggestion}
|
{suggestion}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<div className="my-1 border-t border-gray-200" />
|
<div className="my-1 border-t border-secondary-foreground" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<h3 className="px-2 py-1 text-xs font-semibold text-gray-500">
|
<h3 className="px-2 py-1 text-xs font-semibold text-secondary-foreground">
|
||||||
{currentFilterType ? "Filter Values" : "Filters"}
|
{currentFilterType ? "Filter Values" : "Filters"}
|
||||||
</h3>
|
</h3>
|
||||||
{suggestions
|
{suggestions
|
||||||
// .filter((item) => !searchHistory.includes(item))
|
.filter((item) => !searchHistory.includes(item))
|
||||||
.map((suggestion, index) => (
|
.map((suggestion, index) => (
|
||||||
<button
|
<button
|
||||||
key={index + (currentFilterType ? 0 : searchHistory.length)}
|
key={index + searchHistory.length}
|
||||||
className={`w-full rounded px-2 py-1 text-left text-sm ${
|
className={`w-full rounded px-2 py-1 text-left text-sm text-primary ${
|
||||||
index + (currentFilterType ? 0 : searchHistory.length) ===
|
index + searchHistory.length === selectedSuggestionIndex
|
||||||
selectedSuggestionIndex
|
? "bg-accent text-accent-foreground"
|
||||||
? "bg-blue-100"
|
: "hover:bg-accent hover:text-accent-foreground"
|
||||||
: "hover:bg-gray-100"
|
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleSuggestionClick(suggestion)}
|
onClick={() => handleSuggestionClick(suggestion)}
|
||||||
|
onMouseEnter={() =>
|
||||||
|
setSelectedSuggestionIndex(index + searchHistory.length)
|
||||||
|
}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={
|
aria-selected={
|
||||||
index + (currentFilterType ? 0 : searchHistory.length) ===
|
index + searchHistory.length === selectedSuggestionIndex
|
||||||
selectedSuggestionIndex
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{suggestion}
|
{suggestion}
|
||||||
|
|||||||
@ -20,7 +20,6 @@ export default function Explore() {
|
|||||||
|
|
||||||
// search field handler
|
// search field handler
|
||||||
|
|
||||||
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout>();
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
@ -50,18 +49,7 @@ export default function Explore() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchTimeout) {
|
setSearchTerm(search);
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSearchTimeout(
|
|
||||||
setTimeout(() => {
|
|
||||||
setSearchTimeout(undefined);
|
|
||||||
setSearchTerm(search);
|
|
||||||
}, 750),
|
|
||||||
);
|
|
||||||
// we only want to update the searchTerm when search changes
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
const searchQuery: SearchQuery = useMemo(() => {
|
const searchQuery: SearchQuery = useMemo(() => {
|
||||||
|
|||||||
@ -219,13 +219,15 @@ export default function SearchView({
|
|||||||
<InputWithTags
|
<InputWithTags
|
||||||
filters={searchFilter ?? {}}
|
filters={searchFilter ?? {}}
|
||||||
setFilters={setSearchFilter}
|
setFilters={setSearchFilter}
|
||||||
|
search={search}
|
||||||
|
setSearch={setSearch}
|
||||||
allSuggestions={{
|
allSuggestions={{
|
||||||
cameras: ["ptzcam", "doorbellcam"],
|
cameras: ["ptzcam", "doorbellcam"],
|
||||||
labels: ["person", "car"],
|
labels: ["person", "car"],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{search && (
|
{search && false && (
|
||||||
<LuXCircle
|
<LuXCircle
|
||||||
className="absolute right-2 top-1/2 h-5 w-5 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-primary"
|
className="absolute right-2 top-1/2 h-5 w-5 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-primary"
|
||||||
onClick={() => setSearch("")}
|
onClick={() => setSearch("")}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user