mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Mobile recordings redesign (#10711)
* Only show back button text on desktop * Add mobile camera drawer to separate component * Use bottom sheet for export on mobile * Add intermediary mobile bottom sheet * fix filter * Fix mobile layout jumping * Fix desktop vertical camera view * Fix horizontal camera list * Add overlay instead of using same button for timeline exports * Don't use native hls for now * Fix export bottom sheet * Fix scrolling * Simplify checks * Adjust hls compat approach * Fix events shadow * Make corners consistent * Make corners consistent * fix max drawer height * Use separate buttons for export control * Add icons * Fix list views * Fix new items to review * bottom padding on bottom sheets * bottom padding on bottom sheets
This commit is contained in:
		
							parent
							
								
									559e6910c4
								
							
						
					
					
						commit
						4e800e19ff
					
				@ -108,7 +108,7 @@ export default function ReviewFilterGroup({
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex justify-center">
 | 
			
		||||
    <div className="flex justify-center gap-2">
 | 
			
		||||
      {filters.includes("cameras") && (
 | 
			
		||||
        <CamerasFilterButton
 | 
			
		||||
          allCameras={filterValues.cameras}
 | 
			
		||||
@ -171,8 +171,12 @@ function CamerasFilterButton({
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const trigger = (
 | 
			
		||||
    <Button size="sm" className="mx-1 capitalize" variant="secondary">
 | 
			
		||||
      <FaVideo className="md:mr-[10px] text-muted-foreground" />
 | 
			
		||||
    <Button
 | 
			
		||||
      className="flex items-center gap-2 capitalize"
 | 
			
		||||
      variant="secondary"
 | 
			
		||||
      size="sm"
 | 
			
		||||
    >
 | 
			
		||||
      <FaVideo className="text-muted-foreground" />
 | 
			
		||||
      <div className="hidden md:block">
 | 
			
		||||
        {selectedCameras == undefined
 | 
			
		||||
          ? "All Cameras"
 | 
			
		||||
@ -319,8 +323,8 @@ function CalendarFilterButton({
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const trigger = (
 | 
			
		||||
    <Button size="sm" className="mx-1" variant="secondary">
 | 
			
		||||
      <FaCalendarAlt className="md:mr-[10px] text-muted-foreground" />
 | 
			
		||||
    <Button size="sm" className="flex items-center gap-2" variant="secondary">
 | 
			
		||||
      <FaCalendarAlt className="text-muted-foreground" />
 | 
			
		||||
      <div className="hidden md:block">
 | 
			
		||||
        {day == undefined ? "Last 24 Hours" : selectedDate}
 | 
			
		||||
      </div>
 | 
			
		||||
@ -367,15 +371,15 @@ function CalendarFilterButton({
 | 
			
		||||
type GeneralFilterButtonProps = {
 | 
			
		||||
  allLabels: string[];
 | 
			
		||||
  selectedLabels: string[] | undefined;
 | 
			
		||||
  updateLabelFilter: (labels: string[] | undefined) => void;
 | 
			
		||||
  showReviewed?: 0 | 1;
 | 
			
		||||
  updateLabelFilter: (labels: string[] | undefined) => void;
 | 
			
		||||
  setShowReviewed: (reviewed?: 0 | 1) => void;
 | 
			
		||||
};
 | 
			
		||||
function GeneralFilterButton({
 | 
			
		||||
  allLabels,
 | 
			
		||||
  selectedLabels,
 | 
			
		||||
  updateLabelFilter,
 | 
			
		||||
  showReviewed,
 | 
			
		||||
  updateLabelFilter,
 | 
			
		||||
  setShowReviewed,
 | 
			
		||||
}: GeneralFilterButtonProps) {
 | 
			
		||||
  const [open, setOpen] = useState(false);
 | 
			
		||||
@ -385,12 +389,90 @@ function GeneralFilterButton({
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const trigger = (
 | 
			
		||||
    <Button size="sm" className="ml-1" variant="secondary">
 | 
			
		||||
      <FaFilter className="md:mr-[10px] text-muted-foreground" />
 | 
			
		||||
    <Button size="sm" className="flex items-center gap-2" variant="secondary">
 | 
			
		||||
      <FaFilter className="text-muted-foreground" />
 | 
			
		||||
      <div className="hidden md:block">Filter</div>
 | 
			
		||||
    </Button>
 | 
			
		||||
  );
 | 
			
		||||
  const content = (
 | 
			
		||||
    <GeneralFilterContent
 | 
			
		||||
      allLabels={allLabels}
 | 
			
		||||
      selectedLabels={selectedLabels}
 | 
			
		||||
      currentLabels={currentLabels}
 | 
			
		||||
      showReviewed={showReviewed}
 | 
			
		||||
      reviewed={reviewed}
 | 
			
		||||
      updateLabelFilter={updateLabelFilter}
 | 
			
		||||
      setShowReviewed={setShowReviewed}
 | 
			
		||||
      setCurrentLabels={setCurrentLabels}
 | 
			
		||||
      setReviewed={setReviewed}
 | 
			
		||||
      onClose={() => setOpen(false)}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (isMobile) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Drawer
 | 
			
		||||
        open={open}
 | 
			
		||||
        onOpenChange={(open) => {
 | 
			
		||||
          if (!open) {
 | 
			
		||||
            setReviewed(showReviewed ?? 0);
 | 
			
		||||
            setCurrentLabels(selectedLabels);
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          setOpen(open);
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <DrawerTrigger asChild>{trigger}</DrawerTrigger>
 | 
			
		||||
        <DrawerContent className="max-h-[75dvh] overflow-hidden">
 | 
			
		||||
          {content}
 | 
			
		||||
        </DrawerContent>
 | 
			
		||||
      </Drawer>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Popover
 | 
			
		||||
      open={open}
 | 
			
		||||
      onOpenChange={(open) => {
 | 
			
		||||
        if (!open) {
 | 
			
		||||
          setReviewed(showReviewed ?? 0);
 | 
			
		||||
          setCurrentLabels(selectedLabels);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        setOpen(open);
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <PopoverTrigger asChild>{trigger}</PopoverTrigger>
 | 
			
		||||
      <PopoverContent side="left">{content}</PopoverContent>
 | 
			
		||||
    </Popover>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type GeneralFilterContentProps = {
 | 
			
		||||
  allLabels: string[];
 | 
			
		||||
  selectedLabels: string[] | undefined;
 | 
			
		||||
  currentLabels: string[] | undefined;
 | 
			
		||||
  showReviewed?: 0 | 1;
 | 
			
		||||
  reviewed: 0 | 1;
 | 
			
		||||
  updateLabelFilter: (labels: string[] | undefined) => void;
 | 
			
		||||
  setCurrentLabels: (labels: string[] | undefined) => void;
 | 
			
		||||
  setShowReviewed: (reviewed?: 0 | 1) => void;
 | 
			
		||||
  setReviewed: (reviewed: 0 | 1) => void;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function GeneralFilterContent({
 | 
			
		||||
  allLabels,
 | 
			
		||||
  selectedLabels,
 | 
			
		||||
  currentLabels,
 | 
			
		||||
  showReviewed,
 | 
			
		||||
  reviewed,
 | 
			
		||||
  updateLabelFilter,
 | 
			
		||||
  setCurrentLabels,
 | 
			
		||||
  setShowReviewed,
 | 
			
		||||
  setReviewed,
 | 
			
		||||
  onClose,
 | 
			
		||||
}: GeneralFilterContentProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <div className="flex p-2 justify-start items-center">
 | 
			
		||||
        <Switch
 | 
			
		||||
@ -455,7 +537,7 @@ function GeneralFilterButton({
 | 
			
		||||
              updateLabelFilter(currentLabels);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            setOpen(false);
 | 
			
		||||
            onClose();
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          Apply
 | 
			
		||||
@ -474,44 +556,6 @@ function GeneralFilterButton({
 | 
			
		||||
      </div>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (isMobile) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Drawer
 | 
			
		||||
        open={open}
 | 
			
		||||
        onOpenChange={(open) => {
 | 
			
		||||
          if (!open) {
 | 
			
		||||
            setReviewed(showReviewed ?? 0);
 | 
			
		||||
            setCurrentLabels(selectedLabels);
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          setOpen(open);
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <DrawerTrigger asChild>{trigger}</DrawerTrigger>
 | 
			
		||||
        <DrawerContent className="max-h-[75dvh] overflow-hidden">
 | 
			
		||||
          {content}
 | 
			
		||||
        </DrawerContent>
 | 
			
		||||
      </Drawer>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Popover
 | 
			
		||||
      open={open}
 | 
			
		||||
      onOpenChange={(open) => {
 | 
			
		||||
        if (!open) {
 | 
			
		||||
          setReviewed(showReviewed ?? 0);
 | 
			
		||||
          setCurrentLabels(selectedLabels);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        setOpen(open);
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <PopoverTrigger asChild>{trigger}</PopoverTrigger>
 | 
			
		||||
      <PopoverContent side="left">{content}</PopoverContent>
 | 
			
		||||
    </Popover>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ShowMotionOnlyButtonProps = {
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,6 @@
 | 
			
		||||
import { useCallback, useMemo, useState } from "react";
 | 
			
		||||
import {
 | 
			
		||||
  Dialog,
 | 
			
		||||
  DialogClose,
 | 
			
		||||
  DialogContent,
 | 
			
		||||
  DialogFooter,
 | 
			
		||||
  DialogHeader,
 | 
			
		||||
@ -23,6 +22,9 @@ import { FrigateConfig } from "@/types/frigateConfig";
 | 
			
		||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
 | 
			
		||||
import ReviewActivityCalendar from "./ReviewActivityCalendar";
 | 
			
		||||
import { SelectSeparator } from "../ui/select";
 | 
			
		||||
import { isDesktop } from "react-device-detect";
 | 
			
		||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
 | 
			
		||||
import SaveExportOverlay from "./SaveExportOverlay";
 | 
			
		||||
 | 
			
		||||
const EXPORT_OPTIONS = [
 | 
			
		||||
  "1",
 | 
			
		||||
@ -53,8 +55,121 @@ export default function ExportDialog({
 | 
			
		||||
  setRange,
 | 
			
		||||
  setMode,
 | 
			
		||||
}: ExportDialogProps) {
 | 
			
		||||
  const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
 | 
			
		||||
  const [name, setName] = useState("");
 | 
			
		||||
  const onStartExport = useCallback(() => {
 | 
			
		||||
    if (!range) {
 | 
			
		||||
      toast.error("No valid time range selected", { position: "top-center" });
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    axios
 | 
			
		||||
      .post(`export/${camera}/start/${range.after}/end/${range.before}`, {
 | 
			
		||||
        playback: "realtime",
 | 
			
		||||
        name,
 | 
			
		||||
      })
 | 
			
		||||
      .then((response) => {
 | 
			
		||||
        if (response.status == 200) {
 | 
			
		||||
          toast.success(
 | 
			
		||||
            "Successfully started export. View the file in the /exports folder.",
 | 
			
		||||
            { position: "top-center" },
 | 
			
		||||
          );
 | 
			
		||||
          setName("");
 | 
			
		||||
          setRange(undefined);
 | 
			
		||||
          setMode("none");
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
      .catch((error) => {
 | 
			
		||||
        if (error.response?.data?.message) {
 | 
			
		||||
          toast.error(
 | 
			
		||||
            `Failed to start export: ${error.response.data.message}`,
 | 
			
		||||
            { position: "top-center" },
 | 
			
		||||
          );
 | 
			
		||||
        } else {
 | 
			
		||||
          toast.error(`Failed to start export: ${error.message}`, {
 | 
			
		||||
            position: "top-center",
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
  }, [camera, name, range, setRange, setName, setMode]);
 | 
			
		||||
 | 
			
		||||
  const Overlay = isDesktop ? Dialog : Drawer;
 | 
			
		||||
  const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
 | 
			
		||||
  const Content = isDesktop ? DialogContent : DrawerContent;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <SaveExportOverlay
 | 
			
		||||
        className="absolute top-8 left-1/2 -translate-x-1/2 z-50 pointer-events-none"
 | 
			
		||||
        show={mode == "timeline"}
 | 
			
		||||
        onSave={() => onStartExport()}
 | 
			
		||||
        onCancel={() => setMode("none")}
 | 
			
		||||
      />
 | 
			
		||||
      <Overlay
 | 
			
		||||
        open={mode == "select"}
 | 
			
		||||
        onOpenChange={(open) => {
 | 
			
		||||
          if (!open) {
 | 
			
		||||
            setMode("none");
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <Trigger asChild>
 | 
			
		||||
          <Button
 | 
			
		||||
            className="flex items-center gap-2"
 | 
			
		||||
            variant="secondary"
 | 
			
		||||
            size="sm"
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              setMode("select");
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <FaArrowDown className="p-1 fill-secondary bg-muted-foreground rounded-md" />
 | 
			
		||||
            {isDesktop && "Export"}
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Trigger>
 | 
			
		||||
        <Content
 | 
			
		||||
          className={
 | 
			
		||||
            isDesktop ? "sm:rounded-2xl" : "px-4 pb-4 mx-4 rounded-2xl"
 | 
			
		||||
          }
 | 
			
		||||
        >
 | 
			
		||||
          <ExportContent
 | 
			
		||||
            latestTime={latestTime}
 | 
			
		||||
            currentTime={currentTime}
 | 
			
		||||
            range={range}
 | 
			
		||||
            name={name}
 | 
			
		||||
            onStartExport={onStartExport}
 | 
			
		||||
            setName={setName}
 | 
			
		||||
            setRange={setRange}
 | 
			
		||||
            setMode={setMode}
 | 
			
		||||
            onCancel={() => setMode("none")}
 | 
			
		||||
          />
 | 
			
		||||
        </Content>
 | 
			
		||||
      </Overlay>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ExportContentProps = {
 | 
			
		||||
  latestTime: number;
 | 
			
		||||
  currentTime: number;
 | 
			
		||||
  range?: TimeRange;
 | 
			
		||||
  name: string;
 | 
			
		||||
  onStartExport: () => void;
 | 
			
		||||
  setName: (name: string) => void;
 | 
			
		||||
  setRange: (range: TimeRange | undefined) => void;
 | 
			
		||||
  setMode: (mode: ExportMode) => void;
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function ExportContent({
 | 
			
		||||
  latestTime,
 | 
			
		||||
  currentTime,
 | 
			
		||||
  range,
 | 
			
		||||
  name,
 | 
			
		||||
  onStartExport,
 | 
			
		||||
  setName,
 | 
			
		||||
  setRange,
 | 
			
		||||
  setMode,
 | 
			
		||||
  onCancel,
 | 
			
		||||
}: ExportContentProps) {
 | 
			
		||||
  const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
 | 
			
		||||
 | 
			
		||||
  const onSelectTime = useCallback(
 | 
			
		||||
    (option: ExportOption) => {
 | 
			
		||||
@ -93,136 +208,86 @@ export default function ExportDialog({
 | 
			
		||||
    [latestTime, setRange],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const onStartExport = useCallback(() => {
 | 
			
		||||
    if (!range) {
 | 
			
		||||
      toast.error("No valid time range selected", { position: "top-center" });
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    axios
 | 
			
		||||
      .post(`export/${camera}/start/${range.after}/end/${range.before}`, {
 | 
			
		||||
        playback: "realtime",
 | 
			
		||||
        name,
 | 
			
		||||
      })
 | 
			
		||||
      .then((response) => {
 | 
			
		||||
        if (response.status == 200) {
 | 
			
		||||
          toast.success(
 | 
			
		||||
            "Successfully started export. View the file in the /exports folder.",
 | 
			
		||||
            { position: "top-center" },
 | 
			
		||||
          );
 | 
			
		||||
          setName("");
 | 
			
		||||
          setRange(undefined);
 | 
			
		||||
          setSelectedOption("1");
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
      .catch((error) => {
 | 
			
		||||
        if (error.response?.data?.message) {
 | 
			
		||||
          toast.error(
 | 
			
		||||
            `Failed to start export: ${error.response.data.message}`,
 | 
			
		||||
            { position: "top-center" },
 | 
			
		||||
          );
 | 
			
		||||
        } else {
 | 
			
		||||
          toast.error(`Failed to start export: ${error.message}`, {
 | 
			
		||||
            position: "top-center",
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
  }, [camera, name, range, setRange]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog
 | 
			
		||||
      open={mode == "select"}
 | 
			
		||||
      onOpenChange={(open) => {
 | 
			
		||||
        if (!open) {
 | 
			
		||||
          setMode("none");
 | 
			
		||||
        }
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <DialogTrigger asChild>
 | 
			
		||||
    <div className="w-full">
 | 
			
		||||
      {isDesktop && (
 | 
			
		||||
        <>
 | 
			
		||||
          <DialogHeader>
 | 
			
		||||
            <DialogTitle>Export</DialogTitle>
 | 
			
		||||
          </DialogHeader>
 | 
			
		||||
          <SelectSeparator className="bg-secondary" />
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
      <RadioGroup
 | 
			
		||||
        className={`flex flex-col gap-3 ${isDesktop ? "" : "mt-4"}`}
 | 
			
		||||
        onValueChange={(value) => onSelectTime(value as ExportOption)}
 | 
			
		||||
      >
 | 
			
		||||
        {EXPORT_OPTIONS.map((opt) => {
 | 
			
		||||
          return (
 | 
			
		||||
            <div key={opt} className="flex items-center gap-2">
 | 
			
		||||
              <RadioGroupItem
 | 
			
		||||
                className={
 | 
			
		||||
                  opt == selectedOption
 | 
			
		||||
                    ? "from-selected/50 to-selected/90 text-selected bg-selected"
 | 
			
		||||
                    : "from-secondary/50 to-secondary/90 text-secondary bg-secondary"
 | 
			
		||||
                }
 | 
			
		||||
                id={opt}
 | 
			
		||||
                value={opt}
 | 
			
		||||
              />
 | 
			
		||||
              <Label className="cursor-pointer capitalize" htmlFor={opt}>
 | 
			
		||||
                {isNaN(parseInt(opt))
 | 
			
		||||
                  ? opt == "timeline"
 | 
			
		||||
                    ? "Select from Timeline"
 | 
			
		||||
                    : `${opt}`
 | 
			
		||||
                  : `Last ${opt > "1" ? `${opt} Hours` : "Hour"}`}
 | 
			
		||||
              </Label>
 | 
			
		||||
            </div>
 | 
			
		||||
          );
 | 
			
		||||
        })}
 | 
			
		||||
      </RadioGroup>
 | 
			
		||||
      {selectedOption == "custom" && (
 | 
			
		||||
        <CustomTimeSelector
 | 
			
		||||
          latestTime={latestTime}
 | 
			
		||||
          range={range}
 | 
			
		||||
          setRange={setRange}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      <Input
 | 
			
		||||
        className="mt-3"
 | 
			
		||||
        type="search"
 | 
			
		||||
        placeholder="Name the Export"
 | 
			
		||||
        value={name}
 | 
			
		||||
        onChange={(e) => setName(e.target.value)}
 | 
			
		||||
      />
 | 
			
		||||
      {isDesktop && <SelectSeparator className="bg-secondary" />}
 | 
			
		||||
      <DialogFooter
 | 
			
		||||
        className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-4"}
 | 
			
		||||
      >
 | 
			
		||||
        <div
 | 
			
		||||
          className={`p-2 cursor-pointer text-center ${isDesktop ? "" : "w-full"}`}
 | 
			
		||||
          onClick={onCancel}
 | 
			
		||||
        >
 | 
			
		||||
          Cancel
 | 
			
		||||
        </div>
 | 
			
		||||
        <Button
 | 
			
		||||
          className="flex items-center gap-2"
 | 
			
		||||
          variant="secondary"
 | 
			
		||||
          className={isDesktop ? "" : "w-full"}
 | 
			
		||||
          variant="select"
 | 
			
		||||
          size="sm"
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            if (mode == "none") {
 | 
			
		||||
              setMode("select");
 | 
			
		||||
            } else if (mode == "timeline") {
 | 
			
		||||
            if (selectedOption == "timeline") {
 | 
			
		||||
              setRange({ before: currentTime + 30, after: currentTime - 30 });
 | 
			
		||||
              setMode("timeline");
 | 
			
		||||
            } else {
 | 
			
		||||
              onStartExport();
 | 
			
		||||
              setSelectedOption("1");
 | 
			
		||||
              setMode("none");
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <FaArrowDown className="p-1 fill-secondary bg-muted-foreground rounded-md" />
 | 
			
		||||
          {mode != "timeline" ? "Export" : "Save"}
 | 
			
		||||
          {selectedOption == "timeline" ? "Select" : "Export"}
 | 
			
		||||
        </Button>
 | 
			
		||||
      </DialogTrigger>
 | 
			
		||||
      <DialogContent className="sm:rounded-2xl">
 | 
			
		||||
        <DialogHeader>
 | 
			
		||||
          <DialogTitle>Export</DialogTitle>
 | 
			
		||||
        </DialogHeader>
 | 
			
		||||
        <SelectSeparator className="bg-secondary" />
 | 
			
		||||
        <RadioGroup
 | 
			
		||||
          className="flex flex-col gap-3"
 | 
			
		||||
          onValueChange={(value) => onSelectTime(value as ExportOption)}
 | 
			
		||||
        >
 | 
			
		||||
          {EXPORT_OPTIONS.map((opt) => {
 | 
			
		||||
            return (
 | 
			
		||||
              <div key={opt} className="flex items-center gap-2">
 | 
			
		||||
                <RadioGroupItem
 | 
			
		||||
                  className={
 | 
			
		||||
                    opt == selectedOption
 | 
			
		||||
                      ? "from-selected/50 to-selected/90 text-selected bg-selected"
 | 
			
		||||
                      : "from-secondary/50 to-secondary/90 text-secondary bg-secondary"
 | 
			
		||||
                  }
 | 
			
		||||
                  id={opt}
 | 
			
		||||
                  value={opt}
 | 
			
		||||
                />
 | 
			
		||||
                <Label className="cursor-pointer capitalize" htmlFor={opt}>
 | 
			
		||||
                  {isNaN(parseInt(opt))
 | 
			
		||||
                    ? opt == "timeline"
 | 
			
		||||
                      ? "Select from Timeline"
 | 
			
		||||
                      : `${opt}`
 | 
			
		||||
                    : `Last ${opt > "1" ? `${opt} Hours` : "Hour"}`}
 | 
			
		||||
                </Label>
 | 
			
		||||
              </div>
 | 
			
		||||
            );
 | 
			
		||||
          })}
 | 
			
		||||
        </RadioGroup>
 | 
			
		||||
        {selectedOption == "custom" && (
 | 
			
		||||
          <CustomTimeSelector
 | 
			
		||||
            latestTime={latestTime}
 | 
			
		||||
            range={range}
 | 
			
		||||
            setRange={setRange}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        <Input
 | 
			
		||||
          className="mt-2"
 | 
			
		||||
          type="search"
 | 
			
		||||
          placeholder="Name the Export"
 | 
			
		||||
          value={name}
 | 
			
		||||
          onChange={(e) => setName(e.target.value)}
 | 
			
		||||
        />
 | 
			
		||||
        <SelectSeparator className="bg-secondary" />
 | 
			
		||||
        <DialogFooter>
 | 
			
		||||
          <DialogClose onClick={() => setMode("none")}>Cancel</DialogClose>
 | 
			
		||||
          <Button
 | 
			
		||||
            variant="select"
 | 
			
		||||
            size="sm"
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              if (selectedOption == "timeline") {
 | 
			
		||||
                setRange({ before: currentTime + 30, after: currentTime - 30 });
 | 
			
		||||
                setMode("timeline");
 | 
			
		||||
              } else {
 | 
			
		||||
                onStartExport();
 | 
			
		||||
                setMode("none");
 | 
			
		||||
              }
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            {selectedOption == "timeline" ? "Select" : "Export"}
 | 
			
		||||
          </Button>
 | 
			
		||||
        </DialogFooter>
 | 
			
		||||
      </DialogContent>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
      </DialogFooter>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -276,7 +341,9 @@ function CustomTimeSelector({
 | 
			
		||||
  const [endOpen, setEndOpen] = useState(false);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="mx-8 px-2 flex items-center gap-2 bg-secondary rounded-lg">
 | 
			
		||||
    <div
 | 
			
		||||
      className={`flex items-center bg-secondary rounded-lg ${isDesktop ? "mx-8 px-2 gap-2" : "pl-2 mt-3"}`}
 | 
			
		||||
    >
 | 
			
		||||
      <FaCalendarAlt />
 | 
			
		||||
      <Popover
 | 
			
		||||
        open={startOpen}
 | 
			
		||||
@ -288,7 +355,9 @@ function CustomTimeSelector({
 | 
			
		||||
      >
 | 
			
		||||
        <PopoverTrigger asChild>
 | 
			
		||||
          <Button
 | 
			
		||||
            className={isDesktop ? "" : "text-xs"}
 | 
			
		||||
            variant={startOpen ? "select" : "secondary"}
 | 
			
		||||
            size="sm"
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              setStartOpen(true);
 | 
			
		||||
              setEndOpen(false);
 | 
			
		||||
@ -347,7 +416,9 @@ function CustomTimeSelector({
 | 
			
		||||
      >
 | 
			
		||||
        <PopoverTrigger asChild>
 | 
			
		||||
          <Button
 | 
			
		||||
            className={isDesktop ? "" : "text-xs"}
 | 
			
		||||
            variant={endOpen ? "select" : "secondary"}
 | 
			
		||||
            size="sm"
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              setEndOpen(true);
 | 
			
		||||
              setStartOpen(false);
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										46
									
								
								web/src/components/overlay/MobileCameraDrawer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								web/src/components/overlay/MobileCameraDrawer.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,46 @@
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
 | 
			
		||||
import { Button } from "../ui/button";
 | 
			
		||||
import { FaVideo } from "react-icons/fa";
 | 
			
		||||
import { isMobile } from "react-device-detect";
 | 
			
		||||
 | 
			
		||||
type MobileCameraDrawerProps = {
 | 
			
		||||
  allCameras: string[];
 | 
			
		||||
  selected: string;
 | 
			
		||||
  onSelectCamera: (cam: string) => void;
 | 
			
		||||
};
 | 
			
		||||
export default function MobileCameraDrawer({
 | 
			
		||||
  allCameras,
 | 
			
		||||
  selected,
 | 
			
		||||
  onSelectCamera,
 | 
			
		||||
}: MobileCameraDrawerProps) {
 | 
			
		||||
  const [cameraDrawer, setCameraDrawer] = useState(false);
 | 
			
		||||
 | 
			
		||||
  if (!isMobile) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Drawer open={cameraDrawer} onOpenChange={setCameraDrawer}>
 | 
			
		||||
      <DrawerTrigger asChild>
 | 
			
		||||
        <Button className="rounded-lg capitalize" size="sm" variant="secondary">
 | 
			
		||||
          <FaVideo className="text-muted-foreground" />
 | 
			
		||||
        </Button>
 | 
			
		||||
      </DrawerTrigger>
 | 
			
		||||
      <DrawerContent className="max-h-[75dvh] overflow-hidden flex flex-col items-center gap-2 px-4 pb-4 mx-1 rounded-t-2xl">
 | 
			
		||||
        {allCameras.map((cam) => (
 | 
			
		||||
          <div
 | 
			
		||||
            key={cam}
 | 
			
		||||
            className={`w-full mx-4 py-2 text-center capitalize ${cam == selected ? "bg-secondary rounded-lg" : ""}`}
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              onSelectCamera(cam);
 | 
			
		||||
              setCameraDrawer(false);
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            {cam.replaceAll("_", " ")}
 | 
			
		||||
          </div>
 | 
			
		||||
        ))}
 | 
			
		||||
      </DrawerContent>
 | 
			
		||||
    </Drawer>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										289
									
								
								web/src/components/overlay/MobileReviewSettingsDrawer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										289
									
								
								web/src/components/overlay/MobileReviewSettingsDrawer.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,289 @@
 | 
			
		||||
import { useCallback, useMemo, useState } from "react";
 | 
			
		||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
 | 
			
		||||
import { Button } from "../ui/button";
 | 
			
		||||
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
 | 
			
		||||
import { TimeRange } from "@/types/timeline";
 | 
			
		||||
import { ExportContent } from "./ExportDialog";
 | 
			
		||||
import { ExportMode } from "@/types/filter";
 | 
			
		||||
import ReviewActivityCalendar from "./ReviewActivityCalendar";
 | 
			
		||||
import { SelectSeparator } from "../ui/select";
 | 
			
		||||
import { ReviewFilter } from "@/types/review";
 | 
			
		||||
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
 | 
			
		||||
import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
 | 
			
		||||
import useSWR from "swr";
 | 
			
		||||
import { FrigateConfig } from "@/types/frigateConfig";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import axios from "axios";
 | 
			
		||||
import SaveExportOverlay from "./SaveExportOverlay";
 | 
			
		||||
import { isMobile } from "react-device-detect";
 | 
			
		||||
 | 
			
		||||
const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"];
 | 
			
		||||
type DrawerMode = "none" | "select" | "export" | "calendar" | "filter";
 | 
			
		||||
 | 
			
		||||
type MobileReviewSettingsDrawerProps = {
 | 
			
		||||
  camera: string;
 | 
			
		||||
  filter?: ReviewFilter;
 | 
			
		||||
  latestTime: number;
 | 
			
		||||
  currentTime: number;
 | 
			
		||||
  range?: TimeRange;
 | 
			
		||||
  mode: ExportMode;
 | 
			
		||||
  onUpdateFilter: (filter: ReviewFilter) => void;
 | 
			
		||||
  setRange: (range: TimeRange | undefined) => void;
 | 
			
		||||
  setMode: (mode: ExportMode) => void;
 | 
			
		||||
};
 | 
			
		||||
export default function MobileReviewSettingsDrawer({
 | 
			
		||||
  camera,
 | 
			
		||||
  filter,
 | 
			
		||||
  latestTime,
 | 
			
		||||
  currentTime,
 | 
			
		||||
  range,
 | 
			
		||||
  mode,
 | 
			
		||||
  onUpdateFilter,
 | 
			
		||||
  setRange,
 | 
			
		||||
  setMode,
 | 
			
		||||
}: MobileReviewSettingsDrawerProps) {
 | 
			
		||||
  const { data: config } = useSWR<FrigateConfig>("config");
 | 
			
		||||
  const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
 | 
			
		||||
 | 
			
		||||
  // exports
 | 
			
		||||
 | 
			
		||||
  const [name, setName] = useState("");
 | 
			
		||||
  const onStartExport = useCallback(() => {
 | 
			
		||||
    if (!range) {
 | 
			
		||||
      toast.error("No valid time range selected", { position: "top-center" });
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    axios
 | 
			
		||||
      .post(`export/${camera}/start/${range.after}/end/${range.before}`, {
 | 
			
		||||
        playback: "realtime",
 | 
			
		||||
        name,
 | 
			
		||||
      })
 | 
			
		||||
      .then((response) => {
 | 
			
		||||
        if (response.status == 200) {
 | 
			
		||||
          toast.success(
 | 
			
		||||
            "Successfully started export. View the file in the /exports folder.",
 | 
			
		||||
            { position: "top-center" },
 | 
			
		||||
          );
 | 
			
		||||
          setName("");
 | 
			
		||||
          setRange(undefined);
 | 
			
		||||
          setMode("none");
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
      .catch((error) => {
 | 
			
		||||
        if (error.response?.data?.message) {
 | 
			
		||||
          toast.error(
 | 
			
		||||
            `Failed to start export: ${error.response.data.message}`,
 | 
			
		||||
            { position: "top-center" },
 | 
			
		||||
          );
 | 
			
		||||
        } else {
 | 
			
		||||
          toast.error(`Failed to start export: ${error.message}`, {
 | 
			
		||||
            position: "top-center",
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
  }, [camera, name, range, setRange, setName, setMode]);
 | 
			
		||||
 | 
			
		||||
  // filters
 | 
			
		||||
 | 
			
		||||
  const allLabels = useMemo<string[]>(() => {
 | 
			
		||||
    if (!config) {
 | 
			
		||||
      return [];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const labels = new Set<string>();
 | 
			
		||||
    const cameras = filter?.cameras || Object.keys(config.cameras);
 | 
			
		||||
 | 
			
		||||
    cameras.forEach((camera) => {
 | 
			
		||||
      const cameraConfig = config.cameras[camera];
 | 
			
		||||
      cameraConfig.objects.track.forEach((label) => {
 | 
			
		||||
        if (!ATTRIBUTES.includes(label)) {
 | 
			
		||||
          labels.add(label);
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (cameraConfig.audio.enabled_in_config) {
 | 
			
		||||
        cameraConfig.audio.listen.forEach((label) => {
 | 
			
		||||
          labels.add(label);
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return [...labels].sort();
 | 
			
		||||
  }, [config, filter]);
 | 
			
		||||
  const [currentLabels, setCurrentLabels] = useState<string[] | undefined>(
 | 
			
		||||
    filter?.labels,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (!isMobile) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let content;
 | 
			
		||||
  if (drawerMode == "select") {
 | 
			
		||||
    content = (
 | 
			
		||||
      <div className="w-full p-4 flex flex-col gap-2">
 | 
			
		||||
        <Button
 | 
			
		||||
          className="w-full flex justify-center items-center gap-2"
 | 
			
		||||
          onClick={() => setDrawerMode("export")}
 | 
			
		||||
        >
 | 
			
		||||
          <FaArrowDown className="p-1 fill-secondary bg-muted-foreground rounded-md" />
 | 
			
		||||
          Export
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button
 | 
			
		||||
          className="w-full flex justify-center items-center gap-2"
 | 
			
		||||
          onClick={() => setDrawerMode("calendar")}
 | 
			
		||||
        >
 | 
			
		||||
          <FaCalendarAlt className="fill-muted-foreground" />
 | 
			
		||||
          Calendar
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button
 | 
			
		||||
          className="w-full flex justify-center items-center gap-2"
 | 
			
		||||
          onClick={() => setDrawerMode("filter")}
 | 
			
		||||
        >
 | 
			
		||||
          <FaFilter className="fill-muted-foreground" />
 | 
			
		||||
          Filter
 | 
			
		||||
        </Button>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  } else if (drawerMode == "export") {
 | 
			
		||||
    content = (
 | 
			
		||||
      <ExportContent
 | 
			
		||||
        latestTime={latestTime}
 | 
			
		||||
        currentTime={currentTime}
 | 
			
		||||
        range={range}
 | 
			
		||||
        name={name}
 | 
			
		||||
        onStartExport={onStartExport}
 | 
			
		||||
        setName={setName}
 | 
			
		||||
        setRange={setRange}
 | 
			
		||||
        setMode={(mode) => {
 | 
			
		||||
          setMode(mode);
 | 
			
		||||
 | 
			
		||||
          if (mode == "timeline") {
 | 
			
		||||
            setDrawerMode("none");
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
        onCancel={() => {
 | 
			
		||||
          setMode("none");
 | 
			
		||||
          setRange(undefined);
 | 
			
		||||
          setDrawerMode("select");
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else if (drawerMode == "calendar") {
 | 
			
		||||
    content = (
 | 
			
		||||
      <div className="w-full flex flex-col">
 | 
			
		||||
        <div className="w-full h-8 relative">
 | 
			
		||||
          <div
 | 
			
		||||
            className="absolute left-0 text-selected"
 | 
			
		||||
            onClick={() => setDrawerMode("select")}
 | 
			
		||||
          >
 | 
			
		||||
            Back
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="absolute left-1/2 -translate-x-1/2 text-muted-foreground">
 | 
			
		||||
            Calendar
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <ReviewActivityCalendar
 | 
			
		||||
          selectedDay={
 | 
			
		||||
            filter?.after == undefined
 | 
			
		||||
              ? undefined
 | 
			
		||||
              : new Date(filter.after * 1000)
 | 
			
		||||
          }
 | 
			
		||||
          onSelect={(day) => {
 | 
			
		||||
            onUpdateFilter({
 | 
			
		||||
              ...filter,
 | 
			
		||||
              after: day == undefined ? undefined : day.getTime() / 1000,
 | 
			
		||||
              before: day == undefined ? undefined : getEndOfDayTimestamp(day),
 | 
			
		||||
            });
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
        <SelectSeparator />
 | 
			
		||||
        <div className="p-2 flex justify-center items-center">
 | 
			
		||||
          <Button
 | 
			
		||||
            variant="secondary"
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              onUpdateFilter({
 | 
			
		||||
                ...filter,
 | 
			
		||||
                after: undefined,
 | 
			
		||||
                before: undefined,
 | 
			
		||||
              });
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            Reset
 | 
			
		||||
          </Button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  } else if (drawerMode == "filter") {
 | 
			
		||||
    content = (
 | 
			
		||||
      <div className="w-full h-auto overflow-y-auto flex flex-col">
 | 
			
		||||
        <div className="w-full h-8 mb-2 relative">
 | 
			
		||||
          <div
 | 
			
		||||
            className="absolute left-0 text-selected"
 | 
			
		||||
            onClick={() => setDrawerMode("select")}
 | 
			
		||||
          >
 | 
			
		||||
            Back
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="absolute left-1/2 -translate-x-1/2 text-muted-foreground">
 | 
			
		||||
            Filter
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <GeneralFilterContent
 | 
			
		||||
          allLabels={allLabels.concat(allLabels)}
 | 
			
		||||
          selectedLabels={filter?.labels}
 | 
			
		||||
          currentLabels={currentLabels}
 | 
			
		||||
          showReviewed={0}
 | 
			
		||||
          reviewed={0}
 | 
			
		||||
          setCurrentLabels={setCurrentLabels}
 | 
			
		||||
          updateLabelFilter={(newLabels) =>
 | 
			
		||||
            onUpdateFilter({ ...filter, labels: newLabels })
 | 
			
		||||
          }
 | 
			
		||||
          setShowReviewed={() => {}}
 | 
			
		||||
          setReviewed={() => {}}
 | 
			
		||||
          onClose={() => setDrawerMode("select")}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <SaveExportOverlay
 | 
			
		||||
        className="absolute top-8 left-1/2 -translate-x-1/2 z-50 pointer-events-none"
 | 
			
		||||
        show={mode == "timeline"}
 | 
			
		||||
        onSave={() => onStartExport()}
 | 
			
		||||
        onCancel={() => setMode("none")}
 | 
			
		||||
      />
 | 
			
		||||
      <Drawer
 | 
			
		||||
        open={drawerMode != "none"}
 | 
			
		||||
        onOpenChange={(open) => {
 | 
			
		||||
          if (!open) {
 | 
			
		||||
            setDrawerMode("none");
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <DrawerTrigger asChild>
 | 
			
		||||
          <Button
 | 
			
		||||
            className="rounded-lg capitalize"
 | 
			
		||||
            size="sm"
 | 
			
		||||
            variant="secondary"
 | 
			
		||||
            onClick={() => setDrawerMode("select")}
 | 
			
		||||
          >
 | 
			
		||||
            <FaCog className="text-muted-foreground" />
 | 
			
		||||
          </Button>
 | 
			
		||||
        </DrawerTrigger>
 | 
			
		||||
        <DrawerContent className="max-h-[80dvh] overflow-hidden flex flex-col items-center gap-2 px-4 pb-4 mx-1 rounded-t-2xl">
 | 
			
		||||
          {content}
 | 
			
		||||
        </DrawerContent>
 | 
			
		||||
      </Drawer>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * <MobileTimelineDrawer
 | 
			
		||||
              selected={timelineType ?? "timeline"}
 | 
			
		||||
              onSelect={setTimelineType}
 | 
			
		||||
            />
 | 
			
		||||
 */
 | 
			
		||||
							
								
								
									
										51
									
								
								web/src/components/overlay/MobileTimelineDrawer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								web/src/components/overlay/MobileTimelineDrawer.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,51 @@
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
 | 
			
		||||
import { Button } from "../ui/button";
 | 
			
		||||
import { FaFlag } from "react-icons/fa";
 | 
			
		||||
import { TimelineType } from "@/types/timeline";
 | 
			
		||||
import { isMobile } from "react-device-detect";
 | 
			
		||||
 | 
			
		||||
type MobileTimelineDrawerProps = {
 | 
			
		||||
  selected: TimelineType;
 | 
			
		||||
  onSelect: (timeline: TimelineType) => void;
 | 
			
		||||
};
 | 
			
		||||
export default function MobileTimelineDrawer({
 | 
			
		||||
  selected,
 | 
			
		||||
  onSelect,
 | 
			
		||||
}: MobileTimelineDrawerProps) {
 | 
			
		||||
  const [drawer, setDrawer] = useState(false);
 | 
			
		||||
 | 
			
		||||
  if (!isMobile) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Drawer open={drawer} onOpenChange={setDrawer}>
 | 
			
		||||
      <DrawerTrigger asChild>
 | 
			
		||||
        <Button className="rounded-lg capitalize" size="sm" variant="secondary">
 | 
			
		||||
          <FaFlag className="text-muted-foreground" />
 | 
			
		||||
        </Button>
 | 
			
		||||
      </DrawerTrigger>
 | 
			
		||||
      <DrawerContent className="max-h-[75dvh] overflow-hidden flex flex-col items-center gap-2 px-4 pb-4 mx-1 rounded-t-2xl">
 | 
			
		||||
        <div
 | 
			
		||||
          className={`w-full mx-4 py-2 text-center capitalize ${selected == "timeline" ? "bg-secondary rounded-lg" : ""}`}
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            onSelect("timeline");
 | 
			
		||||
            setDrawer(false);
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          Timeline
 | 
			
		||||
        </div>
 | 
			
		||||
        <div
 | 
			
		||||
          className={`w-full mx-4 py-2 text-center capitalize ${selected == "events" ? "bg-secondary rounded-lg" : ""}`}
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            onSelect("events");
 | 
			
		||||
            setDrawer(false);
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          Events
 | 
			
		||||
        </div>
 | 
			
		||||
      </DrawerContent>
 | 
			
		||||
    </Drawer>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										45
									
								
								web/src/components/overlay/SaveExportOverlay.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								web/src/components/overlay/SaveExportOverlay.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,45 @@
 | 
			
		||||
import { LuX } from "react-icons/lu";
 | 
			
		||||
import { Button } from "../ui/button";
 | 
			
		||||
import { FaCompactDisc } from "react-icons/fa";
 | 
			
		||||
 | 
			
		||||
type SaveExportOverlayProps = {
 | 
			
		||||
  className: string;
 | 
			
		||||
  show: boolean;
 | 
			
		||||
  onSave: () => void;
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
};
 | 
			
		||||
export default function SaveExportOverlay({
 | 
			
		||||
  className,
 | 
			
		||||
  show,
 | 
			
		||||
  onSave,
 | 
			
		||||
  onCancel,
 | 
			
		||||
}: SaveExportOverlayProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={className}>
 | 
			
		||||
      <div
 | 
			
		||||
        className={`flex justify-center px-2 gap-2 items-center pointer-events-auto rounded-lg *:text-white ${
 | 
			
		||||
          show ? "animate-in slide-in-from-top duration-500" : "invisible"
 | 
			
		||||
        }  text-center mt-5 mx-auto`}
 | 
			
		||||
      >
 | 
			
		||||
        <Button
 | 
			
		||||
          className="flex items-center gap-1"
 | 
			
		||||
          variant="select"
 | 
			
		||||
          size="sm"
 | 
			
		||||
          onClick={onSave}
 | 
			
		||||
        >
 | 
			
		||||
          <FaCompactDisc />
 | 
			
		||||
          Save Export
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button
 | 
			
		||||
          className="flex items-center gap-1"
 | 
			
		||||
          size="sm"
 | 
			
		||||
          variant="secondary"
 | 
			
		||||
          onClick={onCancel}
 | 
			
		||||
        >
 | 
			
		||||
          <LuX />
 | 
			
		||||
          Cancel
 | 
			
		||||
        </Button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -88,29 +88,36 @@ export default function HlsVideoPlayer({
 | 
			
		||||
  const [controlsOpen, setControlsOpen] = useState(false);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={`relative ${visible ? "visible" : "hidden"}`}
 | 
			
		||||
      onMouseOver={
 | 
			
		||||
        isDesktop
 | 
			
		||||
          ? () => {
 | 
			
		||||
              setControls(true);
 | 
			
		||||
            }
 | 
			
		||||
          : undefined
 | 
			
		||||
      }
 | 
			
		||||
      onMouseOut={
 | 
			
		||||
        isDesktop
 | 
			
		||||
          ? () => {
 | 
			
		||||
              setControls(controlsOpen);
 | 
			
		||||
            }
 | 
			
		||||
          : undefined
 | 
			
		||||
      }
 | 
			
		||||
      onClick={isDesktop ? undefined : () => setControls(!controls)}
 | 
			
		||||
    >
 | 
			
		||||
      <TransformWrapper minScale={1.0}>
 | 
			
		||||
        <TransformComponent>
 | 
			
		||||
    <TransformWrapper minScale={1.0}>
 | 
			
		||||
      <div
 | 
			
		||||
        className={`relative w-full ${className ?? ""} ${visible ? "visible" : "hidden"}`}
 | 
			
		||||
        onMouseOver={
 | 
			
		||||
          isDesktop
 | 
			
		||||
            ? () => {
 | 
			
		||||
                setControls(true);
 | 
			
		||||
              }
 | 
			
		||||
            : undefined
 | 
			
		||||
        }
 | 
			
		||||
        onMouseOut={
 | 
			
		||||
          isDesktop
 | 
			
		||||
            ? () => {
 | 
			
		||||
                setControls(controlsOpen);
 | 
			
		||||
              }
 | 
			
		||||
            : undefined
 | 
			
		||||
        }
 | 
			
		||||
        onClick={isDesktop ? undefined : () => setControls(!controls)}
 | 
			
		||||
      >
 | 
			
		||||
        <TransformComponent
 | 
			
		||||
          wrapperStyle={{
 | 
			
		||||
            width: "100%",
 | 
			
		||||
          }}
 | 
			
		||||
          contentStyle={{
 | 
			
		||||
            width: "100%",
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <video
 | 
			
		||||
            ref={videoRef}
 | 
			
		||||
            className={`${className ?? ""} bg-black rounded-2xl ${loadedMetadata ? "" : "invisible"}`}
 | 
			
		||||
            className={`size-full bg-black rounded-2xl ${loadedMetadata ? "" : "invisible"}`}
 | 
			
		||||
            preload="auto"
 | 
			
		||||
            autoPlay
 | 
			
		||||
            controls={false}
 | 
			
		||||
@ -149,46 +156,47 @@ export default function HlsVideoPlayer({
 | 
			
		||||
                unsupportedErrorCodes.includes(e.target.error.code) &&
 | 
			
		||||
                videoRef.current
 | 
			
		||||
              ) {
 | 
			
		||||
                setLoadedMetadata(false);
 | 
			
		||||
                setUseHlsCompat(true);
 | 
			
		||||
              }
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
        </TransformComponent>
 | 
			
		||||
      </TransformWrapper>
 | 
			
		||||
      <VideoControls
 | 
			
		||||
        className="absolute bottom-5 left-1/2 -translate-x-1/2"
 | 
			
		||||
        video={videoRef.current}
 | 
			
		||||
        isPlaying={isPlaying}
 | 
			
		||||
        show={controls}
 | 
			
		||||
        controlsOpen={controlsOpen}
 | 
			
		||||
        setControlsOpen={setControlsOpen}
 | 
			
		||||
        playbackRate={videoRef.current?.playbackRate ?? 1}
 | 
			
		||||
        hotKeys={hotKeys}
 | 
			
		||||
        onPlayPause={(play) => {
 | 
			
		||||
          if (!videoRef.current) {
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
        <VideoControls
 | 
			
		||||
          className="absolute bottom-5 left-1/2 -translate-x-1/2"
 | 
			
		||||
          video={videoRef.current}
 | 
			
		||||
          isPlaying={isPlaying}
 | 
			
		||||
          show={controls}
 | 
			
		||||
          controlsOpen={controlsOpen}
 | 
			
		||||
          setControlsOpen={setControlsOpen}
 | 
			
		||||
          playbackRate={videoRef.current?.playbackRate ?? 1}
 | 
			
		||||
          hotKeys={hotKeys}
 | 
			
		||||
          onPlayPause={(play) => {
 | 
			
		||||
            if (!videoRef.current) {
 | 
			
		||||
              return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
          if (play) {
 | 
			
		||||
            videoRef.current.play();
 | 
			
		||||
          } else {
 | 
			
		||||
            videoRef.current.pause();
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
        onSeek={(diff) => {
 | 
			
		||||
          const currentTime = videoRef.current?.currentTime;
 | 
			
		||||
            if (play) {
 | 
			
		||||
              videoRef.current.play();
 | 
			
		||||
            } else {
 | 
			
		||||
              videoRef.current.pause();
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
          onSeek={(diff) => {
 | 
			
		||||
            const currentTime = videoRef.current?.currentTime;
 | 
			
		||||
 | 
			
		||||
          if (!videoRef.current || !currentTime) {
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
            if (!videoRef.current || !currentTime) {
 | 
			
		||||
              return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
          videoRef.current.currentTime = Math.max(0, currentTime + diff);
 | 
			
		||||
        }}
 | 
			
		||||
        onSetPlaybackRate={(rate) =>
 | 
			
		||||
          videoRef.current ? (videoRef.current.playbackRate = rate) : null
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      {children}
 | 
			
		||||
    </div>
 | 
			
		||||
            videoRef.current.currentTime = Math.max(0, currentTime + diff);
 | 
			
		||||
          }}
 | 
			
		||||
          onSetPlaybackRate={(rate) =>
 | 
			
		||||
            videoRef.current ? (videoRef.current.playbackRate = rate) : null
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
        {children}
 | 
			
		||||
      </div>
 | 
			
		||||
    </TransformWrapper>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -9,6 +9,7 @@ import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
 | 
			
		||||
import { DynamicVideoController } from "./DynamicVideoController";
 | 
			
		||||
import HlsVideoPlayer from "../HlsVideoPlayer";
 | 
			
		||||
import { TimeRange, Timeline } from "@/types/timeline";
 | 
			
		||||
import { isDesktop } from "react-device-detect";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Dynamically switches between video playback and scrubbing preview player.
 | 
			
		||||
@ -54,7 +55,7 @@ export default function DynamicVideoPlayer({
 | 
			
		||||
    if (aspectRatio > 2) {
 | 
			
		||||
      return "";
 | 
			
		||||
    } else if (aspectRatio < 16 / 9) {
 | 
			
		||||
      return "aspect-tall";
 | 
			
		||||
      return isDesktop ? "" : "aspect-tall";
 | 
			
		||||
    } else {
 | 
			
		||||
      return "aspect-video";
 | 
			
		||||
    }
 | 
			
		||||
@ -168,9 +169,9 @@ export default function DynamicVideoPlayer({
 | 
			
		||||
  }, [controller, recordings]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={`relative ${className ?? ""}`}>
 | 
			
		||||
    <div className={`w-full relative ${className ?? ""}`}>
 | 
			
		||||
      <HlsVideoPlayer
 | 
			
		||||
        className={`w-full ${grow ?? ""}`}
 | 
			
		||||
        className={isDesktop ? `w-full ${grow}` : "max-h-[50dvh]"}
 | 
			
		||||
        videoRef={playerRef}
 | 
			
		||||
        visible={!(isScrubbing || isLoading)}
 | 
			
		||||
        currentSource={source}
 | 
			
		||||
@ -194,7 +195,7 @@ export default function DynamicVideoPlayer({
 | 
			
		||||
        )}
 | 
			
		||||
      </HlsVideoPlayer>
 | 
			
		||||
      <PreviewPlayer
 | 
			
		||||
        className={`${isScrubbing || isLoading ? "visible" : "hidden"} ${grow}`}
 | 
			
		||||
        className={`${isScrubbing || isLoading ? "visible" : "hidden"} ${isDesktop ? `w-full ${grow}` : "max-h-[50dvh]"}`}
 | 
			
		||||
        camera={camera}
 | 
			
		||||
        timeRange={timeRange}
 | 
			
		||||
        cameraPreviews={cameraPreviews}
 | 
			
		||||
 | 
			
		||||
@ -24,3 +24,5 @@ export type Timeline = {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type TimeRange = { before: number; after: number };
 | 
			
		||||
 | 
			
		||||
export type TimelineType = "timeline" | "events";
 | 
			
		||||
 | 
			
		||||
@ -496,7 +496,7 @@ function DetectionReview({
 | 
			
		||||
      >
 | 
			
		||||
        {filter?.before == undefined && (
 | 
			
		||||
          <NewReviewData
 | 
			
		||||
            className="absolute w-full z-50 pointer-events-none"
 | 
			
		||||
            className="absolute left-1/2 -translate-x-1/2 z-50 pointer-events-none"
 | 
			
		||||
            contentRef={contentRef}
 | 
			
		||||
            reviewItems={currentItems}
 | 
			
		||||
            itemsToReview={itemsToReview}
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,4 @@
 | 
			
		||||
import ReviewCard from "@/components/card/ReviewCard";
 | 
			
		||||
import FilterCheckBox from "@/components/filter/FilterCheckBox";
 | 
			
		||||
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
 | 
			
		||||
import ExportDialog from "@/components/overlay/ExportDialog";
 | 
			
		||||
import PreviewPlayer, {
 | 
			
		||||
@ -9,7 +8,6 @@ import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideo
 | 
			
		||||
import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer";
 | 
			
		||||
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
 | 
			
		||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
 | 
			
		||||
import { useOverlayState } from "@/hooks/use-overlay-state";
 | 
			
		||||
import { ExportMode } from "@/types/filter";
 | 
			
		||||
@ -31,15 +29,17 @@ import {
 | 
			
		||||
  useState,
 | 
			
		||||
} from "react";
 | 
			
		||||
import { isDesktop, isMobile } from "react-device-detect";
 | 
			
		||||
import { FaCircle, FaVideo } from "react-icons/fa";
 | 
			
		||||
import { IoMdArrowRoundBack } from "react-icons/io";
 | 
			
		||||
import { useNavigate } from "react-router-dom";
 | 
			
		||||
import { Toaster } from "@/components/ui/sonner";
 | 
			
		||||
import useSWR from "swr";
 | 
			
		||||
import { TimeRange } from "@/types/timeline";
 | 
			
		||||
import { TimeRange, TimelineType } from "@/types/timeline";
 | 
			
		||||
import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer";
 | 
			
		||||
import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer";
 | 
			
		||||
import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer";
 | 
			
		||||
import Logo from "@/components/Logo";
 | 
			
		||||
 | 
			
		||||
const SEGMENT_DURATION = 30;
 | 
			
		||||
type TimelineType = "timeline" | "events";
 | 
			
		||||
 | 
			
		||||
type RecordingViewProps = {
 | 
			
		||||
  startCamera: string;
 | 
			
		||||
@ -208,10 +208,14 @@ export function RecordingView({
 | 
			
		||||
  }, [config, mainCamera]);
 | 
			
		||||
 | 
			
		||||
  const grow = useMemo(() => {
 | 
			
		||||
    if (isMobile) {
 | 
			
		||||
      return "";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (mainCameraAspect == "wide") {
 | 
			
		||||
      return "w-full aspect-wide";
 | 
			
		||||
    } else if (isDesktop && mainCameraAspect == "tall") {
 | 
			
		||||
      return "h-full aspect-tall";
 | 
			
		||||
      return "h-full aspect-tall flex flex-col justify-center";
 | 
			
		||||
    } else {
 | 
			
		||||
      return "w-full aspect-video";
 | 
			
		||||
    }
 | 
			
		||||
@ -220,59 +224,50 @@ export function RecordingView({
 | 
			
		||||
  return (
 | 
			
		||||
    <div ref={contentRef} className="size-full flex flex-col">
 | 
			
		||||
      <Toaster />
 | 
			
		||||
      <div className={`w-full h-10 flex items-center justify-between pr-1`}>
 | 
			
		||||
        <Button className="rounded-lg" onClick={() => navigate(-1)}>
 | 
			
		||||
          <IoMdArrowRoundBack className="size-5 mr-[10px]" />
 | 
			
		||||
          Back
 | 
			
		||||
      <div
 | 
			
		||||
        className={`w-full h-10 px-2 relative flex items-center justify-between`}
 | 
			
		||||
      >
 | 
			
		||||
        {isMobile && (
 | 
			
		||||
          <Logo className="absolute top-1 inset-x-1/2 -translate-x-1/2 h-8" />
 | 
			
		||||
        )}
 | 
			
		||||
        <Button
 | 
			
		||||
          className="flex items-center gap-2 rounded-lg"
 | 
			
		||||
          onClick={() => navigate(-1)}
 | 
			
		||||
        >
 | 
			
		||||
          <IoMdArrowRoundBack className="size-5" size="small" />
 | 
			
		||||
          {isDesktop && "Back"}
 | 
			
		||||
        </Button>
 | 
			
		||||
        <div className="flex items-center justify-end gap-2">
 | 
			
		||||
          {isMobile && (
 | 
			
		||||
            <Drawer>
 | 
			
		||||
              <DrawerTrigger asChild>
 | 
			
		||||
                <Button
 | 
			
		||||
                  className="rounded-lg capitalize flex items-center gap-2"
 | 
			
		||||
                  size="sm"
 | 
			
		||||
                  variant="secondary"
 | 
			
		||||
                >
 | 
			
		||||
                  <FaVideo className="text-muted-foreground" />
 | 
			
		||||
                  {mainCamera.replaceAll("_", " ")}
 | 
			
		||||
                </Button>
 | 
			
		||||
              </DrawerTrigger>
 | 
			
		||||
              <DrawerContent className="max-h-[75dvh] overflow-hidden">
 | 
			
		||||
                {allCameras.map((cam) => (
 | 
			
		||||
                  <FilterCheckBox
 | 
			
		||||
                    key={cam}
 | 
			
		||||
                    CheckIcon={FaCircle}
 | 
			
		||||
                    iconClassName="size-2"
 | 
			
		||||
                    label={cam.replaceAll("_", " ")}
 | 
			
		||||
                    isChecked={cam == mainCamera}
 | 
			
		||||
                    onCheckedChange={() => {
 | 
			
		||||
                      setPlaybackStart(currentTime);
 | 
			
		||||
                      setMainCamera(cam);
 | 
			
		||||
                    }}
 | 
			
		||||
                  />
 | 
			
		||||
                ))}
 | 
			
		||||
              </DrawerContent>
 | 
			
		||||
            </Drawer>
 | 
			
		||||
          )}
 | 
			
		||||
          <ExportDialog
 | 
			
		||||
            camera={mainCamera}
 | 
			
		||||
            currentTime={currentTime}
 | 
			
		||||
            latestTime={timeRange.end}
 | 
			
		||||
            mode={exportMode}
 | 
			
		||||
            range={exportRange}
 | 
			
		||||
            setRange={setExportRange}
 | 
			
		||||
            setMode={setExportMode}
 | 
			
		||||
          />
 | 
			
		||||
          <ReviewFilterGroup
 | 
			
		||||
            filters={["date", "general"]}
 | 
			
		||||
            reviewSummary={reviewSummary}
 | 
			
		||||
            filter={filter}
 | 
			
		||||
            onUpdateFilter={updateFilter}
 | 
			
		||||
            motionOnly={false}
 | 
			
		||||
            setMotionOnly={() => {}}
 | 
			
		||||
          <MobileCameraDrawer
 | 
			
		||||
            allCameras={allCameras}
 | 
			
		||||
            selected={mainCamera}
 | 
			
		||||
            onSelectCamera={(cam) => {
 | 
			
		||||
              setPlaybackStart(currentTime);
 | 
			
		||||
              setMainCamera(cam);
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
          {isDesktop && (
 | 
			
		||||
            <ExportDialog
 | 
			
		||||
              camera={mainCamera}
 | 
			
		||||
              currentTime={currentTime}
 | 
			
		||||
              latestTime={timeRange.end}
 | 
			
		||||
              mode={exportMode}
 | 
			
		||||
              range={exportRange}
 | 
			
		||||
              setRange={setExportRange}
 | 
			
		||||
              setMode={setExportMode}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
          {isDesktop && (
 | 
			
		||||
            <ReviewFilterGroup
 | 
			
		||||
              filters={["date", "general"]}
 | 
			
		||||
              reviewSummary={reviewSummary}
 | 
			
		||||
              filter={filter}
 | 
			
		||||
              onUpdateFilter={updateFilter}
 | 
			
		||||
              motionOnly={false}
 | 
			
		||||
              setMotionOnly={() => {}}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
          {isDesktop ? (
 | 
			
		||||
            <ToggleGroup
 | 
			
		||||
              className="*:px-3 *:py-4 *:rounded-md"
 | 
			
		||||
              type="single"
 | 
			
		||||
@ -297,12 +292,28 @@ export function RecordingView({
 | 
			
		||||
                <div className="">Events</div>
 | 
			
		||||
              </ToggleGroupItem>
 | 
			
		||||
            </ToggleGroup>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <MobileTimelineDrawer
 | 
			
		||||
              selected={timelineType ?? "timeline"}
 | 
			
		||||
              onSelect={setTimelineType}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
          <MobileReviewSettingsDrawer
 | 
			
		||||
            camera={mainCamera}
 | 
			
		||||
            filter={filter}
 | 
			
		||||
            currentTime={currentTime}
 | 
			
		||||
            latestTime={timeRange.end}
 | 
			
		||||
            mode={exportMode}
 | 
			
		||||
            range={exportRange}
 | 
			
		||||
            onUpdateFilter={updateFilter}
 | 
			
		||||
            setRange={setExportRange}
 | 
			
		||||
            setMode={setExportMode}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div
 | 
			
		||||
        className={`flex h-full my-2 justify-center overflow-hidden ${isDesktop ? "" : "flex-col"}`}
 | 
			
		||||
        className={`h-full flex my-2 justify-center overflow-hidden ${isDesktop ? "" : "flex-col gap-2"}`}
 | 
			
		||||
      >
 | 
			
		||||
        <div className="flex flex-1 flex-wrap">
 | 
			
		||||
          <div
 | 
			
		||||
@ -312,7 +323,7 @@ export function RecordingView({
 | 
			
		||||
              key={mainCamera}
 | 
			
		||||
              className={
 | 
			
		||||
                isDesktop
 | 
			
		||||
                  ? `flex justify-center items mb-5 ${mainCameraAspect == "tall" ? "h-[96%]" : "w-[82%]"}`
 | 
			
		||||
                  ? `flex justify-center mb-5 ${mainCameraAspect == "tall" ? "h-full" : "w-[78%]"}`
 | 
			
		||||
                  : `w-full ${mainCameraAspect == "wide" ? "" : "aspect-video"}`
 | 
			
		||||
              }
 | 
			
		||||
            >
 | 
			
		||||
@ -339,7 +350,7 @@ export function RecordingView({
 | 
			
		||||
            </div>
 | 
			
		||||
            {isDesktop && (
 | 
			
		||||
              <div
 | 
			
		||||
                className={`flex justify-center gap-2 ${mainCameraAspect == "tall" ? "h-full flex-col overflow-y-auto items-center" : "w-full overflow-x-auto"}`}
 | 
			
		||||
                className={`flex gap-2 ${mainCameraAspect == "tall" ? "h-full w-[16%] flex-col overflow-y-auto" : "w-full justify-center overflow-x-auto"}`}
 | 
			
		||||
              >
 | 
			
		||||
                {allCameras.map((cam) => {
 | 
			
		||||
                  if (cam !== mainCamera) {
 | 
			
		||||
@ -347,7 +358,9 @@ export function RecordingView({
 | 
			
		||||
                      <div key={cam}>
 | 
			
		||||
                        <PreviewPlayer
 | 
			
		||||
                          className={
 | 
			
		||||
                            mainCameraAspect == "tall" ? "" : "size-full"
 | 
			
		||||
                            mainCameraAspect == "tall"
 | 
			
		||||
                              ? "size-full"
 | 
			
		||||
                              : "size-full"
 | 
			
		||||
                          }
 | 
			
		||||
                          camera={cam}
 | 
			
		||||
                          timeRange={currentTimeRange}
 | 
			
		||||
@ -369,36 +382,12 @@ export function RecordingView({
 | 
			
		||||
            )}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        {isMobile && (
 | 
			
		||||
          <ToggleGroup
 | 
			
		||||
            className="py-2 *:px-3 *:py-4 *:rounded-md"
 | 
			
		||||
            type="single"
 | 
			
		||||
            size="sm"
 | 
			
		||||
            value={timelineType}
 | 
			
		||||
            onValueChange={(value: TimelineType) =>
 | 
			
		||||
              value ? setTimelineType(value) : null
 | 
			
		||||
            } // don't allow the severity to be unselected
 | 
			
		||||
          >
 | 
			
		||||
            <ToggleGroupItem
 | 
			
		||||
              className={`${timelineType == "timeline" ? "" : "text-gray-500"}`}
 | 
			
		||||
              value="timeline"
 | 
			
		||||
              aria-label="Select timeline"
 | 
			
		||||
            >
 | 
			
		||||
              <div className="">Timeline</div>
 | 
			
		||||
            </ToggleGroupItem>
 | 
			
		||||
            <ToggleGroupItem
 | 
			
		||||
              className={`${timelineType == "events" ? "" : "text-gray-500"}`}
 | 
			
		||||
              value="events"
 | 
			
		||||
              aria-label="Select events"
 | 
			
		||||
            >
 | 
			
		||||
              <div className="">Events</div>
 | 
			
		||||
            </ToggleGroupItem>
 | 
			
		||||
          </ToggleGroup>
 | 
			
		||||
        )}
 | 
			
		||||
        <Timeline
 | 
			
		||||
          contentRef={contentRef}
 | 
			
		||||
          mainCamera={mainCamera}
 | 
			
		||||
          timelineType={timelineType ?? "timeline"}
 | 
			
		||||
          timelineType={
 | 
			
		||||
            (exportRange == undefined ? timelineType : "timeline") ?? "timeline"
 | 
			
		||||
          }
 | 
			
		||||
          timeRange={timeRange}
 | 
			
		||||
          mainCameraReviewItems={mainCameraReviewItems}
 | 
			
		||||
          currentTime={currentTime}
 | 
			
		||||
@ -461,15 +450,17 @@ function Timeline({
 | 
			
		||||
    }
 | 
			
		||||
  }, [exportRange, exportStart, exportEnd, setExportRange, setCurrentTime]);
 | 
			
		||||
 | 
			
		||||
  if (exportRange != undefined || timelineType == "timeline") {
 | 
			
		||||
    return (
 | 
			
		||||
      <div
 | 
			
		||||
        className={
 | 
			
		||||
          isDesktop
 | 
			
		||||
            ? "w-[100px] mt-2 overflow-y-auto no-scrollbar"
 | 
			
		||||
            : "flex-grow overflow-hidden"
 | 
			
		||||
        }
 | 
			
		||||
      >
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={`${
 | 
			
		||||
        isDesktop
 | 
			
		||||
          ? `${timelineType == "timeline" ? "w-[100px]" : "w-60"} mt-2 overflow-y-auto no-scrollbar`
 | 
			
		||||
          : "flex-grow overflow-hidden"
 | 
			
		||||
      } relative`}
 | 
			
		||||
    >
 | 
			
		||||
      <div className="absolute top-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-b from-secondary to-transparent pointer-events-none"></div>
 | 
			
		||||
      <div className="absolute bottom-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-t from-secondary to-transparent pointer-events-none"></div>
 | 
			
		||||
      {timelineType == "timeline" ? (
 | 
			
		||||
        <MotionReviewTimeline
 | 
			
		||||
          segmentDuration={30}
 | 
			
		||||
          timestampSpread={15}
 | 
			
		||||
@ -490,30 +481,24 @@ function Timeline({
 | 
			
		||||
          contentRef={contentRef}
 | 
			
		||||
          onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
      ) : (
 | 
			
		||||
        <div className="h-full flex flex-col gap-4 overflow-auto p-4 bg-secondary">
 | 
			
		||||
          {mainCameraReviewItems.map((review) => {
 | 
			
		||||
            if (review.severity == "significant_motion") {
 | 
			
		||||
              return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={`${isDesktop ? "w-60" : "w-full"} h-full relative p-4 flex flex-col gap-4 bg-secondary overflow-auto`}
 | 
			
		||||
    >
 | 
			
		||||
      <div className="absolute top-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-b from-secondary to-transparent pointer-events-none"></div>
 | 
			
		||||
      <div className="absolute bottom-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-t from-secondary to-transparent pointer-events-none"></div>
 | 
			
		||||
      {mainCameraReviewItems.map((review) => {
 | 
			
		||||
        if (review.severity == "significant_motion") {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
          <ReviewCard
 | 
			
		||||
            key={review.id}
 | 
			
		||||
            event={review}
 | 
			
		||||
            currentTime={currentTime}
 | 
			
		||||
            onClick={() => setCurrentTime(review.start_time)}
 | 
			
		||||
          />
 | 
			
		||||
        );
 | 
			
		||||
      })}
 | 
			
		||||
            return (
 | 
			
		||||
              <ReviewCard
 | 
			
		||||
                key={review.id}
 | 
			
		||||
                event={review}
 | 
			
		||||
                currentTime={currentTime}
 | 
			
		||||
                onClick={() => setCurrentTime(review.start_time)}
 | 
			
		||||
              />
 | 
			
		||||
            );
 | 
			
		||||
          })}
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user