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:
Josh Hawkins 2024-09-18 13:18:16 -05:00 committed by GitHub
parent 5e0d8fe4c7
commit efd1194307
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1580 additions and 40 deletions

379
web/package-lock.json generated
View File

@ -34,6 +34,7 @@
"axios": "^1.7.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.2.0",
@ -3722,6 +3723,384 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.0.tgz",
"integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-dialog": "1.0.5",
"@radix-ui/react-primitive": "1.0.3"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/cmdk/node_modules/@radix-ui/primitive": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz",
"integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-compose-refs": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz",
"integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-context": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz",
"integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-dialog": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz",
"integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-context": "1.0.1",
"@radix-ui/react-dismissable-layer": "1.0.5",
"@radix-ui/react-focus-guards": "1.0.1",
"@radix-ui/react-focus-scope": "1.0.4",
"@radix-ui/react-id": "1.0.1",
"@radix-ui/react-portal": "1.0.4",
"@radix-ui/react-presence": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-slot": "1.0.2",
"@radix-ui/react-use-controllable-state": "1.0.1",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.5.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz",
"integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-use-callback-ref": "1.0.1",
"@radix-ui/react-use-escape-keydown": "1.0.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-focus-guards": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz",
"integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-focus-scope": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz",
"integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-use-callback-ref": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-id": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz",
"integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-use-layout-effect": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-portal": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz",
"integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-primitive": "1.0.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-presence": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz",
"integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-use-layout-effect": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-primitive": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz",
"integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-slot": "1.0.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
"integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz",
"integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz",
"integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-use-callback-ref": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz",
"integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-use-callback-ref": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz",
"integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/react-remove-scroll": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz",
"integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.3",
"react-style-singleton": "^2.2.1",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.0",
"use-sidecar": "^1.1.2"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",

View File

@ -40,6 +40,7 @@
"axios": "^1.7.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.2.0",

View File

@ -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);
});
});

View 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>
);
}

View 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 || ""}
/>
</>
);
}

View 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>
);
}

View 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,
};

View File

@ -0,0 +1,63 @@
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: SavedSearchQuery[],
) => ReturnType<typeof useSuggestions>;
// Define and export the useSuggestions hook
export default function useSuggestions(
filters: SearchFilter,
allSuggestions: { [K in keyof SearchFilter]: string[] },
searchHistory?: SavedSearchQuery[],
) {
const [suggestions, setSuggestions] = useState<string[]>([]);
const updateSuggestions = useCallback(
(value: string, currentFilterType: FilterType | null) => {
if (currentFilterType && currentFilterType in allSuggestions) {
const filterValue = value.split(":").pop() || "";
const currentFilterValues = filters[currentFilterType] || [];
setSuggestions(
allSuggestions[currentFilterType]?.filter(
(item) =>
item.toLowerCase().startsWith(filterValue.toLowerCase()) &&
!(currentFilterValues as (string | number)[]).includes(item),
) ?? [],
);
} else {
const availableFilters = Object.keys(allSuggestions).filter(
(filter) => {
const filterKey = filter as FilterType;
const filterValues = filters[filterKey];
const suggestionValues = allSuggestions[filterKey];
if (!filterValues) return true;
if (
Array.isArray(filterValues) &&
Array.isArray(suggestionValues)
) {
return filterValues.length < suggestionValues.length;
}
return false;
},
);
setSuggestions([
...(searchHistory?.map((search) => search.name) ?? []),
...availableFilters,
"before",
"after",
]);
}
},
[filters, allSuggestions, searchHistory],
);
return {
suggestions,
updateSuggestions,
};
}

View File

@ -20,7 +20,6 @@ export default function Explore() {
// search field handler
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout>();
const [search, setSearch] = useState("");
const [searchTerm, setSearchTerm] = useState("");
@ -50,18 +49,7 @@ export default function Explore() {
});
useEffect(() => {
if (searchTimeout) {
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
setSearchTerm(search);
}, [search]);
const searchQuery: SearchQuery = useMemo(() => {
@ -148,7 +136,7 @@ export default function Explore() {
const { data, size, setSize, isValidating } = useSWRInfinite<SearchResult[]>(
getKey,
{
revalidateFirstPage: false,
revalidateFirstPage: true,
revalidateAll: false,
},
);
@ -277,6 +265,7 @@ export default function Explore() {
isLoading={(isLoadingInitialData || isLoadingMore) ?? true}
setSearch={setSearch}
setSimilaritySearch={(search) => setSearch(`similarity:${search.id}`)}
setSearchFilter={setSearchFilter}
onUpdateFilter={setSearchFilter}
onOpenSearch={onOpenSearch}
loadMore={loadMore}

View File

@ -55,3 +55,10 @@ export type SearchQueryParams = {
};
export type SearchQuery = [string, SearchQueryParams] | null;
export type FilterType = keyof SearchFilter;
export type SavedSearchQuery = {
name: string;
search: string;
filter: SearchFilter | undefined;
};

View File

@ -296,3 +296,75 @@ export function isCurrentHour(timestamp: number) {
return timestamp > now.getTime() / 1000;
}
export const convertLocalDateToTimestamp = (dateString: string): number => {
// Ensure the date string is in the correct format (8 digits)
if (!/^\d{8}$/.test(dateString)) {
return 0;
}
// Determine the local date format
const format = new Intl.DateTimeFormat()
.formatToParts(new Date())
.reduce((acc, part) => {
if (part.type === "day") acc.push("D");
if (part.type === "month") acc.push("M");
if (part.type === "year") acc.push("Y");
return acc;
}, [] as string[])
.join("");
let day: string, month: string, year: string;
// Parse the date string according to the detected format
switch (format) {
case "DMY":
[day, month, year] = [
dateString.slice(0, 2),
dateString.slice(2, 4),
dateString.slice(4),
];
break;
case "MDY":
[month, day, year] = [
dateString.slice(0, 2),
dateString.slice(2, 4),
dateString.slice(4),
];
break;
case "YMD":
[year, month, day] = [
dateString.slice(0, 2),
dateString.slice(2, 4),
dateString.slice(4),
];
break;
default:
return 0;
}
// Create a Date object based on the local timezone
const localDate = new Date(`${year}-${month}-${day}T00:00:00`);
// Check if the date is valid
if (isNaN(localDate.getTime())) {
return 0;
}
// Convert local date to UTC timestamp
const timestamp = localDate.getTime();
return timestamp;
};
export function getIntlDateFormat() {
return new Intl.DateTimeFormat()
.formatToParts(new Date())
.reduce((acc, part) => {
if (part.type === "day") acc.push("DD");
if (part.type === "month") acc.push("MM");
if (part.type === "year") acc.push("YYYY");
return acc;
}, [] as string[])
.join("");
}

View File

@ -3,7 +3,6 @@ import SearchFilterGroup from "@/components/filter/SearchFilterGroup";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import Chip from "@/components/indicators/Chip";
import SearchDetailDialog from "@/components/overlay/detail/SearchDetailDialog";
import { Input } from "@/components/ui/input";
import { Toaster } from "@/components/ui/sonner";
import {
Tooltip,
@ -12,16 +11,17 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import { SearchFilter, SearchResult } from "@/types/search";
import { SearchFilter, SearchResult, SearchSource } from "@/types/search";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isMobileOnly } from "react-device-detect";
import { LuImage, LuSearchX, LuText, LuXCircle } from "react-icons/lu";
import { LuImage, LuSearchX, LuText } from "react-icons/lu";
import useSWR from "swr";
import ExploreView from "../explore/ExploreView";
import useKeyboardListener, {
KeyModifiers,
} from "@/hooks/use-keyboard-listener";
import scrollIntoView from "scroll-into-view-if-needed";
import InputWithTags from "@/components/input/InputWithTags";
type SearchViewProps = {
search: string;
@ -31,6 +31,7 @@ type SearchViewProps = {
isLoading: boolean;
setSearch: (search: string) => void;
setSimilaritySearch: (search: SearchResult) => void;
setSearchFilter: (filter: SearchFilter) => void;
onUpdateFilter: (filter: SearchFilter) => void;
onOpenSearch: (item: SearchResult) => void;
loadMore: () => void;
@ -44,6 +45,7 @@ export default function SearchView({
isLoading,
setSearch,
setSimilaritySearch,
setSearchFilter,
onUpdateFilter,
loadMore,
hasMore,
@ -52,6 +54,69 @@ export default function SearchView({
revalidateOnFocus: false,
});
// suggestions values
const allLabels = useMemo<string[]>(() => {
if (!config) {
return [];
}
const labels = new Set<string>();
const cameras = searchFilter?.cameras || Object.keys(config.cameras);
cameras.forEach((camera) => {
if (camera == "birdseye") {
return;
}
const cameraConfig = config.cameras[camera];
cameraConfig.objects.track.forEach((label) => {
labels.add(label);
});
if (cameraConfig.audio.enabled_in_config) {
cameraConfig.audio.listen.forEach((label) => {
labels.add(label);
});
}
});
return [...labels].sort();
}, [config, searchFilter]);
const { data: allSubLabels } = useSWR("sub_labels");
const allZones = useMemo<string[]>(() => {
if (!config) {
return [];
}
const zones = new Set<string>();
const cameras = searchFilter?.cameras || Object.keys(config.cameras);
cameras.forEach((camera) => {
if (camera == "birdseye") {
return;
}
const cameraConfig = config.cameras[camera];
Object.entries(cameraConfig.zones).map(([name, _]) => {
zones.add(name);
});
});
return [...zones].sort();
}, [config, searchFilter]);
const suggestionsValues = useMemo(
() => ({
cameras: Object.keys(config?.cameras || {}),
labels: Object.values(allLabels || {}),
zones: Object.values(allZones || {}),
sub_labels: allSubLabels,
search_type: ["thumbnail", "description"] as SearchSource[],
}),
[config, allLabels, allZones, allSubLabels],
);
// remove duplicate event ids
const uniqueResults = useMemo(() => {
@ -192,7 +257,7 @@ export default function SearchView({
<div
className={cn(
"flex flex-col items-start space-y-2 pl-2 pr-2 md:mb-2 md:pl-3 lg:h-10 lg:flex-row lg:items-center lg:space-y-0",
"flex flex-col items-start space-y-2 pl-2 pr-2 md:mb-2 md:pl-3 lg:relative lg:h-10 lg:flex-row lg:items-center lg:space-y-0",
config?.semantic_search?.enabled
? "justify-between"
: "justify-center",
@ -200,24 +265,14 @@ export default function SearchView({
)}
>
{config?.semantic_search?.enabled && (
<div
className={cn(
"relative w-full",
hasExistingSearch ? "lg:mr-3 lg:w-1/3" : "lg:ml-[25%] lg:w-1/2",
)}
>
<Input
className="text-md w-full bg-muted pr-10"
placeholder={"Search for a tracked object..."}
value={search}
onChange={(e) => setSearch(e.target.value)}
<div className={cn("z-[41] w-full lg:absolute lg:top-0 lg:w-1/3")}>
<InputWithTags
filters={searchFilter ?? {}}
setFilters={setSearchFilter}
search={search}
setSearch={setSearch}
allSuggestions={suggestionsValues}
/>
{search && (
<LuXCircle
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("")}
/>
)}
</div>
)}