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