Implement score slider including keyboard input

This commit is contained in:
Nicolas Mowen 2024-04-13 08:43:15 -06:00
parent 0b14f3bd9f
commit 4870f976d4
2 changed files with 85 additions and 74 deletions

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider" import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Slider = React.forwardRef< const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>, React.ElementRef<typeof SliderPrimitive.Root>,
@ -11,7 +11,7 @@ const Slider = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full touch-none select-none items-center", "relative flex w-full touch-none select-none items-center",
className className,
)} )}
{...props} {...props}
> >
@ -20,8 +20,8 @@ const Slider = React.forwardRef<
</SliderPrimitive.Track> </SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" /> <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root> </SliderPrimitive.Root>
)) ));
Slider.displayName = SliderPrimitive.Root.displayName Slider.displayName = SliderPrimitive.Root.displayName;
const VolumeSlider = React.forwardRef< const VolumeSlider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>, React.ElementRef<typeof SliderPrimitive.Root>,
@ -56,7 +56,7 @@ const NoThumbSlider = React.forwardRef<
{...props} {...props}
> >
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full"> <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="absolute h-full bg-blue-500" /> <SliderPrimitive.Range className="absolute h-full bg-selected" />
</SliderPrimitive.Track> </SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-16 rounded-full bg-transparent -translate-y-[50%] ring-offset-transparent focus-visible:outline-none focus-visible:ring-transparent disabled:pointer-events-none disabled:opacity-50 cursor-col-resize" /> <SliderPrimitive.Thumb className="block h-4 w-16 rounded-full bg-transparent -translate-y-[50%] ring-offset-transparent focus-visible:outline-none focus-visible:ring-transparent disabled:pointer-events-none disabled:opacity-50 cursor-col-resize" />
</SliderPrimitive.Root> </SliderPrimitive.Root>
@ -71,16 +71,17 @@ const DualThumbSlider = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full touch-none select-none items-center", "relative flex w-full touch-none select-none items-center",
className className,
)} )}
{...props} {...props}
> >
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary"> <SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-selected/60">
<SliderPrimitive.Range className="absolute h-full bg-primary" /> <SliderPrimitive.Range className="absolute h-full bg-selected" />
</SliderPrimitive.Track> </SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" /> <SliderPrimitive.Thumb className="block size-3 rounded-full bg-selected transition-colors cursor-col-resize disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block size-3 rounded-full bg-selected transition-colors cursor-col-resize disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root> </SliderPrimitive.Root>
)) ));
DualThumbSlider.displayName = SliderPrimitive.Root.displayName DualThumbSlider.displayName = SliderPrimitive.Root.displayName;
export { DualThumbSlider, Slider, NoThumbSlider, VolumeSlider } export { DualThumbSlider, Slider, NoThumbSlider, VolumeSlider };

View File

@ -1,5 +1,4 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import FilterCheckBox from "@/components/filter/FilterCheckBox";
import { import {
CamerasFilterButton, CamerasFilterButton,
GeneralFilterContent, GeneralFilterContent,
@ -17,10 +16,11 @@ import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { DualThumbSlider } from "@/components/ui/slider";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios"; import axios from "axios";
@ -53,6 +53,8 @@ export default function SubmitPlus() {
is_submitted: 0, is_submitted: 0,
cameras: selectedCameras ? selectedCameras.join(",") : null, cameras: selectedCameras ? selectedCameras.join(",") : null,
labels: selectedLabels ? selectedLabels.join(",") : null, labels: selectedLabels ? selectedLabels.join(",") : null,
min_score: scoreRange ? scoreRange[0] : null,
max_score: scoreRange ? scoreRange[1] : null,
}, },
]); ]);
const [upload, setUpload] = useState<Event>(); const [upload, setUpload] = useState<Event>();
@ -112,9 +114,11 @@ export default function SubmitPlus() {
<div className="size-full flex flex-col"> <div className="size-full flex flex-col">
<PlusFilterGroup <PlusFilterGroup
selectedCameras={selectedCameras} selectedCameras={selectedCameras}
setSelectedCameras={setSelectedCameras}
selectedLabels={selectedLabels} selectedLabels={selectedLabels}
selectedScoreRange={scoreRange}
setSelectedCameras={setSelectedCameras}
setSelectedLabels={setSelectedLabels} setSelectedLabels={setSelectedLabels}
setSelectedScoreRange={setScoreRange}
/> />
<div className="size-full flex flex-1 flex-wrap content-start gap-2 md:gap-4 overflow-y-auto no-scrollbar"> <div className="size-full flex flex-1 flex-wrap content-start gap-2 md:gap-4 overflow-y-auto no-scrollbar">
<div className="w-full p-2 grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2"> <div className="w-full p-2 grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
@ -184,15 +188,19 @@ const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"];
type PlusFilterGroupProps = { type PlusFilterGroupProps = {
selectedCameras: string[] | undefined; selectedCameras: string[] | undefined;
setSelectedCameras: (cameras: string[] | undefined) => void;
selectedLabels: string[] | undefined; selectedLabels: string[] | undefined;
selectedScoreRange: number[] | undefined;
setSelectedCameras: (cameras: string[] | undefined) => void;
setSelectedLabels: (cameras: string[] | undefined) => void; setSelectedLabels: (cameras: string[] | undefined) => void;
setSelectedScoreRange: (range: number[] | undefined) => void;
}; };
function PlusFilterGroup({ function PlusFilterGroup({
selectedCameras, selectedCameras,
setSelectedCameras,
selectedLabels, selectedLabels,
selectedScoreRange,
setSelectedCameras,
setSelectedLabels, setSelectedLabels,
setSelectedScoreRange,
}: PlusFilterGroupProps) { }: PlusFilterGroupProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -226,12 +234,12 @@ function PlusFilterGroup({
const [open, setOpen] = useState<"none" | "camera" | "label" | "score">( const [open, setOpen] = useState<"none" | "camera" | "label" | "score">(
"none", "none",
); );
const [currentCameras, setCurrentCameras] = useState<string[] | undefined>(
undefined,
);
const [currentLabels, setCurrentLabels] = useState<string[] | undefined>( const [currentLabels, setCurrentLabels] = useState<string[] | undefined>(
undefined, undefined,
); );
const [currentScoreRange, setCurrentScoreRange] = useState<
number[] | undefined
>(undefined);
const Menu = isMobile ? Drawer : DropdownMenu; const Menu = isMobile ? Drawer : DropdownMenu;
const Trigger = isMobile ? DrawerTrigger : DropdownMenuTrigger; const Trigger = isMobile ? DrawerTrigger : DropdownMenuTrigger;
@ -284,77 +292,79 @@ function PlusFilterGroup({
<Menu <Menu
open={open == "score"} open={open == "score"}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) {
setCurrentCameras(selectedCameras);
}
setOpen(open ? "score" : "none"); setOpen(open ? "score" : "none");
}} }}
> >
<Trigger asChild> <Trigger asChild>
<Button size="sm" className="flex items-center gap-2 capitalize"> <Button
<PiSlidersHorizontalFill className="text-secondary-foreground" /> className="flex items-center gap-2 capitalize"
size="sm"
variant={selectedScoreRange == undefined ? "default" : "select"}
>
<PiSlidersHorizontalFill
className={`${selectedScoreRange == undefined ? "text-secondary-foreground" : "text-selected-foreground"}`}
/>
<div className="hidden md:block text-primary"> <div className="hidden md:block text-primary">
{selectedCameras == undefined {selectedScoreRange == undefined
? "All Cameras" ? "Score Range"
: `${selectedCameras.length} Cameras`} : `${selectedScoreRange[0] * 100}% - ${selectedScoreRange[1] * 100}%`}
</div> </div>
</Button> </Button>
</Trigger> </Trigger>
<Content className={isMobile ? "max-h-[75dvh]" : ""}> <Content
<DropdownMenuLabel className="flex justify-center"> className={`min-w-80 p-2 flex flex-col justify-center ${isMobile ? "gap-2 *:max-h-[75dvh]" : ""}`}
Filter Cameras >
</DropdownMenuLabel> <div className="flex items-center gap-1">
<DropdownMenuSeparator /> <Input
<FilterCheckBox className="w-12"
isChecked={currentCameras == undefined} inputMode="numeric"
label="All Cameras" value={Math.round((currentScoreRange?.at(0) ?? 0.5) * 100)}
onCheckedChange={(isChecked) => { onChange={(e) =>
if (isChecked) { setCurrentScoreRange([
setCurrentCameras(undefined); parseInt(e.target.value) / 100.0,
currentScoreRange?.at(1) ?? 1.0,
])
} }
}}
/> />
<DropdownMenuSeparator /> <DualThumbSlider
<div className={isMobile ? "h-auto overflow-y-auto" : ""}> className="w-full"
{allCameras.map((item) => ( min={0.5}
<FilterCheckBox max={1.0}
key={item} step={0.01}
isChecked={currentCameras?.includes(item) ?? false} value={currentScoreRange ?? [0.5, 1.0]}
label={item.replaceAll("_", " ")} onValueChange={setCurrentScoreRange}
onCheckedChange={(isChecked) => { />
if (isChecked) { <Input
const updatedCameras = currentCameras className="w-12"
? [...currentCameras] inputMode="numeric"
: []; value={Math.round((currentScoreRange?.at(1) ?? 1.0) * 100)}
onChange={(e) =>
updatedCameras.push(item); setCurrentScoreRange([
setCurrentCameras(updatedCameras); currentScoreRange?.at(0) ?? 0.5,
} else { parseInt(e.target.value) / 100.0,
const updatedCameras = currentCameras ])
? [...currentCameras] }
: [];
// can not deselect the last item
if (updatedCameras.length > 1) {
updatedCameras.splice(updatedCameras.indexOf(item), 1);
setCurrentCameras(updatedCameras);
}
}
}}
/> />
))}
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="flex justify-center items-center"> <div className="p-2 flex justify-evenly items-center">
<Button <Button
variant="select" variant="select"
onClick={() => { onClick={() => {
setSelectedCameras(currentCameras); setSelectedScoreRange(currentScoreRange);
setOpen("none"); setOpen("none");
}} }}
> >
Apply Apply
</Button> </Button>
<Button
onClick={() => {
setCurrentScoreRange(undefined);
setSelectedScoreRange(undefined);
}}
>
Reset
</Button>
</div> </div>
</Content> </Content>
</Menu> </Menu>