diff --git a/web/package-lock.json b/web/package-lock.json index de42a1ac7..77f0bfc0f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index bb15ea4b8..f8f4bc306 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx index 0b0b3278e..81090958e 100644 --- a/web/src/components/filter/SearchFilterGroup.tsx +++ b/web/src/components/filter/SearchFilterGroup.tsx @@ -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); }); }); diff --git a/web/src/components/input/DeleteSearchDialog.tsx b/web/src/components/input/DeleteSearchDialog.tsx new file mode 100644 index 000000000..0aaabdde5 --- /dev/null +++ b/web/src/components/input/DeleteSearchDialog.tsx @@ -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 ( + + + + Are you sure? + + This will permanently delete the saved search "{searchName}". + + + + Cancel + + Delete + + + + + ); +} diff --git a/web/src/components/input/InputWithTags.tsx b/web/src/components/input/InputWithTags.tsx new file mode 100644 index 000000000..e4b02fa73 --- /dev/null +++ b/web/src/components/input/InputWithTags.tsx @@ -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( + null, + ); + const [inputFocused, setInputFocused] = useState(false); + const [isSimilaritySearch, setIsSimilaritySearch] = useState(false); + const inputRef = useRef(null); + const commandRef = useRef(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(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) => { + 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 ( + <> + +
+ +
+ {(search || Object.keys(filters).length > 0) && ( + + + + + + Clear search + + + )} + + {(search || Object.keys(filters).length > 0) && ( + + + + + + Save search + + + )} + + {isSimilaritySearch && ( + + + + + + Similarity search active + + + )} + + + + + + +
+

How to use text filters

+

+ Filters help you narrow down your search results. Here's how + to use them: +

+
    +
  • + Type a filter name followed by a colon (e.g., "cameras:"). +
  • +
  • + Select a value from the suggestions or type your own. +
  • +
  • + Use multiple filters by adding them one after another. +
  • +
  • + Date filters (before: and after:) use{" "} + {getIntlDateFormat()} format. +
  • +
  • Remove filters by clicking the 'x' next to them.
  • +
+

+ Example:{" "} + + cameras:front_door label:person before:01012024 + +

+
+
+
+ + {inputFocused ? ( + setInputFocused(false)} + className="size-4 cursor-pointer text-secondary-foreground" + /> + ) : ( + setInputFocused(true)} + className="size-4 cursor-pointer text-secondary-foreground" + /> + )} +
+
+ + + {(Object.keys(filters).length > 0 || isSimilaritySearch) && ( + +
+ {isSimilaritySearch && ( + + Similarity Search + + + )} + {Object.entries(filters).map(([filterType, filterValues]) => + Array.isArray(filterValues) ? ( + filterValues.map((value, index) => ( + + {filterType.replaceAll("_", " ")}:{" "} + {value.replaceAll("_", " ")} + + + )) + ) : ( + + {filterType}: + {filterType === "before" || filterType === "after" + ? new Date( + (filterType === "before" + ? (filterValues as number) + 1 + : (filterValues as number)) * 1000, + ).toLocaleDateString( + window.navigator?.language || "en-US", + ) + : filterValues} + + + ), + )} +
+
+ )} + + {!currentFilterType && + !inputValue && + searchHistoryLoaded && + (searchHistory?.length ?? 0) > 0 && ( + + {searchHistory?.map((suggestion, index) => ( + handleLoadSavedSearch(suggestion.name)} + > + {suggestion.name} + + + + + + Delete saved search + + + + ))} + + )} + + {filterSuggestions(suggestions) + .filter( + (item) => + !searchHistory?.some((history) => history.name === item), + ) + .map((suggestion, index) => ( + handleSuggestionClick(suggestion)} + > + {suggestion} + + ))} + +
+
+ setIsSaveDialogOpen(false)} + onSave={handleSaveSearch} + /> + setIsDeleteDialogOpen(false)} + onConfirm={confirmDeleteSearch} + searchName={searchToDelete || ""} + /> + + ); +} diff --git a/web/src/components/input/SaveSearchDialog.tsx b/web/src/components/input/SaveSearchDialog.tsx new file mode 100644 index 000000000..c5bf29001 --- /dev/null +++ b/web/src/components/input/SaveSearchDialog.tsx @@ -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 ( + + { + if (isMobile) { + e.preventDefault(); + } + }} + > + + Save Search + + Provide a name for this saved search. + + + setSearchName(e.target.value)} + placeholder="Enter a name for your search" + /> + + + + + + + ); +} diff --git a/web/src/components/ui/command.tsx b/web/src/components/ui/command.tsx new file mode 100644 index 000000000..64be5e01a --- /dev/null +++ b/web/src/components/ui/command.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/web/src/hooks/use-suggestions.ts b/web/src/hooks/use-suggestions.ts new file mode 100644 index 000000000..9222c866c --- /dev/null +++ b/web/src/hooks/use-suggestions.ts @@ -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; + +// Define and export the useSuggestions hook +export default function useSuggestions( + filters: SearchFilter, + allSuggestions: { [K in keyof SearchFilter]: string[] }, + searchHistory?: SavedSearchQuery[], +) { + const [suggestions, setSuggestions] = useState([]); + + 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, + }; +} diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index 21ab01f71..32c2e1579 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -20,7 +20,6 @@ export default function Explore() { // search field handler - const [searchTimeout, setSearchTimeout] = useState(); 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( 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} diff --git a/web/src/types/search.ts b/web/src/types/search.ts index 9aaf35849..141d3a72e 100644 --- a/web/src/types/search.ts +++ b/web/src/types/search.ts @@ -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; +}; diff --git a/web/src/utils/dateUtil.ts b/web/src/utils/dateUtil.ts index 3699d63f7..fee8bd195 100644 --- a/web/src/utils/dateUtil.ts +++ b/web/src/utils/dateUtil.ts @@ -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(""); +} diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index e031d211c..b0ce92bec 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -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(() => { + if (!config) { + return []; + } + + const labels = new Set(); + 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(() => { + if (!config) { + return []; + } + + const zones = new Set(); + 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({
{config?.semantic_search?.enabled && ( -
- setSearch(e.target.value)} +
+ - {search && ( - setSearch("")} - /> - )}
)}