mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Refactor search details into Explore Page (#13665)
This commit is contained in:
		
							parent
							
								
									e016bd6900
								
							
						
					
					
						commit
						3972642ba0
					
				@ -251,6 +251,61 @@ def events():
 | 
				
			|||||||
    return jsonify(list(events))
 | 
					    return jsonify(list(events))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@EventBp.route("/events/explore")
 | 
				
			||||||
 | 
					def events_explore():
 | 
				
			||||||
 | 
					    limit = request.args.get("limit", 10, type=int)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    subquery = Event.select(
 | 
				
			||||||
 | 
					        Event.id,
 | 
				
			||||||
 | 
					        Event.camera,
 | 
				
			||||||
 | 
					        Event.label,
 | 
				
			||||||
 | 
					        Event.zones,
 | 
				
			||||||
 | 
					        Event.start_time,
 | 
				
			||||||
 | 
					        Event.end_time,
 | 
				
			||||||
 | 
					        Event.has_clip,
 | 
				
			||||||
 | 
					        Event.has_snapshot,
 | 
				
			||||||
 | 
					        Event.plus_id,
 | 
				
			||||||
 | 
					        Event.retain_indefinitely,
 | 
				
			||||||
 | 
					        Event.sub_label,
 | 
				
			||||||
 | 
					        Event.top_score,
 | 
				
			||||||
 | 
					        Event.false_positive,
 | 
				
			||||||
 | 
					        Event.box,
 | 
				
			||||||
 | 
					        Event.data,
 | 
				
			||||||
 | 
					        fn.rank()
 | 
				
			||||||
 | 
					        .over(partition_by=[Event.label], order_by=[Event.start_time.desc()])
 | 
				
			||||||
 | 
					        .alias("rank"),
 | 
				
			||||||
 | 
					        fn.COUNT(Event.id).over(partition_by=[Event.label]).alias("event_count"),
 | 
				
			||||||
 | 
					    ).alias("subquery")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    query = (
 | 
				
			||||||
 | 
					        Event.select(
 | 
				
			||||||
 | 
					            subquery.c.id,
 | 
				
			||||||
 | 
					            subquery.c.camera,
 | 
				
			||||||
 | 
					            subquery.c.label,
 | 
				
			||||||
 | 
					            subquery.c.zones,
 | 
				
			||||||
 | 
					            subquery.c.start_time,
 | 
				
			||||||
 | 
					            subquery.c.end_time,
 | 
				
			||||||
 | 
					            subquery.c.has_clip,
 | 
				
			||||||
 | 
					            subquery.c.has_snapshot,
 | 
				
			||||||
 | 
					            subquery.c.plus_id,
 | 
				
			||||||
 | 
					            subquery.c.retain_indefinitely,
 | 
				
			||||||
 | 
					            subquery.c.sub_label,
 | 
				
			||||||
 | 
					            subquery.c.top_score,
 | 
				
			||||||
 | 
					            subquery.c.false_positive,
 | 
				
			||||||
 | 
					            subquery.c.box,
 | 
				
			||||||
 | 
					            subquery.c.data,
 | 
				
			||||||
 | 
					            subquery.c.event_count,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .from_(subquery)
 | 
				
			||||||
 | 
					        .where(subquery.c.rank <= limit)
 | 
				
			||||||
 | 
					        .order_by(subquery.c.event_count.desc(), subquery.c.start_time.desc())
 | 
				
			||||||
 | 
					        .dicts()
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    events = query.iterator()
 | 
				
			||||||
 | 
					    return jsonify(list(events))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@EventBp.route("/event_ids")
 | 
					@EventBp.route("/event_ids")
 | 
				
			||||||
def event_ids():
 | 
					def event_ids():
 | 
				
			||||||
    idString = request.args.get("ids")
 | 
					    idString = request.args.get("ids")
 | 
				
			||||||
@ -317,7 +372,10 @@ def events_search():
 | 
				
			|||||||
        Event.zones,
 | 
					        Event.zones,
 | 
				
			||||||
        Event.start_time,
 | 
					        Event.start_time,
 | 
				
			||||||
        Event.end_time,
 | 
					        Event.end_time,
 | 
				
			||||||
 | 
					        Event.has_clip,
 | 
				
			||||||
 | 
					        Event.has_snapshot,
 | 
				
			||||||
        Event.data,
 | 
					        Event.data,
 | 
				
			||||||
 | 
					        Event.plus_id,
 | 
				
			||||||
        ReviewSegment.thumb_path,
 | 
					        ReviewSegment.thumb_path,
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -13,7 +13,7 @@ import { isPWA } from "./utils/isPWA";
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const Live = lazy(() => import("@/pages/Live"));
 | 
					const Live = lazy(() => import("@/pages/Live"));
 | 
				
			||||||
const Events = lazy(() => import("@/pages/Events"));
 | 
					const Events = lazy(() => import("@/pages/Events"));
 | 
				
			||||||
const Search = lazy(() => import("@/pages/Search"));
 | 
					const Explore = lazy(() => import("@/pages/Explore"));
 | 
				
			||||||
const Exports = lazy(() => import("@/pages/Exports"));
 | 
					const Exports = lazy(() => import("@/pages/Exports"));
 | 
				
			||||||
const SubmitPlus = lazy(() => import("@/pages/SubmitPlus"));
 | 
					const SubmitPlus = lazy(() => import("@/pages/SubmitPlus"));
 | 
				
			||||||
const ConfigEditor = lazy(() => import("@/pages/ConfigEditor"));
 | 
					const ConfigEditor = lazy(() => import("@/pages/ConfigEditor"));
 | 
				
			||||||
@ -45,7 +45,7 @@ function App() {
 | 
				
			|||||||
                  <Route index element={<Live />} />
 | 
					                  <Route index element={<Live />} />
 | 
				
			||||||
                  <Route path="/events" element={<Redirect to="/review" />} />
 | 
					                  <Route path="/events" element={<Redirect to="/review" />} />
 | 
				
			||||||
                  <Route path="/review" element={<Events />} />
 | 
					                  <Route path="/review" element={<Events />} />
 | 
				
			||||||
                  <Route path="/search" element={<Search />} />
 | 
					                  <Route path="/explore" element={<Explore />} />
 | 
				
			||||||
                  <Route path="/export" element={<Exports />} />
 | 
					                  <Route path="/export" element={<Exports />} />
 | 
				
			||||||
                  <Route path="/plus" element={<SubmitPlus />} />
 | 
					                  <Route path="/plus" element={<SubmitPlus />} />
 | 
				
			||||||
                  <Route path="/system" element={<System />} />
 | 
					                  <Route path="/system" element={<System />} />
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										185
									
								
								web/src/components/card/SearchThumbnail.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								web/src/components/card/SearchThumbnail.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,185 @@
 | 
				
			|||||||
 | 
					import React, { useCallback, useEffect, useMemo, useState } from "react";
 | 
				
			||||||
 | 
					import { useApiHost } from "@/api";
 | 
				
			||||||
 | 
					import { getIconForLabel } from "@/utils/iconUtil";
 | 
				
			||||||
 | 
					import TimeAgo from "../dynamic/TimeAgo";
 | 
				
			||||||
 | 
					import useSWR from "swr";
 | 
				
			||||||
 | 
					import { FrigateConfig } from "@/types/frigateConfig";
 | 
				
			||||||
 | 
					import { isIOS, isMobile, isSafari } from "react-device-detect";
 | 
				
			||||||
 | 
					import Chip from "@/components/indicators/Chip";
 | 
				
			||||||
 | 
					import { useFormattedTimestamp } from "@/hooks/use-date-utils";
 | 
				
			||||||
 | 
					import useImageLoaded from "@/hooks/use-image-loaded";
 | 
				
			||||||
 | 
					import { useSwipeable } from "react-swipeable";
 | 
				
			||||||
 | 
					import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
 | 
				
			||||||
 | 
					import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
 | 
				
			||||||
 | 
					import ActivityIndicator from "../indicators/activity-indicator";
 | 
				
			||||||
 | 
					import { capitalizeFirstLetter } from "@/utils/stringUtil";
 | 
				
			||||||
 | 
					import { SearchResult } from "@/types/search";
 | 
				
			||||||
 | 
					import useContextMenu from "@/hooks/use-contextmenu";
 | 
				
			||||||
 | 
					import { cn } from "@/lib/utils";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type SearchThumbnailProps = {
 | 
				
			||||||
 | 
					  searchResult: SearchResult;
 | 
				
			||||||
 | 
					  scrollLock?: boolean;
 | 
				
			||||||
 | 
					  findSimilar: () => void;
 | 
				
			||||||
 | 
					  onClick: (searchResult: SearchResult, detail: boolean) => void;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function SearchThumbnail({
 | 
				
			||||||
 | 
					  searchResult,
 | 
				
			||||||
 | 
					  scrollLock = false,
 | 
				
			||||||
 | 
					  findSimilar,
 | 
				
			||||||
 | 
					  onClick,
 | 
				
			||||||
 | 
					}: SearchThumbnailProps) {
 | 
				
			||||||
 | 
					  const apiHost = useApiHost();
 | 
				
			||||||
 | 
					  const { data: config } = useSWR<FrigateConfig>("config");
 | 
				
			||||||
 | 
					  const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // interaction
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const swipeHandlers = useSwipeable({
 | 
				
			||||||
 | 
					    onSwipedLeft: () => setDetails(false),
 | 
				
			||||||
 | 
					    onSwipedRight: () => setDetails(true),
 | 
				
			||||||
 | 
					    preventScrollOnSwipe: true,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useContextMenu(imgRef, findSimilar);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Hover Details
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout | null>();
 | 
				
			||||||
 | 
					  const [details, setDetails] = useState(false);
 | 
				
			||||||
 | 
					  const [tooltipHovering, setTooltipHovering] = useState(false);
 | 
				
			||||||
 | 
					  const showingMoreDetail = useMemo(
 | 
				
			||||||
 | 
					    () => details && !tooltipHovering,
 | 
				
			||||||
 | 
					    [details, tooltipHovering],
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					  const [isHovered, setIsHovered] = useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleOnClick = useCallback(
 | 
				
			||||||
 | 
					    (e: React.MouseEvent<HTMLDivElement>) => {
 | 
				
			||||||
 | 
					      if (!showingMoreDetail) {
 | 
				
			||||||
 | 
					        onClick(searchResult, e.metaKey);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    [searchResult, showingMoreDetail, onClick],
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (isHovered && scrollLock) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (isHovered && !tooltipHovering) {
 | 
				
			||||||
 | 
					      setHoverTimeout(
 | 
				
			||||||
 | 
					        setTimeout(() => {
 | 
				
			||||||
 | 
					          setDetails(true);
 | 
				
			||||||
 | 
					          setHoverTimeout(null);
 | 
				
			||||||
 | 
					        }, 500),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      if (hoverTimeout) {
 | 
				
			||||||
 | 
					        clearTimeout(hoverTimeout);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      setDetails(false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    // we know that these deps are correct
 | 
				
			||||||
 | 
					    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
				
			||||||
 | 
					  }, [isHovered, scrollLock, tooltipHovering]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // date
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const formattedDate = useFormattedTimestamp(
 | 
				
			||||||
 | 
					    searchResult.start_time,
 | 
				
			||||||
 | 
					    config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p",
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      className="relative size-full cursor-pointer"
 | 
				
			||||||
 | 
					      onMouseOver={isMobile ? undefined : () => setIsHovered(true)}
 | 
				
			||||||
 | 
					      onMouseLeave={isMobile ? undefined : () => setIsHovered(false)}
 | 
				
			||||||
 | 
					      onClick={handleOnClick}
 | 
				
			||||||
 | 
					      {...swipeHandlers}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <ImageLoadingIndicator
 | 
				
			||||||
 | 
					        className="absolute inset-0"
 | 
				
			||||||
 | 
					        imgLoaded={imgLoaded}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      <div className={`${imgLoaded ? "visible" : "invisible"}`}>
 | 
				
			||||||
 | 
					        <img
 | 
				
			||||||
 | 
					          ref={imgRef}
 | 
				
			||||||
 | 
					          className={cn(
 | 
				
			||||||
 | 
					            "size-full select-none opacity-100 transition-opacity",
 | 
				
			||||||
 | 
					            searchResult.search_source == "thumbnail" && "object-contain",
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					          style={
 | 
				
			||||||
 | 
					            isIOS
 | 
				
			||||||
 | 
					              ? {
 | 
				
			||||||
 | 
					                  WebkitUserSelect: "none",
 | 
				
			||||||
 | 
					                  WebkitTouchCallout: "none",
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					              : undefined
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          draggable={false}
 | 
				
			||||||
 | 
					          src={`${apiHost}api/events/${searchResult.id}/thumbnail.jpg`}
 | 
				
			||||||
 | 
					          loading={isSafari ? "eager" : "lazy"}
 | 
				
			||||||
 | 
					          onLoad={() => {
 | 
				
			||||||
 | 
					            onImgLoad();
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div className="absolute left-0 top-2 z-40">
 | 
				
			||||||
 | 
					          <Tooltip>
 | 
				
			||||||
 | 
					            <div
 | 
				
			||||||
 | 
					              className="flex"
 | 
				
			||||||
 | 
					              onMouseEnter={() => setTooltipHovering(true)}
 | 
				
			||||||
 | 
					              onMouseLeave={() => setTooltipHovering(false)}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <TooltipTrigger asChild>
 | 
				
			||||||
 | 
					                <div className="mx-3 pb-1 text-sm text-white">
 | 
				
			||||||
 | 
					                  {
 | 
				
			||||||
 | 
					                    <>
 | 
				
			||||||
 | 
					                      <Chip
 | 
				
			||||||
 | 
					                        className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500`}
 | 
				
			||||||
 | 
					                        onClick={() => onClick(searchResult, true)}
 | 
				
			||||||
 | 
					                      >
 | 
				
			||||||
 | 
					                        {getIconForLabel(
 | 
				
			||||||
 | 
					                          searchResult.label,
 | 
				
			||||||
 | 
					                          "size-3 text-white",
 | 
				
			||||||
 | 
					                        )}
 | 
				
			||||||
 | 
					                      </Chip>
 | 
				
			||||||
 | 
					                    </>
 | 
				
			||||||
 | 
					                  }
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </TooltipTrigger>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <TooltipContent className="capitalize">
 | 
				
			||||||
 | 
					              {[...new Set([searchResult.label])]
 | 
				
			||||||
 | 
					                .filter(
 | 
				
			||||||
 | 
					                  (item) => item !== undefined && !item.includes("-verified"),
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                .map((text) => capitalizeFirstLetter(text))
 | 
				
			||||||
 | 
					                .sort()
 | 
				
			||||||
 | 
					                .join(", ")
 | 
				
			||||||
 | 
					                .replaceAll("-verified", "")}
 | 
				
			||||||
 | 
					            </TooltipContent>
 | 
				
			||||||
 | 
					          </Tooltip>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div className="rounded-t-l pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/60 to-transparent"></div>
 | 
				
			||||||
 | 
					        <div className="rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[20%] w-full bg-gradient-to-t from-black/60 to-transparent">
 | 
				
			||||||
 | 
					          <div className="mx-3 flex h-full items-end justify-between pb-1 text-sm text-white">
 | 
				
			||||||
 | 
					            {searchResult.end_time ? (
 | 
				
			||||||
 | 
					              <TimeAgo time={searchResult.start_time * 1000} dense />
 | 
				
			||||||
 | 
					            ) : (
 | 
				
			||||||
 | 
					              <div>
 | 
				
			||||||
 | 
					                <ActivityIndicator size={24} />
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					            {formattedDate}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -110,7 +110,7 @@ export function CalendarRangeFilterButton({
 | 
				
			|||||||
        className={`${range == undefined ? "text-secondary-foreground" : "text-selected-foreground"}`}
 | 
					        className={`${range == undefined ? "text-secondary-foreground" : "text-selected-foreground"}`}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
      <div
 | 
					      <div
 | 
				
			||||||
        className={`hidden md:block ${range == undefined ? "text-primary" : "text-selected-foreground"}`}
 | 
					        className={`${range == undefined ? "text-primary" : "text-selected-foreground"}`}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        {range == undefined ? defaultText : selectedDate}
 | 
					        {range == undefined ? defaultText : selectedDate}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
import { Button } from "../ui/button";
 | 
					import { Button } from "../ui/button";
 | 
				
			||||||
import { CameraGroupConfig } from "@/types/frigateConfig";
 | 
					import { CameraGroupConfig } from "@/types/frigateConfig";
 | 
				
			||||||
import { useState } from "react";
 | 
					import { useMemo, useState } from "react";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  DropdownMenu,
 | 
					  DropdownMenu,
 | 
				
			||||||
  DropdownMenuContent,
 | 
					  DropdownMenuContent,
 | 
				
			||||||
@ -17,12 +17,14 @@ type CameraFilterButtonProps = {
 | 
				
			|||||||
  allCameras: string[];
 | 
					  allCameras: string[];
 | 
				
			||||||
  groups: [string, CameraGroupConfig][];
 | 
					  groups: [string, CameraGroupConfig][];
 | 
				
			||||||
  selectedCameras: string[] | undefined;
 | 
					  selectedCameras: string[] | undefined;
 | 
				
			||||||
 | 
					  hideText?: boolean;
 | 
				
			||||||
  updateCameraFilter: (cameras: string[] | undefined) => void;
 | 
					  updateCameraFilter: (cameras: string[] | undefined) => void;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export function CamerasFilterButton({
 | 
					export function CamerasFilterButton({
 | 
				
			||||||
  allCameras,
 | 
					  allCameras,
 | 
				
			||||||
  groups,
 | 
					  groups,
 | 
				
			||||||
  selectedCameras,
 | 
					  selectedCameras,
 | 
				
			||||||
 | 
					  hideText = isMobile,
 | 
				
			||||||
  updateCameraFilter,
 | 
					  updateCameraFilter,
 | 
				
			||||||
}: CameraFilterButtonProps) {
 | 
					}: CameraFilterButtonProps) {
 | 
				
			||||||
  const [open, setOpen] = useState(false);
 | 
					  const [open, setOpen] = useState(false);
 | 
				
			||||||
@ -30,6 +32,18 @@ export function CamerasFilterButton({
 | 
				
			|||||||
    selectedCameras,
 | 
					    selectedCameras,
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const buttonText = useMemo(() => {
 | 
				
			||||||
 | 
					    if (isMobile) {
 | 
				
			||||||
 | 
					      return "Cameras";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!selectedCameras || selectedCameras.length == 0) {
 | 
				
			||||||
 | 
					      return "All Cameras";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return `${selectedCameras.includes("birdseye") ? selectedCameras.length - 1 : selectedCameras.length} Camera${selectedCameras.length !== 1 ? "s" : ""}`;
 | 
				
			||||||
 | 
					  }, [selectedCameras]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const trigger = (
 | 
					  const trigger = (
 | 
				
			||||||
    <Button
 | 
					    <Button
 | 
				
			||||||
      className="flex items-center gap-2 capitalize"
 | 
					      className="flex items-center gap-2 capitalize"
 | 
				
			||||||
@ -40,11 +54,9 @@ export function CamerasFilterButton({
 | 
				
			|||||||
        className={`${(selectedCameras?.length ?? 0) >= 1 ? "text-selected-foreground" : "text-secondary-foreground"}`}
 | 
					        className={`${(selectedCameras?.length ?? 0) >= 1 ? "text-selected-foreground" : "text-secondary-foreground"}`}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
      <div
 | 
					      <div
 | 
				
			||||||
        className={`hidden md:block ${selectedCameras?.length ? "text-selected-foreground" : "text-primary"}`}
 | 
					        className={`${hideText ? "hidden" : ""} ${selectedCameras?.length ? "text-selected-foreground" : "text-primary"}`}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        {selectedCameras == undefined
 | 
					        {buttonText}
 | 
				
			||||||
          ? "All Cameras"
 | 
					 | 
				
			||||||
          : `${selectedCameras.includes("birdseye") ? selectedCameras.length - 1 : selectedCameras.length} Camera${selectedCameras.length !== 1 ? "s" : ""}`}
 | 
					 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </Button>
 | 
					    </Button>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
				
			|||||||
@ -5,7 +5,6 @@ import { FrigateConfig } from "@/types/frigateConfig";
 | 
				
			|||||||
import { useCallback, useMemo, useState } from "react";
 | 
					import { useCallback, useMemo, useState } from "react";
 | 
				
			||||||
import { DropdownMenuSeparator } from "../ui/dropdown-menu";
 | 
					import { DropdownMenuSeparator } from "../ui/dropdown-menu";
 | 
				
			||||||
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
 | 
					import { getEndOfDayTimestamp } from "@/utils/dateUtil";
 | 
				
			||||||
import { FaFilter } from "react-icons/fa";
 | 
					 | 
				
			||||||
import { isMobile } from "react-device-detect";
 | 
					import { isMobile } from "react-device-detect";
 | 
				
			||||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
 | 
					import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
 | 
				
			||||||
import { Switch } from "../ui/switch";
 | 
					import { Switch } from "../ui/switch";
 | 
				
			||||||
@ -19,6 +18,8 @@ import { DateRange } from "react-day-picker";
 | 
				
			|||||||
import { cn } from "@/lib/utils";
 | 
					import { cn } from "@/lib/utils";
 | 
				
			||||||
import SubFilterIcon from "../icons/SubFilterIcon";
 | 
					import SubFilterIcon from "../icons/SubFilterIcon";
 | 
				
			||||||
import { FaLocationDot } from "react-icons/fa6";
 | 
					import { FaLocationDot } from "react-icons/fa6";
 | 
				
			||||||
 | 
					import { MdLabel } from "react-icons/md";
 | 
				
			||||||
 | 
					import SearchSourceIcon from "../icons/SearchSourceIcon";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const SEARCH_FILTERS = [
 | 
					const SEARCH_FILTERS = [
 | 
				
			||||||
  "cameras",
 | 
					  "cameras",
 | 
				
			||||||
@ -154,12 +155,18 @@ export default function SearchFilterGroup({
 | 
				
			|||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className={cn("flex justify-center gap-2", className)}>
 | 
					    <div
 | 
				
			||||||
 | 
					      className={cn(
 | 
				
			||||||
 | 
					        "scrollbar-container flex justify-center gap-2 overflow-x-auto",
 | 
				
			||||||
 | 
					        className,
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
      {filters.includes("cameras") && (
 | 
					      {filters.includes("cameras") && (
 | 
				
			||||||
        <CamerasFilterButton
 | 
					        <CamerasFilterButton
 | 
				
			||||||
          allCameras={filterValues.cameras}
 | 
					          allCameras={filterValues.cameras}
 | 
				
			||||||
          groups={groups}
 | 
					          groups={groups}
 | 
				
			||||||
          selectedCameras={filter?.cameras}
 | 
					          selectedCameras={filter?.cameras}
 | 
				
			||||||
 | 
					          hideText={false}
 | 
				
			||||||
          updateCameraFilter={(newCameras) => {
 | 
					          updateCameraFilter={(newCameras) => {
 | 
				
			||||||
            onUpdateFilter({ ...filter, cameras: newCameras });
 | 
					            onUpdateFilter({ ...filter, cameras: newCameras });
 | 
				
			||||||
          }}
 | 
					          }}
 | 
				
			||||||
@ -175,19 +182,10 @@ export default function SearchFilterGroup({
 | 
				
			|||||||
                  to: new Date(filter.before * 1000),
 | 
					                  to: new Date(filter.before * 1000),
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
          defaultText="All Dates"
 | 
					          defaultText={isMobile ? "Dates" : "All Dates"}
 | 
				
			||||||
          updateSelectedRange={onUpdateSelectedRange}
 | 
					          updateSelectedRange={onUpdateSelectedRange}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
      {filters.includes("general") && (
 | 
					 | 
				
			||||||
        <GeneralFilterButton
 | 
					 | 
				
			||||||
          allLabels={filterValues.labels}
 | 
					 | 
				
			||||||
          selectedLabels={filter?.labels}
 | 
					 | 
				
			||||||
          updateLabelFilter={(newLabels) => {
 | 
					 | 
				
			||||||
            onUpdateFilter({ ...filter, labels: newLabels });
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
      {filters.includes("zone") && allZones.length > 0 && (
 | 
					      {filters.includes("zone") && allZones.length > 0 && (
 | 
				
			||||||
        <ZoneFilterButton
 | 
					        <ZoneFilterButton
 | 
				
			||||||
          allZones={filterValues.zones}
 | 
					          allZones={filterValues.zones}
 | 
				
			||||||
@ -197,6 +195,15 @@ export default function SearchFilterGroup({
 | 
				
			|||||||
          }
 | 
					          }
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
 | 
					      {filters.includes("general") && (
 | 
				
			||||||
 | 
					        <GeneralFilterButton
 | 
				
			||||||
 | 
					          allLabels={filterValues.labels}
 | 
				
			||||||
 | 
					          selectedLabels={filter?.labels}
 | 
				
			||||||
 | 
					          updateLabelFilter={(newLabels) => {
 | 
				
			||||||
 | 
					            onUpdateFilter({ ...filter, labels: newLabels });
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
      {filters.includes("sub") && (
 | 
					      {filters.includes("sub") && (
 | 
				
			||||||
        <SubFilterButton
 | 
					        <SubFilterButton
 | 
				
			||||||
          allSubLabels={allSubLabels}
 | 
					          allSubLabels={allSubLabels}
 | 
				
			||||||
@ -235,19 +242,35 @@ function GeneralFilterButton({
 | 
				
			|||||||
    selectedLabels,
 | 
					    selectedLabels,
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const buttonText = useMemo(() => {
 | 
				
			||||||
 | 
					    if (isMobile) {
 | 
				
			||||||
 | 
					      return "Labels";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!selectedLabels || selectedLabels.length == 0) {
 | 
				
			||||||
 | 
					      return "All Labels";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (selectedLabels.length == 1) {
 | 
				
			||||||
 | 
					      return selectedLabels[0];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return `${selectedLabels.length} Labels`;
 | 
				
			||||||
 | 
					  }, [selectedLabels]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const trigger = (
 | 
					  const trigger = (
 | 
				
			||||||
    <Button
 | 
					    <Button
 | 
				
			||||||
      size="sm"
 | 
					      size="sm"
 | 
				
			||||||
      variant={selectedLabels?.length ? "select" : "default"}
 | 
					      variant={selectedLabels?.length ? "select" : "default"}
 | 
				
			||||||
      className="flex items-center gap-2 capitalize"
 | 
					      className="flex items-center gap-2 capitalize"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <FaFilter
 | 
					      <MdLabel
 | 
				
			||||||
        className={`${selectedLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
 | 
					        className={`${selectedLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
      <div
 | 
					      <div
 | 
				
			||||||
        className={`hidden md:block ${selectedLabels?.length ? "text-selected-foreground" : "text-primary"}`}
 | 
					        className={`${selectedLabels?.length ? "text-selected-foreground" : "text-primary"}`}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        Filter
 | 
					        {buttonText}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </Button>
 | 
					    </Button>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
@ -405,6 +428,22 @@ function ZoneFilterButton({
 | 
				
			|||||||
    selectedZones,
 | 
					    selectedZones,
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const buttonText = useMemo(() => {
 | 
				
			||||||
 | 
					    if (isMobile) {
 | 
				
			||||||
 | 
					      return "Zones";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!selectedZones || selectedZones.length == 0) {
 | 
				
			||||||
 | 
					      return "All Zones";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (selectedZones.length == 1) {
 | 
				
			||||||
 | 
					      return selectedZones[0];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return `${selectedZones.length} Zones`;
 | 
				
			||||||
 | 
					  }, [selectedZones]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const trigger = (
 | 
					  const trigger = (
 | 
				
			||||||
    <Button
 | 
					    <Button
 | 
				
			||||||
      size="sm"
 | 
					      size="sm"
 | 
				
			||||||
@ -415,11 +454,9 @@ function ZoneFilterButton({
 | 
				
			|||||||
        className={`${selectedZones?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
 | 
					        className={`${selectedZones?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
      <div
 | 
					      <div
 | 
				
			||||||
        className={`hidden md:block ${selectedZones?.length ? "text-selected-foreground" : "text-primary"}`}
 | 
					        className={`${selectedZones?.length ? "text-selected-foreground" : "text-primary"}`}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        {selectedZones?.length
 | 
					        {buttonText}
 | 
				
			||||||
          ? `${selectedZones.length} Zone${selectedZones.length > 1 ? "s" : ""}`
 | 
					 | 
				
			||||||
          : "All Zones"}
 | 
					 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </Button>
 | 
					    </Button>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
@ -585,6 +622,22 @@ function SubFilterButton({
 | 
				
			|||||||
    string[] | undefined
 | 
					    string[] | undefined
 | 
				
			||||||
  >(selectedSubLabels);
 | 
					  >(selectedSubLabels);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const buttonText = useMemo(() => {
 | 
				
			||||||
 | 
					    if (isMobile) {
 | 
				
			||||||
 | 
					      return "Sub Labels";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!selectedSubLabels || selectedSubLabels.length == 0) {
 | 
				
			||||||
 | 
					      return "All Sub Labels";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (selectedSubLabels.length == 1) {
 | 
				
			||||||
 | 
					      return selectedSubLabels[0];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return `${selectedSubLabels.length} Sub Labels`;
 | 
				
			||||||
 | 
					  }, [selectedSubLabels]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const trigger = (
 | 
					  const trigger = (
 | 
				
			||||||
    <Button
 | 
					    <Button
 | 
				
			||||||
      size="sm"
 | 
					      size="sm"
 | 
				
			||||||
@ -595,11 +648,9 @@ function SubFilterButton({
 | 
				
			|||||||
        className={`${selectedSubLabels?.length || selectedSubLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
 | 
					        className={`${selectedSubLabels?.length || selectedSubLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
      <div
 | 
					      <div
 | 
				
			||||||
        className={`hidden md:block ${selectedSubLabels?.length ? "text-selected-foreground" : "text-primary"}`}
 | 
					        className={`${selectedSubLabels?.length ? "text-selected-foreground" : "text-primary"}`}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        {selectedSubLabels?.length
 | 
					        {buttonText}
 | 
				
			||||||
          ? `${selectedSubLabels.length} Sub Labels`
 | 
					 | 
				
			||||||
          : "All Sub Labels"}
 | 
					 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </Button>
 | 
					    </Button>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
@ -745,17 +796,34 @@ export function SubFilterContent({
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type SearchTypeButtonProps = {
 | 
					type SearchTypeButtonProps = {
 | 
				
			||||||
  selectedSearchSources: SearchSource[];
 | 
					  selectedSearchSources: SearchSource[] | undefined;
 | 
				
			||||||
  updateSearchSourceFilter: (sources: SearchSource[]) => void;
 | 
					  updateSearchSourceFilter: (sources: SearchSource[] | undefined) => void;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
function SearchTypeButton({
 | 
					function SearchTypeButton({
 | 
				
			||||||
  selectedSearchSources,
 | 
					  selectedSearchSources,
 | 
				
			||||||
  updateSearchSourceFilter,
 | 
					  updateSearchSourceFilter,
 | 
				
			||||||
}: SearchTypeButtonProps) {
 | 
					}: SearchTypeButtonProps) {
 | 
				
			||||||
  const [open, setOpen] = useState(false);
 | 
					  const [open, setOpen] = useState(false);
 | 
				
			||||||
  const [currentSearchSources, setCurrentSearchSources] = useState<
 | 
					
 | 
				
			||||||
    SearchSource[]
 | 
					  const buttonText = useMemo(() => {
 | 
				
			||||||
  >(selectedSearchSources);
 | 
					    if (isMobile) {
 | 
				
			||||||
 | 
					      return "Sources";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (
 | 
				
			||||||
 | 
					      !selectedSearchSources ||
 | 
				
			||||||
 | 
					      selectedSearchSources.length == 0 ||
 | 
				
			||||||
 | 
					      selectedSearchSources.length == 2
 | 
				
			||||||
 | 
					    ) {
 | 
				
			||||||
 | 
					      return "All Search Sources";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (selectedSearchSources.length == 1) {
 | 
				
			||||||
 | 
					      return selectedSearchSources[0];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return `${selectedSearchSources.length} Search Sources`;
 | 
				
			||||||
 | 
					  }, [selectedSearchSources]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const trigger = (
 | 
					  const trigger = (
 | 
				
			||||||
    <Button
 | 
					    <Button
 | 
				
			||||||
@ -763,23 +831,19 @@ function SearchTypeButton({
 | 
				
			|||||||
      variant={selectedSearchSources?.length != 2 ? "select" : "default"}
 | 
					      variant={selectedSearchSources?.length != 2 ? "select" : "default"}
 | 
				
			||||||
      className="flex items-center gap-2 capitalize"
 | 
					      className="flex items-center gap-2 capitalize"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <FaFilter
 | 
					      <SearchSourceIcon
 | 
				
			||||||
        className={`${selectedSearchSources?.length != 2 ? "text-selected-foreground" : "text-secondary-foreground"}`}
 | 
					        className={`${selectedSearchSources?.length != 2 ? "text-selected-foreground" : "text-secondary-foreground"}`}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
      <div
 | 
					      <div
 | 
				
			||||||
        className={`hidden md:block ${selectedSearchSources?.length != 2 ? "text-selected-foreground" : "text-primary"}`}
 | 
					        className={`${selectedSearchSources?.length != 2 ? "text-selected-foreground" : "text-primary"}`}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        {selectedSearchSources?.length != 2
 | 
					        {buttonText}
 | 
				
			||||||
          ? `${selectedSearchSources[0]}`
 | 
					 | 
				
			||||||
          : "All Search Sources"}
 | 
					 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </Button>
 | 
					    </Button>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
  const content = (
 | 
					  const content = (
 | 
				
			||||||
    <SearchTypeContent
 | 
					    <SearchTypeContent
 | 
				
			||||||
      selectedSearchSources={selectedSearchSources}
 | 
					      selectedSearchSources={selectedSearchSources}
 | 
				
			||||||
      currentSearchSources={currentSearchSources}
 | 
					 | 
				
			||||||
      setCurrentSearchSources={setCurrentSearchSources}
 | 
					 | 
				
			||||||
      updateSearchSourceFilter={updateSearchSourceFilter}
 | 
					      updateSearchSourceFilter={updateSearchSourceFilter}
 | 
				
			||||||
      onClose={() => setOpen(false)}
 | 
					      onClose={() => setOpen(false)}
 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
@ -790,10 +854,6 @@ function SearchTypeButton({
 | 
				
			|||||||
      <Drawer
 | 
					      <Drawer
 | 
				
			||||||
        open={open}
 | 
					        open={open}
 | 
				
			||||||
        onOpenChange={(open) => {
 | 
					        onOpenChange={(open) => {
 | 
				
			||||||
          if (!open) {
 | 
					 | 
				
			||||||
            setCurrentSearchSources(selectedSearchSources);
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          setOpen(open);
 | 
					          setOpen(open);
 | 
				
			||||||
        }}
 | 
					        }}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
@ -809,10 +869,6 @@ function SearchTypeButton({
 | 
				
			|||||||
    <Popover
 | 
					    <Popover
 | 
				
			||||||
      open={open}
 | 
					      open={open}
 | 
				
			||||||
      onOpenChange={(open) => {
 | 
					      onOpenChange={(open) => {
 | 
				
			||||||
        if (!open) {
 | 
					 | 
				
			||||||
          setCurrentSearchSources(selectedSearchSources);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        setOpen(open);
 | 
					        setOpen(open);
 | 
				
			||||||
      }}
 | 
					      }}
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
@ -823,26 +879,26 @@ function SearchTypeButton({
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type SearchTypeContentProps = {
 | 
					type SearchTypeContentProps = {
 | 
				
			||||||
  selectedSearchSources: SearchSource[];
 | 
					  selectedSearchSources: SearchSource[] | undefined;
 | 
				
			||||||
  currentSearchSources: SearchSource[];
 | 
					  updateSearchSourceFilter: (sources: SearchSource[] | undefined) => void;
 | 
				
			||||||
  setCurrentSearchSources: (sources: SearchSource[]) => void;
 | 
					 | 
				
			||||||
  updateSearchSourceFilter: (sources: SearchSource[]) => void;
 | 
					 | 
				
			||||||
  onClose: () => void;
 | 
					  onClose: () => void;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export function SearchTypeContent({
 | 
					export function SearchTypeContent({
 | 
				
			||||||
  selectedSearchSources,
 | 
					  selectedSearchSources,
 | 
				
			||||||
  currentSearchSources,
 | 
					 | 
				
			||||||
  setCurrentSearchSources,
 | 
					 | 
				
			||||||
  updateSearchSourceFilter,
 | 
					  updateSearchSourceFilter,
 | 
				
			||||||
  onClose,
 | 
					  onClose,
 | 
				
			||||||
}: SearchTypeContentProps) {
 | 
					}: SearchTypeContentProps) {
 | 
				
			||||||
 | 
					  const [currentSearchSources, setCurrentSearchSources] = useState<
 | 
				
			||||||
 | 
					    SearchSource[] | undefined
 | 
				
			||||||
 | 
					  >(selectedSearchSources);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <>
 | 
					    <>
 | 
				
			||||||
      <div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
 | 
					      <div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
 | 
				
			||||||
        <div className="my-2.5 flex flex-col gap-2.5">
 | 
					        <div className="my-2.5 flex flex-col gap-2.5">
 | 
				
			||||||
          <FilterSwitch
 | 
					          <FilterSwitch
 | 
				
			||||||
            label="Thumbnail Image"
 | 
					            label="Thumbnail Image"
 | 
				
			||||||
            isChecked={currentSearchSources?.includes("thumbnail") ?? false}
 | 
					            isChecked={selectedSearchSources?.includes("thumbnail") ?? false}
 | 
				
			||||||
            onCheckedChange={(isChecked) => {
 | 
					            onCheckedChange={(isChecked) => {
 | 
				
			||||||
              const updatedSources = currentSearchSources
 | 
					              const updatedSources = currentSearchSources
 | 
				
			||||||
                ? [...currentSearchSources]
 | 
					                ? [...currentSearchSources]
 | 
				
			||||||
@ -897,10 +953,8 @@ export function SearchTypeContent({
 | 
				
			|||||||
          </Button>
 | 
					          </Button>
 | 
				
			||||||
          <Button
 | 
					          <Button
 | 
				
			||||||
            onClick={() => {
 | 
					            onClick={() => {
 | 
				
			||||||
              setCurrentSearchSources([
 | 
					              updateSearchSourceFilter(undefined);
 | 
				
			||||||
                "thumbnail",
 | 
					              setCurrentSearchSources(["thumbnail", "description"]);
 | 
				
			||||||
                "description",
 | 
					 | 
				
			||||||
              ] as SearchSource[]);
 | 
					 | 
				
			||||||
            }}
 | 
					            }}
 | 
				
			||||||
          >
 | 
					          >
 | 
				
			||||||
            Reset
 | 
					            Reset
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										26
									
								
								web/src/components/icons/SearchSourceIcon.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								web/src/components/icons/SearchSourceIcon.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,26 @@
 | 
				
			|||||||
 | 
					import { forwardRef } from "react";
 | 
				
			||||||
 | 
					import { cn } from "@/lib/utils";
 | 
				
			||||||
 | 
					import { FaImage } from "react-icons/fa";
 | 
				
			||||||
 | 
					import { LuText } from "react-icons/lu";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type SearchSourceIconProps = {
 | 
				
			||||||
 | 
					  className?: string;
 | 
				
			||||||
 | 
					  onClick?: () => void;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const SearchSourceIcon = forwardRef<HTMLDivElement, SearchSourceIconProps>(
 | 
				
			||||||
 | 
					  ({ className, onClick }, ref) => {
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <div
 | 
				
			||||||
 | 
					        ref={ref}
 | 
				
			||||||
 | 
					        className={cn("relative flex items-center", className)}
 | 
				
			||||||
 | 
					        onClick={onClick}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <LuText className="absolute size-3 translate-x-3 translate-y-3/4" />
 | 
				
			||||||
 | 
					        <FaImage className="size-5" />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default SearchSourceIcon;
 | 
				
			||||||
@ -1,6 +1,7 @@
 | 
				
			|||||||
import { forwardRef } from "react";
 | 
					import { forwardRef } from "react";
 | 
				
			||||||
import { cn } from "@/lib/utils";
 | 
					import { cn } from "@/lib/utils";
 | 
				
			||||||
import { FaCog, FaFilter } from "react-icons/fa";
 | 
					import { FaCog } from "react-icons/fa";
 | 
				
			||||||
 | 
					import { MdLabelOutline } from "react-icons/md";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type SubFilterIconProps = {
 | 
					type SubFilterIconProps = {
 | 
				
			||||||
  className?: string;
 | 
					  className?: string;
 | 
				
			||||||
@ -15,8 +16,8 @@ const SubFilterIcon = forwardRef<HTMLDivElement, SubFilterIconProps>(
 | 
				
			|||||||
        className={cn("relative flex items-center", className)}
 | 
					        className={cn("relative flex items-center", className)}
 | 
				
			||||||
        onClick={onClick}
 | 
					        onClick={onClick}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <FaFilter className="size-full" />
 | 
					        <FaCog className="absolute size-3 translate-x-3 translate-y-[62%]" />
 | 
				
			||||||
        <FaCog className="absolute size-3 translate-x-3 translate-y-3/4" />
 | 
					        <MdLabelOutline className="size-5" />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
				
			|||||||
@ -338,12 +338,7 @@ function EventItem({
 | 
				
			|||||||
                    <Chip
 | 
					                    <Chip
 | 
				
			||||||
                      className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
 | 
					                      className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
 | 
				
			||||||
                      onClick={() => {
 | 
					                      onClick={() => {
 | 
				
			||||||
                        const similaritySearchParams = new URLSearchParams({
 | 
					                        navigate(`/explore?similarity_search_id=${event.id}`);
 | 
				
			||||||
                          search_type: "similarity",
 | 
					 | 
				
			||||||
                          event_id: event.id,
 | 
					 | 
				
			||||||
                        }).toString();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        navigate(`/search?${similaritySearchParams}`);
 | 
					 | 
				
			||||||
                      }}
 | 
					                      }}
 | 
				
			||||||
                    >
 | 
					                    >
 | 
				
			||||||
                      <FaImages className="size-4 text-white" />
 | 
					                      <FaImages className="size-4 text-white" />
 | 
				
			||||||
 | 
				
			|||||||
@ -1,11 +1,4 @@
 | 
				
			|||||||
import { isDesktop, isIOS } from "react-device-detect";
 | 
					import { isDesktop, isIOS, isMobile } from "react-device-detect";
 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
  Sheet,
 | 
					 | 
				
			||||||
  SheetContent,
 | 
					 | 
				
			||||||
  SheetDescription,
 | 
					 | 
				
			||||||
  SheetHeader,
 | 
					 | 
				
			||||||
  SheetTitle,
 | 
					 | 
				
			||||||
} from "../../ui/sheet";
 | 
					 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Drawer,
 | 
					  Drawer,
 | 
				
			||||||
  DrawerContent,
 | 
					  DrawerContent,
 | 
				
			||||||
@ -20,10 +13,28 @@ import { useFormattedTimestamp } from "@/hooks/use-date-utils";
 | 
				
			|||||||
import { getIconForLabel } from "@/utils/iconUtil";
 | 
					import { getIconForLabel } from "@/utils/iconUtil";
 | 
				
			||||||
import { useApiHost } from "@/api";
 | 
					import { useApiHost } from "@/api";
 | 
				
			||||||
import { Button } from "../../ui/button";
 | 
					import { Button } from "../../ui/button";
 | 
				
			||||||
import { useCallback, useEffect, useMemo, useState } from "react";
 | 
					import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 | 
				
			||||||
import axios from "axios";
 | 
					import axios from "axios";
 | 
				
			||||||
import { toast } from "sonner";
 | 
					import { toast } from "sonner";
 | 
				
			||||||
import { Textarea } from "../../ui/textarea";
 | 
					import { Textarea } from "../../ui/textarea";
 | 
				
			||||||
 | 
					import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
 | 
				
			||||||
 | 
					import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
 | 
				
			||||||
 | 
					import useOptimisticState from "@/hooks/use-optimistic-state";
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  Dialog,
 | 
				
			||||||
 | 
					  DialogContent,
 | 
				
			||||||
 | 
					  DialogDescription,
 | 
				
			||||||
 | 
					  DialogHeader,
 | 
				
			||||||
 | 
					  DialogTitle,
 | 
				
			||||||
 | 
					} from "@/components/ui/dialog";
 | 
				
			||||||
 | 
					import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
 | 
				
			||||||
 | 
					import { Event } from "@/types/event";
 | 
				
			||||||
 | 
					import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
 | 
				
			||||||
 | 
					import { baseUrl } from "@/api/baseUrl";
 | 
				
			||||||
 | 
					import { cn } from "@/lib/utils";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const SEARCH_TABS = ["details", "Frigate+", "video"] as const;
 | 
				
			||||||
 | 
					type SearchTab = (typeof SEARCH_TABS)[number];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type SearchDetailDialogProps = {
 | 
					type SearchDetailDialogProps = {
 | 
				
			||||||
  search?: SearchResult;
 | 
					  search?: SearchResult;
 | 
				
			||||||
@ -39,6 +50,130 @@ export default function SearchDetailDialog({
 | 
				
			|||||||
    revalidateOnFocus: false,
 | 
					    revalidateOnFocus: false,
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // tabs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [page, setPage] = useState<SearchTab>("details");
 | 
				
			||||||
 | 
					  const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const searchTabs = useMemo(() => {
 | 
				
			||||||
 | 
					    if (!config || !search) {
 | 
				
			||||||
 | 
					      return [];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const views = [...SEARCH_TABS];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!config.plus.enabled || !search.has_snapshot) {
 | 
				
			||||||
 | 
					      const index = views.indexOf("Frigate+");
 | 
				
			||||||
 | 
					      views.splice(index, 1);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // TODO implement
 | 
				
			||||||
 | 
					    //if (!config.semantic_search.enabled) {
 | 
				
			||||||
 | 
					    //  const index = views.indexOf("similar-calendar");
 | 
				
			||||||
 | 
					    //  views.splice(index, 1);
 | 
				
			||||||
 | 
					    // }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return views;
 | 
				
			||||||
 | 
					  }, [config, search]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!search) {
 | 
				
			||||||
 | 
					    return;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // content
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const Overlay = isDesktop ? Dialog : Drawer;
 | 
				
			||||||
 | 
					  const Content = isDesktop ? DialogContent : DrawerContent;
 | 
				
			||||||
 | 
					  const Header = isDesktop ? DialogHeader : DrawerHeader;
 | 
				
			||||||
 | 
					  const Title = isDesktop ? DialogTitle : DrawerTitle;
 | 
				
			||||||
 | 
					  const Description = isDesktop ? DialogDescription : DrawerDescription;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Overlay
 | 
				
			||||||
 | 
					      open={search != undefined}
 | 
				
			||||||
 | 
					      onOpenChange={(open) => {
 | 
				
			||||||
 | 
					        if (!open) {
 | 
				
			||||||
 | 
					          setSearch(undefined);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <Content
 | 
				
			||||||
 | 
					        className={
 | 
				
			||||||
 | 
					          isDesktop
 | 
				
			||||||
 | 
					            ? "sm:max-w-xl md:max-w-3xl lg:max-w-4xl xl:max-w-7xl"
 | 
				
			||||||
 | 
					            : "max-h-[75dvh] overflow-hidden px-2 pb-4"
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <Header className="sr-only">
 | 
				
			||||||
 | 
					          <Title>Tracked Object Details</Title>
 | 
				
			||||||
 | 
					          <Description>Tracked object details</Description>
 | 
				
			||||||
 | 
					        </Header>
 | 
				
			||||||
 | 
					        <ScrollArea
 | 
				
			||||||
 | 
					          className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <div className="flex flex-row">
 | 
				
			||||||
 | 
					            <ToggleGroup
 | 
				
			||||||
 | 
					              className="*:rounded-md *:px-3 *:py-4"
 | 
				
			||||||
 | 
					              type="single"
 | 
				
			||||||
 | 
					              size="sm"
 | 
				
			||||||
 | 
					              value={pageToggle}
 | 
				
			||||||
 | 
					              onValueChange={(value: SearchTab) => {
 | 
				
			||||||
 | 
					                if (value) {
 | 
				
			||||||
 | 
					                  setPageToggle(value);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					              }}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              {Object.values(searchTabs).map((item) => (
 | 
				
			||||||
 | 
					                <ToggleGroupItem
 | 
				
			||||||
 | 
					                  key={item}
 | 
				
			||||||
 | 
					                  className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "details" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
 | 
				
			||||||
 | 
					                  value={item}
 | 
				
			||||||
 | 
					                  data-nav-item={item}
 | 
				
			||||||
 | 
					                  aria-label={`Select ${item}`}
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                  <div className="capitalize">{item}</div>
 | 
				
			||||||
 | 
					                </ToggleGroupItem>
 | 
				
			||||||
 | 
					              ))}
 | 
				
			||||||
 | 
					            </ToggleGroup>
 | 
				
			||||||
 | 
					            <ScrollBar orientation="horizontal" className="h-0" />
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </ScrollArea>
 | 
				
			||||||
 | 
					        {page == "details" && (
 | 
				
			||||||
 | 
					          <ObjectDetailsTab
 | 
				
			||||||
 | 
					            search={search}
 | 
				
			||||||
 | 
					            config={config}
 | 
				
			||||||
 | 
					            setSearch={setSearch}
 | 
				
			||||||
 | 
					            setSimilarity={setSimilarity}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					        {page == "Frigate+" && (
 | 
				
			||||||
 | 
					          <FrigatePlusDialog
 | 
				
			||||||
 | 
					            upload={search as unknown as Event}
 | 
				
			||||||
 | 
					            dialog={false}
 | 
				
			||||||
 | 
					            onClose={() => {}}
 | 
				
			||||||
 | 
					            onEventUploaded={() => {
 | 
				
			||||||
 | 
					              search.plus_id = "new_upload";
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					        {page == "video" && <VideoTab search={search} />}
 | 
				
			||||||
 | 
					      </Content>
 | 
				
			||||||
 | 
					    </Overlay>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type ObjectDetailsTabProps = {
 | 
				
			||||||
 | 
					  search: SearchResult;
 | 
				
			||||||
 | 
					  config?: FrigateConfig;
 | 
				
			||||||
 | 
					  setSearch: (search: SearchResult | undefined) => void;
 | 
				
			||||||
 | 
					  setSimilarity?: () => void;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					function ObjectDetailsTab({
 | 
				
			||||||
 | 
					  search,
 | 
				
			||||||
 | 
					  config,
 | 
				
			||||||
 | 
					  setSearch,
 | 
				
			||||||
 | 
					  setSimilarity,
 | 
				
			||||||
 | 
					}: ObjectDetailsTabProps) {
 | 
				
			||||||
  const apiHost = useApiHost();
 | 
					  const apiHost = useApiHost();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // data
 | 
					  // data
 | 
				
			||||||
@ -77,8 +212,6 @@ export default function SearchDetailDialog({
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }, [search]);
 | 
					  }, [search]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // api
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const updateDescription = useCallback(() => {
 | 
					  const updateDescription = useCallback(() => {
 | 
				
			||||||
    if (!search) {
 | 
					    if (!search) {
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
@ -101,105 +234,97 @@ export default function SearchDetailDialog({
 | 
				
			|||||||
      });
 | 
					      });
 | 
				
			||||||
  }, [desc, search]);
 | 
					  }, [desc, search]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // content
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const Overlay = isDesktop ? Sheet : Drawer;
 | 
					 | 
				
			||||||
  const Content = isDesktop ? SheetContent : DrawerContent;
 | 
					 | 
				
			||||||
  const Header = isDesktop ? SheetHeader : DrawerHeader;
 | 
					 | 
				
			||||||
  const Title = isDesktop ? SheetTitle : DrawerTitle;
 | 
					 | 
				
			||||||
  const Description = isDesktop ? SheetDescription : DrawerDescription;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Overlay
 | 
					    <div className="mt-3 flex size-full flex-col gap-5 md:mt-0">
 | 
				
			||||||
      open={search != undefined}
 | 
					      <div className="flex w-full flex-row">
 | 
				
			||||||
      onOpenChange={(open) => {
 | 
					        <div className="flex w-full flex-col gap-3">
 | 
				
			||||||
        if (!open) {
 | 
					          <div className="flex flex-col gap-1.5">
 | 
				
			||||||
          setSearch(undefined);
 | 
					            <div className="text-sm text-primary/40">Label</div>
 | 
				
			||||||
        }
 | 
					            <div className="flex flex-row items-center gap-2 text-sm capitalize">
 | 
				
			||||||
      }}
 | 
					              {getIconForLabel(search.label, "size-4 text-primary")}
 | 
				
			||||||
    >
 | 
					              {search.label}
 | 
				
			||||||
      <Content
 | 
					              {search.sub_label && ` (${search.sub_label})`}
 | 
				
			||||||
        className={
 | 
					 | 
				
			||||||
          isDesktop ? "sm:max-w-xl" : "max-h-[75dvh] overflow-hidden p-2 pb-4"
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      >
 | 
					 | 
				
			||||||
        <Header className="sr-only">
 | 
					 | 
				
			||||||
          <Title>Tracked Object Details</Title>
 | 
					 | 
				
			||||||
          <Description>Tracked object details</Description>
 | 
					 | 
				
			||||||
        </Header>
 | 
					 | 
				
			||||||
        {search && (
 | 
					 | 
				
			||||||
          <div className="mt-3 flex size-full flex-col gap-5 md:mt-0">
 | 
					 | 
				
			||||||
            <div className="flex w-full flex-row">
 | 
					 | 
				
			||||||
              <div className="flex w-full flex-col gap-3">
 | 
					 | 
				
			||||||
                <div className="flex flex-col gap-1.5">
 | 
					 | 
				
			||||||
                  <div className="text-sm text-primary/40">Label</div>
 | 
					 | 
				
			||||||
                  <div className="flex flex-row items-center gap-2 text-sm capitalize">
 | 
					 | 
				
			||||||
                    {getIconForLabel(search.label, "size-4 text-primary")}
 | 
					 | 
				
			||||||
                    {search.label}
 | 
					 | 
				
			||||||
                    {search.sub_label && ` (${search.sub_label})`}
 | 
					 | 
				
			||||||
                  </div>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
                <div className="flex flex-col gap-1.5">
 | 
					 | 
				
			||||||
                  <div className="text-sm text-primary/40">Score</div>
 | 
					 | 
				
			||||||
                  <div className="text-sm">
 | 
					 | 
				
			||||||
                    {score}%{subLabelScore && ` (${subLabelScore}%)`}
 | 
					 | 
				
			||||||
                  </div>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
                <div className="flex flex-col gap-1.5">
 | 
					 | 
				
			||||||
                  <div className="text-sm text-primary/40">Camera</div>
 | 
					 | 
				
			||||||
                  <div className="text-sm capitalize">
 | 
					 | 
				
			||||||
                    {search.camera.replaceAll("_", " ")}
 | 
					 | 
				
			||||||
                  </div>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
                <div className="flex flex-col gap-1.5">
 | 
					 | 
				
			||||||
                  <div className="text-sm text-primary/40">Timestamp</div>
 | 
					 | 
				
			||||||
                  <div className="text-sm">{formattedDate}</div>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
              <div className="flex w-full flex-col gap-2 px-6">
 | 
					 | 
				
			||||||
                <img
 | 
					 | 
				
			||||||
                  className="aspect-video select-none rounded-lg object-contain transition-opacity"
 | 
					 | 
				
			||||||
                  style={
 | 
					 | 
				
			||||||
                    isIOS
 | 
					 | 
				
			||||||
                      ? {
 | 
					 | 
				
			||||||
                          WebkitUserSelect: "none",
 | 
					 | 
				
			||||||
                          WebkitTouchCallout: "none",
 | 
					 | 
				
			||||||
                        }
 | 
					 | 
				
			||||||
                      : undefined
 | 
					 | 
				
			||||||
                  }
 | 
					 | 
				
			||||||
                  draggable={false}
 | 
					 | 
				
			||||||
                  src={`${apiHost}api/events/${search.id}/thumbnail.jpg`}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
                <Button
 | 
					 | 
				
			||||||
                  onClick={() => {
 | 
					 | 
				
			||||||
                    setSearch(undefined);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    if (setSimilarity) {
 | 
					 | 
				
			||||||
                      setSimilarity();
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                  }}
 | 
					 | 
				
			||||||
                >
 | 
					 | 
				
			||||||
                  Find Similar
 | 
					 | 
				
			||||||
                </Button>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
            <div className="flex flex-col gap-1.5">
 | 
					 | 
				
			||||||
              <div className="text-sm text-primary/40">Description</div>
 | 
					 | 
				
			||||||
              <Textarea
 | 
					 | 
				
			||||||
                className="md:h-64"
 | 
					 | 
				
			||||||
                placeholder="Description of the event"
 | 
					 | 
				
			||||||
                value={desc}
 | 
					 | 
				
			||||||
                onChange={(e) => setDesc(e.target.value)}
 | 
					 | 
				
			||||||
              />
 | 
					 | 
				
			||||||
              <div className="flex w-full flex-row justify-end">
 | 
					 | 
				
			||||||
                <Button variant="select" onClick={updateDescription}>
 | 
					 | 
				
			||||||
                  Save
 | 
					 | 
				
			||||||
                </Button>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        )}
 | 
					          <div className="flex flex-col gap-1.5">
 | 
				
			||||||
      </Content>
 | 
					            <div className="text-sm text-primary/40">Score</div>
 | 
				
			||||||
    </Overlay>
 | 
					            <div className="text-sm">
 | 
				
			||||||
 | 
					              {score}%{subLabelScore && ` (${subLabelScore}%)`}
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div className="flex flex-col gap-1.5">
 | 
				
			||||||
 | 
					            <div className="text-sm text-primary/40">Camera</div>
 | 
				
			||||||
 | 
					            <div className="text-sm capitalize">
 | 
				
			||||||
 | 
					              {search.camera.replaceAll("_", " ")}
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div className="flex flex-col gap-1.5">
 | 
				
			||||||
 | 
					            <div className="text-sm text-primary/40">Timestamp</div>
 | 
				
			||||||
 | 
					            <div className="text-sm">{formattedDate}</div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div className="flex w-full flex-col gap-2 px-6">
 | 
				
			||||||
 | 
					          <img
 | 
				
			||||||
 | 
					            className="aspect-video select-none rounded-lg object-contain transition-opacity"
 | 
				
			||||||
 | 
					            style={
 | 
				
			||||||
 | 
					              isIOS
 | 
				
			||||||
 | 
					                ? {
 | 
				
			||||||
 | 
					                    WebkitUserSelect: "none",
 | 
				
			||||||
 | 
					                    WebkitTouchCallout: "none",
 | 
				
			||||||
 | 
					                  }
 | 
				
			||||||
 | 
					                : undefined
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            draggable={false}
 | 
				
			||||||
 | 
					            src={`${apiHost}api/events/${search.id}/thumbnail.jpg`}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					          <Button
 | 
				
			||||||
 | 
					            onClick={() => {
 | 
				
			||||||
 | 
					              setSearch(undefined);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              if (setSimilarity) {
 | 
				
			||||||
 | 
					                setSimilarity();
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Find Similar
 | 
				
			||||||
 | 
					          </Button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div className="flex flex-col gap-1.5">
 | 
				
			||||||
 | 
					        <div className="text-sm text-primary/40">Description</div>
 | 
				
			||||||
 | 
					        <Textarea
 | 
				
			||||||
 | 
					          className="md:h-64"
 | 
				
			||||||
 | 
					          placeholder="Description of the event"
 | 
				
			||||||
 | 
					          value={desc}
 | 
				
			||||||
 | 
					          onChange={(e) => setDesc(e.target.value)}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					        <div className="flex w-full flex-row justify-end">
 | 
				
			||||||
 | 
					          <Button variant="select" onClick={updateDescription}>
 | 
				
			||||||
 | 
					            Save
 | 
				
			||||||
 | 
					          </Button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type VideoTabProps = {
 | 
				
			||||||
 | 
					  search: SearchResult;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					function VideoTab({ search }: VideoTabProps) {
 | 
				
			||||||
 | 
					  const videoRef = useRef<HTMLVideoElement | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const endTime = useMemo(() => search.end_time ?? Date.now() / 1000, [search]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <HlsVideoPlayer
 | 
				
			||||||
 | 
					      videoRef={videoRef}
 | 
				
			||||||
 | 
					      currentSource={`${baseUrl}vod/${search.camera}/start/${search.start_time}/end/${endTime}/index.m3u8`}
 | 
				
			||||||
 | 
					      hotKeys
 | 
				
			||||||
 | 
					      visible
 | 
				
			||||||
 | 
					      frigateControls={false}
 | 
				
			||||||
 | 
					      fullscreen={false}
 | 
				
			||||||
 | 
					      supportsFullscreen={false}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,5 @@
 | 
				
			|||||||
import { baseUrl } from "@/api/baseUrl";
 | 
					import { baseUrl } from "@/api/baseUrl";
 | 
				
			||||||
 | 
					import ActivityIndicator from "@/components/indicators/activity-indicator";
 | 
				
			||||||
import { Button } from "@/components/ui/button";
 | 
					import { Button } from "@/components/ui/button";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Dialog,
 | 
					  Dialog,
 | 
				
			||||||
@ -11,17 +12,21 @@ import {
 | 
				
			|||||||
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";
 | 
				
			||||||
import { useCallback, useMemo } from "react";
 | 
					import { useCallback, useMemo, useState } from "react";
 | 
				
			||||||
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
 | 
					import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
 | 
				
			||||||
import useSWR from "swr";
 | 
					import useSWR from "swr";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type SubmissionState = "reviewing" | "uploading" | "submitted";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type FrigatePlusDialogProps = {
 | 
					type FrigatePlusDialogProps = {
 | 
				
			||||||
  upload?: Event;
 | 
					  upload?: Event;
 | 
				
			||||||
 | 
					  dialog?: boolean;
 | 
				
			||||||
  onClose: () => void;
 | 
					  onClose: () => void;
 | 
				
			||||||
  onEventUploaded: () => void;
 | 
					  onEventUploaded: () => void;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export function FrigatePlusDialog({
 | 
					export function FrigatePlusDialog({
 | 
				
			||||||
  upload,
 | 
					  upload,
 | 
				
			||||||
 | 
					  dialog = true,
 | 
				
			||||||
  onClose,
 | 
					  onClose,
 | 
				
			||||||
  onEventUploaded,
 | 
					  onEventUploaded,
 | 
				
			||||||
}: FrigatePlusDialogProps) {
 | 
					}: FrigatePlusDialogProps) {
 | 
				
			||||||
@ -49,6 +54,10 @@ export function FrigatePlusDialog({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  // upload
 | 
					  // upload
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [state, setState] = useState<SubmissionState>(
 | 
				
			||||||
 | 
					    upload?.plus_id ? "submitted" : "reviewing",
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onSubmitToPlus = useCallback(
 | 
					  const onSubmitToPlus = useCallback(
 | 
				
			||||||
    async (falsePositive: boolean) => {
 | 
					    async (falsePositive: boolean) => {
 | 
				
			||||||
      if (!upload) {
 | 
					      if (!upload) {
 | 
				
			||||||
@ -61,63 +70,84 @@ export function FrigatePlusDialog({
 | 
				
			|||||||
            include_annotation: 1,
 | 
					            include_annotation: 1,
 | 
				
			||||||
          });
 | 
					          });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      setState("submitted");
 | 
				
			||||||
      onEventUploaded();
 | 
					      onEventUploaded();
 | 
				
			||||||
      onClose();
 | 
					      onClose();
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    [upload, onClose, onEventUploaded],
 | 
					    [upload, onClose, onEventUploaded],
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  const content = (
 | 
				
			||||||
    <Dialog
 | 
					    <TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
 | 
				
			||||||
      open={upload != undefined}
 | 
					      <DialogHeader>
 | 
				
			||||||
      onOpenChange={(open) => (!open ? onClose() : null)}
 | 
					        <DialogTitle>Submit To Frigate+</DialogTitle>
 | 
				
			||||||
    >
 | 
					        <DialogDescription>
 | 
				
			||||||
      <DialogContent className="md:max-w-3xl lg:max-w-4xl xl:max-w-7xl">
 | 
					          Objects in locations you want to avoid are not false positives.
 | 
				
			||||||
        <TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
 | 
					          Submitting them as false positives will confuse the model.
 | 
				
			||||||
          <DialogHeader>
 | 
					        </DialogDescription>
 | 
				
			||||||
            <DialogTitle>Submit To Frigate+</DialogTitle>
 | 
					      </DialogHeader>
 | 
				
			||||||
            <DialogDescription>
 | 
					      <TransformComponent
 | 
				
			||||||
              Objects in locations you want to avoid are not false positives.
 | 
					        wrapperStyle={{
 | 
				
			||||||
              Submitting them as false positives will confuse the model.
 | 
					          width: "100%",
 | 
				
			||||||
            </DialogDescription>
 | 
					          height: "100%",
 | 
				
			||||||
          </DialogHeader>
 | 
					        }}
 | 
				
			||||||
          <TransformComponent
 | 
					        contentStyle={{
 | 
				
			||||||
            wrapperStyle={{
 | 
					          position: "relative",
 | 
				
			||||||
              width: "100%",
 | 
					          width: "100%",
 | 
				
			||||||
              height: "100%",
 | 
					          height: "100%",
 | 
				
			||||||
            }}
 | 
					        }}
 | 
				
			||||||
            contentStyle={{
 | 
					      >
 | 
				
			||||||
              position: "relative",
 | 
					        {upload?.id && (
 | 
				
			||||||
              width: "100%",
 | 
					          <img
 | 
				
			||||||
              height: "100%",
 | 
					            className={`w-full ${grow} bg-black`}
 | 
				
			||||||
            }}
 | 
					            src={`${baseUrl}api/events/${upload?.id}/snapshot.jpg`}
 | 
				
			||||||
          >
 | 
					            alt={`${upload?.label}`}
 | 
				
			||||||
            {upload?.id && (
 | 
					          />
 | 
				
			||||||
              <img
 | 
					        )}
 | 
				
			||||||
                className={`w-full ${grow} bg-black`}
 | 
					      </TransformComponent>
 | 
				
			||||||
                src={`${baseUrl}api/events/${upload?.id}/snapshot.jpg`}
 | 
					
 | 
				
			||||||
                alt={`${upload?.label}`}
 | 
					      <DialogFooter>
 | 
				
			||||||
              />
 | 
					        {state == "reviewing" && (
 | 
				
			||||||
            )}
 | 
					          <>
 | 
				
			||||||
          </TransformComponent>
 | 
					            {dialog && <Button onClick={onClose}>Cancel</Button>}
 | 
				
			||||||
          <DialogFooter>
 | 
					 | 
				
			||||||
            <Button onClick={onClose}>Cancel</Button>
 | 
					 | 
				
			||||||
            <Button
 | 
					            <Button
 | 
				
			||||||
              className="bg-success"
 | 
					              className="bg-success"
 | 
				
			||||||
              onClick={() => onSubmitToPlus(false)}
 | 
					              onClick={() => {
 | 
				
			||||||
 | 
					                setState("uploading");
 | 
				
			||||||
 | 
					                onSubmitToPlus(false);
 | 
				
			||||||
 | 
					              }}
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              This is a {upload?.label}
 | 
					              This is a {upload?.label}
 | 
				
			||||||
            </Button>
 | 
					            </Button>
 | 
				
			||||||
            <Button
 | 
					            <Button
 | 
				
			||||||
              className="text-white"
 | 
					              className="text-white"
 | 
				
			||||||
              variant="destructive"
 | 
					              variant="destructive"
 | 
				
			||||||
              onClick={() => onSubmitToPlus(true)}
 | 
					              onClick={() => {
 | 
				
			||||||
 | 
					                setState("uploading");
 | 
				
			||||||
 | 
					                onSubmitToPlus(true);
 | 
				
			||||||
 | 
					              }}
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              This is not a {upload?.label}
 | 
					              This is not a {upload?.label}
 | 
				
			||||||
            </Button>
 | 
					            </Button>
 | 
				
			||||||
          </DialogFooter>
 | 
					          </>
 | 
				
			||||||
        </TransformWrapper>
 | 
					        )}
 | 
				
			||||||
      </DialogContent>
 | 
					        {state == "uploading" && <ActivityIndicator />}
 | 
				
			||||||
    </Dialog>
 | 
					      </DialogFooter>
 | 
				
			||||||
 | 
					    </TransformWrapper>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (dialog) {
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <Dialog
 | 
				
			||||||
 | 
					        open={upload != undefined}
 | 
				
			||||||
 | 
					        onOpenChange={(open) => (!open ? onClose() : null)}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <DialogContent className="md:max-w-3xl lg:max-w-4xl xl:max-w-7xl">
 | 
				
			||||||
 | 
					          {content}
 | 
				
			||||||
 | 
					        </DialogContent>
 | 
				
			||||||
 | 
					      </Dialog>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return content;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -35,6 +35,7 @@ type HlsVideoPlayerProps = {
 | 
				
			|||||||
  hotKeys: boolean;
 | 
					  hotKeys: boolean;
 | 
				
			||||||
  supportsFullscreen: boolean;
 | 
					  supportsFullscreen: boolean;
 | 
				
			||||||
  fullscreen: boolean;
 | 
					  fullscreen: boolean;
 | 
				
			||||||
 | 
					  frigateControls?: boolean;
 | 
				
			||||||
  onClipEnded?: () => void;
 | 
					  onClipEnded?: () => void;
 | 
				
			||||||
  onPlayerLoaded?: () => void;
 | 
					  onPlayerLoaded?: () => void;
 | 
				
			||||||
  onTimeUpdate?: (time: number) => void;
 | 
					  onTimeUpdate?: (time: number) => void;
 | 
				
			||||||
@ -52,6 +53,7 @@ export default function HlsVideoPlayer({
 | 
				
			|||||||
  hotKeys,
 | 
					  hotKeys,
 | 
				
			||||||
  supportsFullscreen,
 | 
					  supportsFullscreen,
 | 
				
			||||||
  fullscreen,
 | 
					  fullscreen,
 | 
				
			||||||
 | 
					  frigateControls = true,
 | 
				
			||||||
  onClipEnded,
 | 
					  onClipEnded,
 | 
				
			||||||
  onPlayerLoaded,
 | 
					  onPlayerLoaded,
 | 
				
			||||||
  onTimeUpdate,
 | 
					  onTimeUpdate,
 | 
				
			||||||
@ -167,73 +169,75 @@ export default function HlsVideoPlayer({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
 | 
					    <TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
 | 
				
			||||||
      <VideoControls
 | 
					      {frigateControls && (
 | 
				
			||||||
        className={cn(
 | 
					        <VideoControls
 | 
				
			||||||
          "absolute left-1/2 z-50 -translate-x-1/2",
 | 
					          className={cn(
 | 
				
			||||||
          tallCamera ? "bottom-12" : "bottom-5",
 | 
					            "absolute left-1/2 z-50 -translate-x-1/2",
 | 
				
			||||||
        )}
 | 
					            tallCamera ? "bottom-12" : "bottom-5",
 | 
				
			||||||
        video={videoRef.current}
 | 
					          )}
 | 
				
			||||||
        isPlaying={isPlaying}
 | 
					          video={videoRef.current}
 | 
				
			||||||
        show={visible && (controls || controlsOpen)}
 | 
					          isPlaying={isPlaying}
 | 
				
			||||||
        muted={muted}
 | 
					          show={visible && (controls || controlsOpen)}
 | 
				
			||||||
        volume={volume}
 | 
					          muted={muted}
 | 
				
			||||||
        features={{
 | 
					          volume={volume}
 | 
				
			||||||
          volume: true,
 | 
					          features={{
 | 
				
			||||||
          seek: true,
 | 
					            volume: true,
 | 
				
			||||||
          playbackRate: true,
 | 
					            seek: true,
 | 
				
			||||||
          plusUpload: config?.plus?.enabled == true,
 | 
					            playbackRate: true,
 | 
				
			||||||
          fullscreen: supportsFullscreen,
 | 
					            plusUpload: config?.plus?.enabled == true,
 | 
				
			||||||
        }}
 | 
					            fullscreen: supportsFullscreen,
 | 
				
			||||||
        setControlsOpen={setControlsOpen}
 | 
					          }}
 | 
				
			||||||
        setMuted={(muted) => setMuted(muted, true)}
 | 
					          setControlsOpen={setControlsOpen}
 | 
				
			||||||
        playbackRate={playbackRate ?? 1}
 | 
					          setMuted={(muted) => setMuted(muted, true)}
 | 
				
			||||||
        hotKeys={hotKeys}
 | 
					          playbackRate={playbackRate ?? 1}
 | 
				
			||||||
        onPlayPause={(play) => {
 | 
					          hotKeys={hotKeys}
 | 
				
			||||||
          if (!videoRef.current) {
 | 
					          onPlayPause={(play) => {
 | 
				
			||||||
            return;
 | 
					            if (!videoRef.current) {
 | 
				
			||||||
          }
 | 
					              return;
 | 
				
			||||||
 | 
					 | 
				
			||||||
          if (play) {
 | 
					 | 
				
			||||||
            videoRef.current.play();
 | 
					 | 
				
			||||||
          } else {
 | 
					 | 
				
			||||||
            videoRef.current.pause();
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        }}
 | 
					 | 
				
			||||||
        onSeek={(diff) => {
 | 
					 | 
				
			||||||
          const currentTime = videoRef.current?.currentTime;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          if (!videoRef.current || !currentTime) {
 | 
					 | 
				
			||||||
            return;
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          videoRef.current.currentTime = Math.max(0, currentTime + diff);
 | 
					 | 
				
			||||||
        }}
 | 
					 | 
				
			||||||
        onSetPlaybackRate={(rate) => {
 | 
					 | 
				
			||||||
          setPlaybackRate(rate, true);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          if (videoRef.current) {
 | 
					 | 
				
			||||||
            videoRef.current.playbackRate = rate;
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        }}
 | 
					 | 
				
			||||||
        onUploadFrame={async () => {
 | 
					 | 
				
			||||||
          if (videoRef.current && onUploadFrame) {
 | 
					 | 
				
			||||||
            const resp = await onUploadFrame(videoRef.current.currentTime);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            if (resp && resp.status == 200) {
 | 
					 | 
				
			||||||
              toast.success("Successfully submitted frame to Frigate+", {
 | 
					 | 
				
			||||||
                position: "top-center",
 | 
					 | 
				
			||||||
              });
 | 
					 | 
				
			||||||
            } else {
 | 
					 | 
				
			||||||
              toast.success("Failed to submit frame to Frigate+", {
 | 
					 | 
				
			||||||
                position: "top-center",
 | 
					 | 
				
			||||||
              });
 | 
					 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
          }
 | 
					
 | 
				
			||||||
        }}
 | 
					            if (play) {
 | 
				
			||||||
        fullscreen={fullscreen}
 | 
					              videoRef.current.play();
 | 
				
			||||||
        toggleFullscreen={toggleFullscreen}
 | 
					            } else {
 | 
				
			||||||
        containerRef={containerRef}
 | 
					              videoRef.current.pause();
 | 
				
			||||||
      />
 | 
					            }
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					          onSeek={(diff) => {
 | 
				
			||||||
 | 
					            const currentTime = videoRef.current?.currentTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (!videoRef.current || !currentTime) {
 | 
				
			||||||
 | 
					              return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            videoRef.current.currentTime = Math.max(0, currentTime + diff);
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					          onSetPlaybackRate={(rate) => {
 | 
				
			||||||
 | 
					            setPlaybackRate(rate, true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (videoRef.current) {
 | 
				
			||||||
 | 
					              videoRef.current.playbackRate = rate;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					          onUploadFrame={async () => {
 | 
				
			||||||
 | 
					            if (videoRef.current && onUploadFrame) {
 | 
				
			||||||
 | 
					              const resp = await onUploadFrame(videoRef.current.currentTime);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              if (resp && resp.status == 200) {
 | 
				
			||||||
 | 
					                toast.success("Successfully submitted frame to Frigate+", {
 | 
				
			||||||
 | 
					                  position: "top-center",
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					              } else {
 | 
				
			||||||
 | 
					                toast.success("Failed to submit frame to Frigate+", {
 | 
				
			||||||
 | 
					                  position: "top-center",
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					          fullscreen={fullscreen}
 | 
				
			||||||
 | 
					          toggleFullscreen={toggleFullscreen}
 | 
				
			||||||
 | 
					          containerRef={containerRef}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
      <TransformComponent
 | 
					      <TransformComponent
 | 
				
			||||||
        wrapperStyle={{
 | 
					        wrapperStyle={{
 | 
				
			||||||
          display: visible ? undefined : "none",
 | 
					          display: visible ? undefined : "none",
 | 
				
			||||||
@ -253,7 +257,7 @@ export default function HlsVideoPlayer({
 | 
				
			|||||||
          className={`size-full rounded-lg bg-black md:rounded-2xl ${loadedMetadata ? "" : "invisible"}`}
 | 
					          className={`size-full rounded-lg bg-black md:rounded-2xl ${loadedMetadata ? "" : "invisible"}`}
 | 
				
			||||||
          preload="auto"
 | 
					          preload="auto"
 | 
				
			||||||
          autoPlay
 | 
					          autoPlay
 | 
				
			||||||
          controls={false}
 | 
					          controls={!frigateControls}
 | 
				
			||||||
          playsInline
 | 
					          playsInline
 | 
				
			||||||
          muted={muted}
 | 
					          muted={muted}
 | 
				
			||||||
          onVolumeChange={() =>
 | 
					          onVolumeChange={() =>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,308 +0,0 @@
 | 
				
			|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
 | 
					 | 
				
			||||||
import { useApiHost } from "@/api";
 | 
					 | 
				
			||||||
import { isCurrentHour } from "@/utils/dateUtil";
 | 
					 | 
				
			||||||
import { getIconForLabel } from "@/utils/iconUtil";
 | 
					 | 
				
			||||||
import TimeAgo from "../dynamic/TimeAgo";
 | 
					 | 
				
			||||||
import useSWR from "swr";
 | 
					 | 
				
			||||||
import { FrigateConfig } from "@/types/frigateConfig";
 | 
					 | 
				
			||||||
import { isIOS, isMobile, isSafari } from "react-device-detect";
 | 
					 | 
				
			||||||
import Chip from "@/components/indicators/Chip";
 | 
					 | 
				
			||||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
 | 
					 | 
				
			||||||
import useImageLoaded from "@/hooks/use-image-loaded";
 | 
					 | 
				
			||||||
import { useSwipeable } from "react-swipeable";
 | 
					 | 
				
			||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
 | 
					 | 
				
			||||||
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
 | 
					 | 
				
			||||||
import ActivityIndicator from "../indicators/activity-indicator";
 | 
					 | 
				
			||||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
 | 
					 | 
				
			||||||
import { InProgressPreview, VideoPreview } from "../preview/ScrubbablePreview";
 | 
					 | 
				
			||||||
import { Preview } from "@/types/preview";
 | 
					 | 
				
			||||||
import { SearchResult } from "@/types/search";
 | 
					 | 
				
			||||||
import useContextMenu from "@/hooks/use-contextmenu";
 | 
					 | 
				
			||||||
import { cn } from "@/lib/utils";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type SearchPlayerProps = {
 | 
					 | 
				
			||||||
  searchResult: SearchResult;
 | 
					 | 
				
			||||||
  allPreviews?: Preview[];
 | 
					 | 
				
			||||||
  scrollLock?: boolean;
 | 
					 | 
				
			||||||
  onClick: (searchResult: SearchResult, detail: boolean) => void;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default function SearchThumbnailPlayer({
 | 
					 | 
				
			||||||
  searchResult,
 | 
					 | 
				
			||||||
  allPreviews,
 | 
					 | 
				
			||||||
  scrollLock = false,
 | 
					 | 
				
			||||||
  onClick,
 | 
					 | 
				
			||||||
}: SearchPlayerProps) {
 | 
					 | 
				
			||||||
  const apiHost = useApiHost();
 | 
					 | 
				
			||||||
  const { data: config } = useSWR<FrigateConfig>("config");
 | 
					 | 
				
			||||||
  const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // interaction
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const [ignoreClick, setIgnoreClick] = useState(false);
 | 
					 | 
				
			||||||
  const handleOnClick = useCallback(
 | 
					 | 
				
			||||||
    (e: React.MouseEvent<HTMLDivElement>) => {
 | 
					 | 
				
			||||||
      if (!ignoreClick) {
 | 
					 | 
				
			||||||
        onClick(searchResult, e.metaKey);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    [ignoreClick, searchResult, onClick],
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const swipeHandlers = useSwipeable({
 | 
					 | 
				
			||||||
    onSwipedLeft: () => setPlayback(false),
 | 
					 | 
				
			||||||
    onSwipedRight: () => setPlayback(true),
 | 
					 | 
				
			||||||
    preventScrollOnSwipe: true,
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useContextMenu(imgRef, () => {
 | 
					 | 
				
			||||||
    onClick(searchResult, true);
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // playback
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const relevantPreview = useMemo(() => {
 | 
					 | 
				
			||||||
    if (!allPreviews) {
 | 
					 | 
				
			||||||
      return undefined;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    let multiHour = false;
 | 
					 | 
				
			||||||
    const firstIndex = Object.values(allPreviews).findIndex((preview) => {
 | 
					 | 
				
			||||||
      if (
 | 
					 | 
				
			||||||
        preview.camera != searchResult.camera ||
 | 
					 | 
				
			||||||
        preview.end < searchResult.start_time
 | 
					 | 
				
			||||||
      ) {
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if ((searchResult.end_time ?? Date.now() / 1000) > preview.end) {
 | 
					 | 
				
			||||||
        multiHour = true;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return true;
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (firstIndex == -1) {
 | 
					 | 
				
			||||||
      return undefined;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!multiHour) {
 | 
					 | 
				
			||||||
      return allPreviews[firstIndex];
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const firstPrev = allPreviews[firstIndex];
 | 
					 | 
				
			||||||
    const firstDuration = firstPrev.end - searchResult.start_time;
 | 
					 | 
				
			||||||
    const secondDuration =
 | 
					 | 
				
			||||||
      (searchResult.end_time ?? Date.now() / 1000) - firstPrev.end;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (firstDuration > secondDuration) {
 | 
					 | 
				
			||||||
      // the first preview is longer than the second, return the first
 | 
					 | 
				
			||||||
      return firstPrev;
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      // the second preview is longer, return the second if it exists
 | 
					 | 
				
			||||||
      if (firstIndex < allPreviews.length - 1) {
 | 
					 | 
				
			||||||
        return allPreviews.find(
 | 
					 | 
				
			||||||
          (preview, idx) =>
 | 
					 | 
				
			||||||
            idx > firstIndex && preview.camera == searchResult.camera,
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return undefined;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }, [allPreviews, searchResult]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Hover Playback
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout | null>();
 | 
					 | 
				
			||||||
  const [playback, setPlayback] = useState(false);
 | 
					 | 
				
			||||||
  const [tooltipHovering, setTooltipHovering] = useState(false);
 | 
					 | 
				
			||||||
  const playingBack = useMemo(
 | 
					 | 
				
			||||||
    () => playback && !tooltipHovering,
 | 
					 | 
				
			||||||
    [playback, tooltipHovering],
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
  const [isHovered, setIsHovered] = useState(false);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    if (isHovered && scrollLock) {
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (isHovered && !tooltipHovering) {
 | 
					 | 
				
			||||||
      setHoverTimeout(
 | 
					 | 
				
			||||||
        setTimeout(() => {
 | 
					 | 
				
			||||||
          setPlayback(true);
 | 
					 | 
				
			||||||
          setHoverTimeout(null);
 | 
					 | 
				
			||||||
        }, 500),
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      if (hoverTimeout) {
 | 
					 | 
				
			||||||
        clearTimeout(hoverTimeout);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      setPlayback(false);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    // we know that these deps are correct
 | 
					 | 
				
			||||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
					 | 
				
			||||||
  }, [isHovered, scrollLock, tooltipHovering]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // date
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const formattedDate = useFormattedTimestamp(
 | 
					 | 
				
			||||||
    searchResult.start_time,
 | 
					 | 
				
			||||||
    config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p",
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div
 | 
					 | 
				
			||||||
      className="relative size-full cursor-pointer"
 | 
					 | 
				
			||||||
      onMouseOver={isMobile ? undefined : () => setIsHovered(true)}
 | 
					 | 
				
			||||||
      onMouseLeave={isMobile ? undefined : () => setIsHovered(false)}
 | 
					 | 
				
			||||||
      onClick={handleOnClick}
 | 
					 | 
				
			||||||
      {...swipeHandlers}
 | 
					 | 
				
			||||||
    >
 | 
					 | 
				
			||||||
      {playingBack && (
 | 
					 | 
				
			||||||
        <div className="absolute inset-0 animate-in fade-in">
 | 
					 | 
				
			||||||
          <PreviewContent
 | 
					 | 
				
			||||||
            searchResult={searchResult}
 | 
					 | 
				
			||||||
            relevantPreview={relevantPreview}
 | 
					 | 
				
			||||||
            setIgnoreClick={setIgnoreClick}
 | 
					 | 
				
			||||||
            isPlayingBack={setPlayback}
 | 
					 | 
				
			||||||
          />
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
      <ImageLoadingIndicator
 | 
					 | 
				
			||||||
        className="absolute inset-0"
 | 
					 | 
				
			||||||
        imgLoaded={imgLoaded}
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
      <div className={`${imgLoaded ? "visible" : "invisible"}`}>
 | 
					 | 
				
			||||||
        <img
 | 
					 | 
				
			||||||
          ref={imgRef}
 | 
					 | 
				
			||||||
          className={cn(
 | 
					 | 
				
			||||||
            "size-full select-none transition-opacity",
 | 
					 | 
				
			||||||
            playingBack ? "opacity-0" : "opacity-100",
 | 
					 | 
				
			||||||
            searchResult.search_source == "thumbnail" && "object-contain",
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
          style={
 | 
					 | 
				
			||||||
            isIOS
 | 
					 | 
				
			||||||
              ? {
 | 
					 | 
				
			||||||
                  WebkitUserSelect: "none",
 | 
					 | 
				
			||||||
                  WebkitTouchCallout: "none",
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
              : undefined
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
          draggable={false}
 | 
					 | 
				
			||||||
          src={`${apiHost}api/events/${searchResult.id}/thumbnail.jpg`}
 | 
					 | 
				
			||||||
          loading={isSafari ? "eager" : "lazy"}
 | 
					 | 
				
			||||||
          onLoad={() => {
 | 
					 | 
				
			||||||
            onImgLoad();
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <div className="absolute left-0 top-2 z-40">
 | 
					 | 
				
			||||||
          <Tooltip>
 | 
					 | 
				
			||||||
            <div
 | 
					 | 
				
			||||||
              className="flex"
 | 
					 | 
				
			||||||
              onMouseEnter={() => setTooltipHovering(true)}
 | 
					 | 
				
			||||||
              onMouseLeave={() => setTooltipHovering(false)}
 | 
					 | 
				
			||||||
            >
 | 
					 | 
				
			||||||
              <TooltipTrigger asChild>
 | 
					 | 
				
			||||||
                <div className="mx-3 pb-1 text-sm text-white">
 | 
					 | 
				
			||||||
                  {
 | 
					 | 
				
			||||||
                    <>
 | 
					 | 
				
			||||||
                      <Chip
 | 
					 | 
				
			||||||
                        className={`flex items-start justify-between space-x-1 ${playingBack ? "hidden" : ""} "bg-gray-500 z-0 bg-gradient-to-br from-gray-400 to-gray-500`}
 | 
					 | 
				
			||||||
                        onClick={() => onClick(searchResult, true)}
 | 
					 | 
				
			||||||
                      >
 | 
					 | 
				
			||||||
                        {getIconForLabel(
 | 
					 | 
				
			||||||
                          searchResult.label,
 | 
					 | 
				
			||||||
                          "size-3 text-white",
 | 
					 | 
				
			||||||
                        )}
 | 
					 | 
				
			||||||
                      </Chip>
 | 
					 | 
				
			||||||
                    </>
 | 
					 | 
				
			||||||
                  }
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              </TooltipTrigger>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
            <TooltipContent className="capitalize">
 | 
					 | 
				
			||||||
              {[...new Set([searchResult.label])]
 | 
					 | 
				
			||||||
                .filter(
 | 
					 | 
				
			||||||
                  (item) => item !== undefined && !item.includes("-verified"),
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
                .map((text) => capitalizeFirstLetter(text))
 | 
					 | 
				
			||||||
                .sort()
 | 
					 | 
				
			||||||
                .join(", ")
 | 
					 | 
				
			||||||
                .replaceAll("-verified", "")}
 | 
					 | 
				
			||||||
            </TooltipContent>
 | 
					 | 
				
			||||||
          </Tooltip>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
        {!playingBack && (
 | 
					 | 
				
			||||||
          <>
 | 
					 | 
				
			||||||
            <div className="rounded-t-l pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/60 to-transparent"></div>
 | 
					 | 
				
			||||||
            <div className="rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[20%] w-full bg-gradient-to-t from-black/60 to-transparent">
 | 
					 | 
				
			||||||
              <div className="mx-3 flex h-full items-end justify-between pb-1 text-sm text-white">
 | 
					 | 
				
			||||||
                {searchResult.end_time ? (
 | 
					 | 
				
			||||||
                  <TimeAgo time={searchResult.start_time * 1000} dense />
 | 
					 | 
				
			||||||
                ) : (
 | 
					 | 
				
			||||||
                  <div>
 | 
					 | 
				
			||||||
                    <ActivityIndicator size={24} />
 | 
					 | 
				
			||||||
                  </div>
 | 
					 | 
				
			||||||
                )}
 | 
					 | 
				
			||||||
                {formattedDate}
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </>
 | 
					 | 
				
			||||||
        )}
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type PreviewContentProps = {
 | 
					 | 
				
			||||||
  searchResult: SearchResult;
 | 
					 | 
				
			||||||
  relevantPreview: Preview | undefined;
 | 
					 | 
				
			||||||
  setIgnoreClick: (ignore: boolean) => void;
 | 
					 | 
				
			||||||
  isPlayingBack: (ended: boolean) => void;
 | 
					 | 
				
			||||||
  onTimeUpdate?: (time: number | undefined) => void;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
function PreviewContent({
 | 
					 | 
				
			||||||
  searchResult,
 | 
					 | 
				
			||||||
  relevantPreview,
 | 
					 | 
				
			||||||
  setIgnoreClick,
 | 
					 | 
				
			||||||
  isPlayingBack,
 | 
					 | 
				
			||||||
  onTimeUpdate,
 | 
					 | 
				
			||||||
}: PreviewContentProps) {
 | 
					 | 
				
			||||||
  // preview
 | 
					 | 
				
			||||||
  const now = useMemo(() => Date.now() / 1000, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (relevantPreview) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <VideoPreview
 | 
					 | 
				
			||||||
        relevantPreview={relevantPreview}
 | 
					 | 
				
			||||||
        startTime={searchResult.start_time}
 | 
					 | 
				
			||||||
        endTime={searchResult.end_time}
 | 
					 | 
				
			||||||
        setIgnoreClick={setIgnoreClick}
 | 
					 | 
				
			||||||
        isPlayingBack={isPlayingBack}
 | 
					 | 
				
			||||||
        onTimeUpdate={onTimeUpdate}
 | 
					 | 
				
			||||||
        windowVisible={true}
 | 
					 | 
				
			||||||
        setReviewed={() => {}}
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  } else if (isCurrentHour(searchResult.start_time)) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <InProgressPreview
 | 
					 | 
				
			||||||
        camera={searchResult.camera}
 | 
					 | 
				
			||||||
        startTime={searchResult.start_time}
 | 
					 | 
				
			||||||
        endTime={searchResult.end_time}
 | 
					 | 
				
			||||||
        timeRange={{
 | 
					 | 
				
			||||||
          before: now,
 | 
					 | 
				
			||||||
          after: searchResult.start_time,
 | 
					 | 
				
			||||||
        }}
 | 
					 | 
				
			||||||
        setIgnoreClick={setIgnoreClick}
 | 
					 | 
				
			||||||
        isPlayingBack={isPlayingBack}
 | 
					 | 
				
			||||||
        onTimeUpdate={onTimeUpdate}
 | 
					 | 
				
			||||||
        windowVisible={true}
 | 
					 | 
				
			||||||
        setReviewed={() => {}}
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -11,7 +11,7 @@ import useSWR from "swr";
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const ID_LIVE = 1;
 | 
					export const ID_LIVE = 1;
 | 
				
			||||||
export const ID_REVIEW = 2;
 | 
					export const ID_REVIEW = 2;
 | 
				
			||||||
export const ID_SEARCH = 3;
 | 
					export const ID_EXPLORE = 3;
 | 
				
			||||||
export const ID_EXPORT = 4;
 | 
					export const ID_EXPORT = 4;
 | 
				
			||||||
export const ID_PLUS = 5;
 | 
					export const ID_PLUS = 5;
 | 
				
			||||||
export const ID_PLAYGROUND = 6;
 | 
					export const ID_PLAYGROUND = 6;
 | 
				
			||||||
@ -41,11 +41,11 @@ export default function useNavigation(
 | 
				
			|||||||
          url: "/review",
 | 
					          url: "/review",
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
          id: ID_SEARCH,
 | 
					          id: ID_EXPLORE,
 | 
				
			||||||
          variant,
 | 
					          variant,
 | 
				
			||||||
          icon: IoSearch,
 | 
					          icon: IoSearch,
 | 
				
			||||||
          title: "Search",
 | 
					          title: "Explore",
 | 
				
			||||||
          url: "/search",
 | 
					          url: "/explore",
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
          id: ID_EXPORT,
 | 
					          id: ID_EXPORT,
 | 
				
			||||||
 | 
				
			|||||||
@ -1,12 +1,13 @@
 | 
				
			|||||||
import { useApiFilterArgs } from "@/hooks/use-api-filter";
 | 
					import { useApiFilterArgs } from "@/hooks/use-api-filter";
 | 
				
			||||||
import { useCameraPreviews } from "@/hooks/use-camera-previews";
 | 
					import { useCameraPreviews } from "@/hooks/use-camera-previews";
 | 
				
			||||||
import { useOverlayState } from "@/hooks/use-overlay-state";
 | 
					import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
 | 
				
			||||||
import { FrigateConfig } from "@/types/frigateConfig";
 | 
					import { FrigateConfig } from "@/types/frigateConfig";
 | 
				
			||||||
import { RecordingStartingPoint } from "@/types/record";
 | 
					import { RecordingStartingPoint } from "@/types/record";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  PartialSearchResult,
 | 
					  PartialSearchResult,
 | 
				
			||||||
  SearchFilter,
 | 
					  SearchFilter,
 | 
				
			||||||
  SearchResult,
 | 
					  SearchResult,
 | 
				
			||||||
 | 
					  SearchSource,
 | 
				
			||||||
} from "@/types/search";
 | 
					} from "@/types/search";
 | 
				
			||||||
import { TimeRange } from "@/types/timeline";
 | 
					import { TimeRange } from "@/types/timeline";
 | 
				
			||||||
import { RecordingView } from "@/views/recording/RecordingView";
 | 
					import { RecordingView } from "@/views/recording/RecordingView";
 | 
				
			||||||
@ -14,7 +15,7 @@ import SearchView from "@/views/search/SearchView";
 | 
				
			|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
 | 
					import { useCallback, useEffect, useMemo, useState } from "react";
 | 
				
			||||||
import useSWR from "swr";
 | 
					import useSWR from "swr";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function Search() {
 | 
					export default function Explore() {
 | 
				
			||||||
  const { data: config } = useSWR<FrigateConfig>("config", {
 | 
					  const { data: config } = useSWR<FrigateConfig>("config", {
 | 
				
			||||||
    revalidateOnFocus: false,
 | 
					    revalidateOnFocus: false,
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
@ -30,39 +31,56 @@ export default function Search() {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  // search filter
 | 
					  // search filter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [similaritySearch, setSimilaritySearch] =
 | 
				
			||||||
 | 
					    useState<PartialSearchResult>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [searchFilter, setSearchFilter, searchSearchParams] =
 | 
					  const [searchFilter, setSearchFilter, searchSearchParams] =
 | 
				
			||||||
    useApiFilterArgs<SearchFilter>();
 | 
					    useApiFilterArgs<SearchFilter>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onUpdateFilter = useCallback(
 | 
					  const onUpdateFilter = useCallback(
 | 
				
			||||||
    (newFilter: SearchFilter) => {
 | 
					    (newFilter: SearchFilter) => {
 | 
				
			||||||
      setSearchFilter(newFilter);
 | 
					      setSearchFilter(newFilter);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (similaritySearch && !newFilter.search_type?.includes("similarity")) {
 | 
				
			||||||
 | 
					        setSimilaritySearch(undefined);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    [setSearchFilter],
 | 
					    [similaritySearch, setSearchFilter],
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // search api
 | 
					  // search api
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [similaritySearch, setSimilaritySearch] =
 | 
					  const updateFilterWithSimilarity = useCallback(
 | 
				
			||||||
    useState<PartialSearchResult>();
 | 
					    (similaritySearch: PartialSearchResult) => {
 | 
				
			||||||
 | 
					      let newFilter = searchFilter;
 | 
				
			||||||
 | 
					      setSimilaritySearch(similaritySearch);
 | 
				
			||||||
 | 
					      if (similaritySearch) {
 | 
				
			||||||
 | 
					        newFilter = {
 | 
				
			||||||
 | 
					          ...searchFilter,
 | 
				
			||||||
 | 
					          // @ts-expect-error we want to set this
 | 
				
			||||||
 | 
					          similarity_search_id: undefined,
 | 
				
			||||||
 | 
					          search_type: ["similarity"] as SearchSource[],
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        if (searchFilter?.search_type?.includes("similarity" as SearchSource)) {
 | 
				
			||||||
 | 
					          newFilter = {
 | 
				
			||||||
 | 
					            ...searchFilter,
 | 
				
			||||||
 | 
					            // @ts-expect-error we want to set this
 | 
				
			||||||
 | 
					            similarity_search_id: undefined,
 | 
				
			||||||
 | 
					            search_type: undefined,
 | 
				
			||||||
 | 
					          };
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (newFilter) {
 | 
				
			||||||
 | 
					        setSearchFilter(newFilter);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    [searchFilter, setSearchFilter],
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useSearchEffect("similarity_search_id", (similarityId) => {
 | 
				
			||||||
    if (
 | 
					    updateFilterWithSimilarity({ id: similarityId });
 | 
				
			||||||
      config?.semantic_search.enabled &&
 | 
					  });
 | 
				
			||||||
      searchSearchParams["search_type"] == "similarity" &&
 | 
					 | 
				
			||||||
      searchSearchParams["event_id"]?.length != 0 &&
 | 
					 | 
				
			||||||
      searchFilter
 | 
					 | 
				
			||||||
    ) {
 | 
					 | 
				
			||||||
      setSimilaritySearch({
 | 
					 | 
				
			||||||
        id: searchSearchParams["event_id"],
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // remove event id from url params
 | 
					 | 
				
			||||||
      const { event_id: _event_id, ...newFilter } = searchFilter;
 | 
					 | 
				
			||||||
      setSearchFilter(newFilter);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    // only run similarity search with event_id in the url when coming from review
 | 
					 | 
				
			||||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
					 | 
				
			||||||
  }, []);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    if (similaritySearch) {
 | 
					    if (similaritySearch) {
 | 
				
			||||||
@ -118,21 +136,25 @@ export default function Search() {
 | 
				
			|||||||
      ];
 | 
					      ];
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return [
 | 
					    if (searchSearchParams && Object.keys(searchSearchParams).length !== 0) {
 | 
				
			||||||
      "events",
 | 
					      return [
 | 
				
			||||||
      {
 | 
					        "events",
 | 
				
			||||||
        cameras: searchSearchParams["cameras"],
 | 
					        {
 | 
				
			||||||
        labels: searchSearchParams["labels"],
 | 
					          cameras: searchSearchParams["cameras"],
 | 
				
			||||||
        sub_labels: searchSearchParams["subLabels"],
 | 
					          labels: searchSearchParams["labels"],
 | 
				
			||||||
        zones: searchSearchParams["zones"],
 | 
					          sub_labels: searchSearchParams["subLabels"],
 | 
				
			||||||
        before: searchSearchParams["before"],
 | 
					          zones: searchSearchParams["zones"],
 | 
				
			||||||
        after: searchSearchParams["after"],
 | 
					          before: searchSearchParams["before"],
 | 
				
			||||||
        search_type: searchSearchParams["search_type"],
 | 
					          after: searchSearchParams["after"],
 | 
				
			||||||
        limit: Object.keys(searchSearchParams).length == 0 ? 20 : null,
 | 
					          search_type: searchSearchParams["search_type"],
 | 
				
			||||||
        in_progress: 0,
 | 
					          limit: Object.keys(searchSearchParams).length == 0 ? 20 : null,
 | 
				
			||||||
        include_thumbnails: 0,
 | 
					          in_progress: 0,
 | 
				
			||||||
      },
 | 
					          include_thumbnails: 0,
 | 
				
			||||||
    ];
 | 
					        },
 | 
				
			||||||
 | 
					      ];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
  }, [searchTerm, searchSearchParams, similaritySearch]);
 | 
					  }, [searchTerm, searchSearchParams, similaritySearch]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { data: searchResults, isLoading } =
 | 
					  const { data: searchResults, isLoading } =
 | 
				
			||||||
@ -230,11 +252,10 @@ export default function Search() {
 | 
				
			|||||||
        searchTerm={searchTerm}
 | 
					        searchTerm={searchTerm}
 | 
				
			||||||
        searchFilter={searchFilter}
 | 
					        searchFilter={searchFilter}
 | 
				
			||||||
        searchResults={searchResults}
 | 
					        searchResults={searchResults}
 | 
				
			||||||
        allPreviews={allPreviews}
 | 
					 | 
				
			||||||
        isLoading={isLoading}
 | 
					        isLoading={isLoading}
 | 
				
			||||||
        setSearch={setSearch}
 | 
					        setSearch={setSearch}
 | 
				
			||||||
        similaritySearch={similaritySearch}
 | 
					        similaritySearch={similaritySearch}
 | 
				
			||||||
        setSimilaritySearch={setSimilaritySearch}
 | 
					        setSimilaritySearch={updateFilterWithSimilarity}
 | 
				
			||||||
        onUpdateFilter={onUpdateFilter}
 | 
					        onUpdateFilter={onUpdateFilter}
 | 
				
			||||||
        onOpenSearch={onOpenSearch}
 | 
					        onOpenSearch={onOpenSearch}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
@ -10,6 +10,9 @@ export type SearchResult = {
 | 
				
			|||||||
  label: string;
 | 
					  label: string;
 | 
				
			||||||
  sub_label?: string;
 | 
					  sub_label?: string;
 | 
				
			||||||
  thumb_path?: string;
 | 
					  thumb_path?: string;
 | 
				
			||||||
 | 
					  plus_id?: string;
 | 
				
			||||||
 | 
					  has_snapshot: boolean;
 | 
				
			||||||
 | 
					  has_clip: boolean;
 | 
				
			||||||
  zones: string[];
 | 
					  zones: string[];
 | 
				
			||||||
  search_source: SearchSource;
 | 
					  search_source: SearchSource;
 | 
				
			||||||
  search_distance: number;
 | 
					  search_distance: number;
 | 
				
			||||||
@ -25,7 +28,6 @@ export type SearchResult = {
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
export type PartialSearchResult = Partial<SearchResult> & { id: string };
 | 
					export type PartialSearchResult = Partial<SearchResult> & { id: string };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type SearchFilter = {
 | 
					export type SearchFilter = {
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										158
									
								
								web/src/views/explore/ExploreView.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										158
									
								
								web/src/views/explore/ExploreView.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,158 @@
 | 
				
			|||||||
 | 
					import { useEffect, useMemo } from "react";
 | 
				
			||||||
 | 
					import { isIOS, isMobileOnly } from "react-device-detect";
 | 
				
			||||||
 | 
					import useSWR from "swr";
 | 
				
			||||||
 | 
					import { useApiHost } from "@/api";
 | 
				
			||||||
 | 
					import { cn } from "@/lib/utils";
 | 
				
			||||||
 | 
					import { LuArrowRightCircle } from "react-icons/lu";
 | 
				
			||||||
 | 
					import { useNavigate } from "react-router-dom";
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  Tooltip,
 | 
				
			||||||
 | 
					  TooltipContent,
 | 
				
			||||||
 | 
					  TooltipTrigger,
 | 
				
			||||||
 | 
					} from "@/components/ui/tooltip";
 | 
				
			||||||
 | 
					import { TooltipPortal } from "@radix-ui/react-tooltip";
 | 
				
			||||||
 | 
					import { SearchResult } from "@/types/search";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type ExploreViewProps = {
 | 
				
			||||||
 | 
					  onSelectSearch: (searchResult: SearchResult, detail: boolean) => void;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function ExploreView({ onSelectSearch }: ExploreViewProps) {
 | 
				
			||||||
 | 
					  // title
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    document.title = "Explore - Frigate";
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const { data: events } = useSWR<SearchResult[]>(
 | 
				
			||||||
 | 
					    [
 | 
				
			||||||
 | 
					      "events/explore",
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        limit: isMobileOnly ? 5 : 10,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      revalidateOnFocus: false,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const eventsByLabel = useMemo(() => {
 | 
				
			||||||
 | 
					    if (!events) return {};
 | 
				
			||||||
 | 
					    return events.reduce<Record<string, SearchResult[]>>((acc, event) => {
 | 
				
			||||||
 | 
					      const label = event.label || "Unknown";
 | 
				
			||||||
 | 
					      if (!acc[label]) {
 | 
				
			||||||
 | 
					        acc[label] = [];
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      acc[label].push(event);
 | 
				
			||||||
 | 
					      return acc;
 | 
				
			||||||
 | 
					    }, {});
 | 
				
			||||||
 | 
					  }, [events]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="space-y-4 overflow-x-hidden p-2">
 | 
				
			||||||
 | 
					      {Object.entries(eventsByLabel).map(([label, filteredEvents]) => (
 | 
				
			||||||
 | 
					        <ThumbnailRow
 | 
				
			||||||
 | 
					          key={label}
 | 
				
			||||||
 | 
					          searchResults={filteredEvents}
 | 
				
			||||||
 | 
					          objectType={label}
 | 
				
			||||||
 | 
					          onSelectSearch={onSelectSearch}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      ))}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type ThumbnailRowType = {
 | 
				
			||||||
 | 
					  objectType: string;
 | 
				
			||||||
 | 
					  searchResults?: SearchResult[];
 | 
				
			||||||
 | 
					  onSelectSearch: (searchResult: SearchResult, detail: boolean) => void;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function ThumbnailRow({
 | 
				
			||||||
 | 
					  objectType,
 | 
				
			||||||
 | 
					  searchResults,
 | 
				
			||||||
 | 
					  onSelectSearch,
 | 
				
			||||||
 | 
					}: ThumbnailRowType) {
 | 
				
			||||||
 | 
					  const apiHost = useApiHost();
 | 
				
			||||||
 | 
					  const navigate = useNavigate();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleSearch = (label: string) => {
 | 
				
			||||||
 | 
					    const similaritySearchParams = new URLSearchParams({
 | 
				
			||||||
 | 
					      labels: label,
 | 
				
			||||||
 | 
					    }).toString();
 | 
				
			||||||
 | 
					    navigate(`/explore?${similaritySearchParams}`);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="rounded-lg bg-background_alt p-2 md:p-4">
 | 
				
			||||||
 | 
					      <div className="text-lg capitalize">
 | 
				
			||||||
 | 
					        {objectType.replaceAll("_", " ")}
 | 
				
			||||||
 | 
					        {searchResults && (
 | 
				
			||||||
 | 
					          <span className="ml-3 text-sm text-secondary-foreground">
 | 
				
			||||||
 | 
					            (
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					              // @ts-expect-error we know this is correct
 | 
				
			||||||
 | 
					              searchResults[0].event_count
 | 
				
			||||||
 | 
					            }{" "}
 | 
				
			||||||
 | 
					            detected objects){" "}
 | 
				
			||||||
 | 
					          </span>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div className="flex flex-row items-center space-x-2 py-2">
 | 
				
			||||||
 | 
					        {searchResults?.map((event) => (
 | 
				
			||||||
 | 
					          <div
 | 
				
			||||||
 | 
					            key={event.id}
 | 
				
			||||||
 | 
					            className="relative aspect-square h-auto max-w-[20%] flex-grow md:max-w-[10%]"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <img
 | 
				
			||||||
 | 
					              className={cn(
 | 
				
			||||||
 | 
					                "absolute h-full w-full cursor-pointer rounded-lg object-cover transition-all duration-300 ease-in-out md:rounded-2xl",
 | 
				
			||||||
 | 
					              )}
 | 
				
			||||||
 | 
					              style={
 | 
				
			||||||
 | 
					                isIOS
 | 
				
			||||||
 | 
					                  ? {
 | 
				
			||||||
 | 
					                      WebkitUserSelect: "none",
 | 
				
			||||||
 | 
					                      WebkitTouchCallout: "none",
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                  : undefined
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					              draggable={false}
 | 
				
			||||||
 | 
					              src={`${apiHost}api/events/${event.id}/thumbnail.jpg`}
 | 
				
			||||||
 | 
					              alt={`${objectType} snapshot`}
 | 
				
			||||||
 | 
					              onClick={() => onSelectSearch(event, true)}
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        ))}
 | 
				
			||||||
 | 
					        <div
 | 
				
			||||||
 | 
					          className="flex cursor-pointer items-center justify-center"
 | 
				
			||||||
 | 
					          onClick={() => handleSearch(objectType)}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <Tooltip>
 | 
				
			||||||
 | 
					            <TooltipTrigger>
 | 
				
			||||||
 | 
					              <LuArrowRightCircle
 | 
				
			||||||
 | 
					                className="ml-2 text-secondary-foreground transition-all duration-300 hover:text-primary"
 | 
				
			||||||
 | 
					                size={24}
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					            </TooltipTrigger>
 | 
				
			||||||
 | 
					            <TooltipPortal>
 | 
				
			||||||
 | 
					              <TooltipContent className="capitalize">
 | 
				
			||||||
 | 
					                <ExploreMoreLink objectType={objectType} />
 | 
				
			||||||
 | 
					              </TooltipContent>
 | 
				
			||||||
 | 
					            </TooltipPortal>
 | 
				
			||||||
 | 
					          </Tooltip>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function ExploreMoreLink({ objectType }: { objectType: string }) {
 | 
				
			||||||
 | 
					  const formattedType = objectType.replaceAll("_", " ");
 | 
				
			||||||
 | 
					  const label = formattedType.endsWith("s")
 | 
				
			||||||
 | 
					    ? `${formattedType}es`
 | 
				
			||||||
 | 
					    : `${formattedType}s`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return <div>Explore More {label}</div>;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,8 +1,8 @@
 | 
				
			|||||||
 | 
					import SearchThumbnail from "@/components/card/SearchThumbnail";
 | 
				
			||||||
import SearchFilterGroup from "@/components/filter/SearchFilterGroup";
 | 
					import SearchFilterGroup from "@/components/filter/SearchFilterGroup";
 | 
				
			||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
 | 
					import ActivityIndicator from "@/components/indicators/activity-indicator";
 | 
				
			||||||
import Chip from "@/components/indicators/Chip";
 | 
					import Chip from "@/components/indicators/Chip";
 | 
				
			||||||
import SearchDetailDialog from "@/components/overlay/detail/SearchDetailDialog";
 | 
					import SearchDetailDialog from "@/components/overlay/detail/SearchDetailDialog";
 | 
				
			||||||
import SearchThumbnailPlayer from "@/components/player/SearchThumbnailPlayer";
 | 
					 | 
				
			||||||
import { Input } from "@/components/ui/input";
 | 
					import { Input } from "@/components/ui/input";
 | 
				
			||||||
import { Toaster } from "@/components/ui/sonner";
 | 
					import { Toaster } from "@/components/ui/sonner";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
@ -12,7 +12,6 @@ import {
 | 
				
			|||||||
} from "@/components/ui/tooltip";
 | 
					} from "@/components/ui/tooltip";
 | 
				
			||||||
import { cn } from "@/lib/utils";
 | 
					import { cn } from "@/lib/utils";
 | 
				
			||||||
import { FrigateConfig } from "@/types/frigateConfig";
 | 
					import { FrigateConfig } from "@/types/frigateConfig";
 | 
				
			||||||
import { Preview } from "@/types/preview";
 | 
					 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  PartialSearchResult,
 | 
					  PartialSearchResult,
 | 
				
			||||||
  SearchFilter,
 | 
					  SearchFilter,
 | 
				
			||||||
@ -22,13 +21,13 @@ import { useCallback, useMemo, useState } from "react";
 | 
				
			|||||||
import { isMobileOnly } from "react-device-detect";
 | 
					import { isMobileOnly } from "react-device-detect";
 | 
				
			||||||
import { LuImage, LuSearchX, LuText, LuXCircle } from "react-icons/lu";
 | 
					import { LuImage, LuSearchX, LuText, LuXCircle } from "react-icons/lu";
 | 
				
			||||||
import useSWR from "swr";
 | 
					import useSWR from "swr";
 | 
				
			||||||
 | 
					import ExploreView from "../explore/ExploreView";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type SearchViewProps = {
 | 
					type SearchViewProps = {
 | 
				
			||||||
  search: string;
 | 
					  search: string;
 | 
				
			||||||
  searchTerm: string;
 | 
					  searchTerm: string;
 | 
				
			||||||
  searchFilter?: SearchFilter;
 | 
					  searchFilter?: SearchFilter;
 | 
				
			||||||
  searchResults?: SearchResult[];
 | 
					  searchResults?: SearchResult[];
 | 
				
			||||||
  allPreviews?: Preview[];
 | 
					 | 
				
			||||||
  isLoading: boolean;
 | 
					  isLoading: boolean;
 | 
				
			||||||
  similaritySearch?: PartialSearchResult;
 | 
					  similaritySearch?: PartialSearchResult;
 | 
				
			||||||
  setSearch: (search: string) => void;
 | 
					  setSearch: (search: string) => void;
 | 
				
			||||||
@ -41,13 +40,11 @@ export default function SearchView({
 | 
				
			|||||||
  searchTerm,
 | 
					  searchTerm,
 | 
				
			||||||
  searchFilter,
 | 
					  searchFilter,
 | 
				
			||||||
  searchResults,
 | 
					  searchResults,
 | 
				
			||||||
  allPreviews,
 | 
					 | 
				
			||||||
  isLoading,
 | 
					  isLoading,
 | 
				
			||||||
  similaritySearch,
 | 
					  similaritySearch,
 | 
				
			||||||
  setSearch,
 | 
					  setSearch,
 | 
				
			||||||
  setSimilaritySearch,
 | 
					  setSimilaritySearch,
 | 
				
			||||||
  onUpdateFilter,
 | 
					  onUpdateFilter,
 | 
				
			||||||
  onOpenSearch,
 | 
					 | 
				
			||||||
}: SearchViewProps) {
 | 
					}: SearchViewProps) {
 | 
				
			||||||
  const { data: config } = useSWR<FrigateConfig>("config", {
 | 
					  const { data: config } = useSWR<FrigateConfig>("config", {
 | 
				
			||||||
    revalidateOnFocus: false,
 | 
					    revalidateOnFocus: false,
 | 
				
			||||||
@ -68,16 +65,13 @@ export default function SearchView({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  // search interaction
 | 
					  // search interaction
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onSelectSearch = useCallback(
 | 
					  const onSelectSearch = useCallback((item: SearchResult, detail: boolean) => {
 | 
				
			||||||
    (item: SearchResult, detail: boolean) => {
 | 
					    if (detail) {
 | 
				
			||||||
      if (detail) {
 | 
					      setSearchDetail(item);
 | 
				
			||||||
        setSearchDetail(item);
 | 
					    } else {
 | 
				
			||||||
      } else {
 | 
					      setSearchDetail(item);
 | 
				
			||||||
        onOpenSearch(item);
 | 
					    }
 | 
				
			||||||
      }
 | 
					  }, []);
 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    [onOpenSearch],
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // confidence score - probably needs tweaking
 | 
					  // confidence score - probably needs tweaking
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -116,25 +110,23 @@ export default function SearchView({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      <div
 | 
					      <div
 | 
				
			||||||
        className={cn(
 | 
					        className={cn(
 | 
				
			||||||
          "relative mb-2 flex h-11 items-center pl-2 pr-2 md:pl-3",
 | 
					          "flex h-11 items-center pl-2 pr-2 md:pl-3",
 | 
				
			||||||
          config?.semantic_search?.enabled
 | 
					          config?.semantic_search?.enabled
 | 
				
			||||||
            ? "justify-between"
 | 
					            ? "justify-between"
 | 
				
			||||||
            : "justify-center",
 | 
					            : "justify-center",
 | 
				
			||||||
          isMobileOnly && "h-[88px] flex-wrap gap-2",
 | 
					          isMobileOnly && "mb-3 h-auto flex-wrap gap-2",
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        {config?.semantic_search?.enabled && (
 | 
					        {config?.semantic_search?.enabled && (
 | 
				
			||||||
          <div
 | 
					          <div
 | 
				
			||||||
            className={cn(
 | 
					            className={cn(
 | 
				
			||||||
              "relative w-full",
 | 
					              "relative w-full",
 | 
				
			||||||
              hasExistingSearch ? "mr-3 md:w-1/3" : "md:ml-[25%] md:w-1/2",
 | 
					              hasExistingSearch ? "md:mr-3 md:w-1/3" : "md:ml-[25%] md:w-1/2",
 | 
				
			||||||
            )}
 | 
					            )}
 | 
				
			||||||
          >
 | 
					          >
 | 
				
			||||||
            <Input
 | 
					            <Input
 | 
				
			||||||
              className="text-md w-full bg-muted pr-10"
 | 
					              className="text-md w-full bg-muted pr-10"
 | 
				
			||||||
              placeholder={
 | 
					              placeholder={"Search for a detected object..."}
 | 
				
			||||||
                isMobileOnly ? "Search" : "Search for a detected object..."
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
              value={similaritySearch ? "" : search}
 | 
					              value={similaritySearch ? "" : search}
 | 
				
			||||||
              onChange={(e) => setSearch(e.target.value)}
 | 
					              onChange={(e) => setSearch(e.target.value)}
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
@ -168,66 +160,73 @@ export default function SearchView({
 | 
				
			|||||||
          <ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
 | 
					          <ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div className="grid w-full gap-2 px-1 sm:grid-cols-2 md:mx-2 md:grid-cols-4 md:gap-4 3xl:grid-cols-6">
 | 
					        {uniqueResults && (
 | 
				
			||||||
          {uniqueResults &&
 | 
					          <div className="mt-2 grid w-full gap-2 px-1 sm:grid-cols-2 md:mx-2 md:grid-cols-4 md:gap-4 3xl:grid-cols-6">
 | 
				
			||||||
            uniqueResults.map((value) => {
 | 
					            {uniqueResults &&
 | 
				
			||||||
              const selected = false;
 | 
					              uniqueResults.map((value) => {
 | 
				
			||||||
 | 
					                const selected = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              return (
 | 
					                return (
 | 
				
			||||||
                <div
 | 
					 | 
				
			||||||
                  key={value.id}
 | 
					 | 
				
			||||||
                  data-start={value.start_time}
 | 
					 | 
				
			||||||
                  className="review-item relative rounded-lg"
 | 
					 | 
				
			||||||
                >
 | 
					 | 
				
			||||||
                  <div
 | 
					                  <div
 | 
				
			||||||
                    className={cn(
 | 
					                    key={value.id}
 | 
				
			||||||
                      "aspect-square size-full overflow-hidden rounded-lg",
 | 
					                    data-start={value.start_time}
 | 
				
			||||||
                    )}
 | 
					                    className="review-item relative rounded-lg"
 | 
				
			||||||
                  >
 | 
					                  >
 | 
				
			||||||
                    <SearchThumbnailPlayer
 | 
					                    <div
 | 
				
			||||||
                      searchResult={value}
 | 
					                      className={cn(
 | 
				
			||||||
                      allPreviews={allPreviews}
 | 
					                        "aspect-square size-full overflow-hidden rounded-lg",
 | 
				
			||||||
                      scrollLock={false}
 | 
					                      )}
 | 
				
			||||||
                      onClick={onSelectSearch}
 | 
					                    >
 | 
				
			||||||
                    />
 | 
					                      <SearchThumbnail
 | 
				
			||||||
                    {(searchTerm || similaritySearch) && (
 | 
					                        searchResult={value}
 | 
				
			||||||
                      <div className={cn("absolute right-2 top-2 z-40")}>
 | 
					                        scrollLock={false}
 | 
				
			||||||
                        <Tooltip>
 | 
					                        findSimilar={() => setSimilaritySearch(value)}
 | 
				
			||||||
                          <TooltipTrigger>
 | 
					                        onClick={onSelectSearch}
 | 
				
			||||||
                            <Chip
 | 
					                      />
 | 
				
			||||||
                              className={`flex select-none items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize text-white`}
 | 
					                      {(searchTerm || similaritySearch) && (
 | 
				
			||||||
                            >
 | 
					                        <div className={cn("absolute right-2 top-2 z-40")}>
 | 
				
			||||||
                              {value.search_source == "thumbnail" ? (
 | 
					                          <Tooltip>
 | 
				
			||||||
                                <LuImage className="mr-1 size-3" />
 | 
					                            <TooltipTrigger>
 | 
				
			||||||
                              ) : (
 | 
					                              <Chip
 | 
				
			||||||
                                <LuText className="mr-1 size-3" />
 | 
					                                className={`flex select-none items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize text-white`}
 | 
				
			||||||
                              )}
 | 
					                              >
 | 
				
			||||||
 | 
					                                {value.search_source == "thumbnail" ? (
 | 
				
			||||||
 | 
					                                  <LuImage className="mr-1 size-3" />
 | 
				
			||||||
 | 
					                                ) : (
 | 
				
			||||||
 | 
					                                  <LuText className="mr-1 size-3" />
 | 
				
			||||||
 | 
					                                )}
 | 
				
			||||||
 | 
					                                {zScoreToConfidence(
 | 
				
			||||||
 | 
					                                  value.search_distance,
 | 
				
			||||||
 | 
					                                  value.search_source,
 | 
				
			||||||
 | 
					                                )}
 | 
				
			||||||
 | 
					                                %
 | 
				
			||||||
 | 
					                              </Chip>
 | 
				
			||||||
 | 
					                            </TooltipTrigger>
 | 
				
			||||||
 | 
					                            <TooltipContent>
 | 
				
			||||||
 | 
					                              Matched {value.search_source} at{" "}
 | 
				
			||||||
                              {zScoreToConfidence(
 | 
					                              {zScoreToConfidence(
 | 
				
			||||||
                                value.search_distance,
 | 
					                                value.search_distance,
 | 
				
			||||||
                                value.search_source,
 | 
					                                value.search_source,
 | 
				
			||||||
                              )}
 | 
					                              )}
 | 
				
			||||||
                              %
 | 
					                              %
 | 
				
			||||||
                            </Chip>
 | 
					                            </TooltipContent>
 | 
				
			||||||
                          </TooltipTrigger>
 | 
					                          </Tooltip>
 | 
				
			||||||
                          <TooltipContent>
 | 
					                        </div>
 | 
				
			||||||
                            Matched {value.search_source} at{" "}
 | 
					                      )}
 | 
				
			||||||
                            {zScoreToConfidence(
 | 
					                    </div>
 | 
				
			||||||
                              value.search_distance,
 | 
					                    <div
 | 
				
			||||||
                              value.search_source,
 | 
					                      className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `shadow-severity_alert outline-severity_alert` : "outline-transparent duration-500"}`}
 | 
				
			||||||
                            )}
 | 
					                    />
 | 
				
			||||||
                            %
 | 
					 | 
				
			||||||
                          </TooltipContent>
 | 
					 | 
				
			||||||
                        </Tooltip>
 | 
					 | 
				
			||||||
                      </div>
 | 
					 | 
				
			||||||
                    )}
 | 
					 | 
				
			||||||
                  </div>
 | 
					                  </div>
 | 
				
			||||||
                  <div
 | 
					                );
 | 
				
			||||||
                    className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `shadow-severity_alert outline-severity_alert` : "outline-transparent duration-500"}`}
 | 
					              })}
 | 
				
			||||||
                  />
 | 
					          </div>
 | 
				
			||||||
                </div>
 | 
					        )}
 | 
				
			||||||
              );
 | 
					        {!uniqueResults && !isLoading && (
 | 
				
			||||||
            })}
 | 
					          <div className="flex size-full flex-col">
 | 
				
			||||||
        </div>
 | 
					            <ExploreView onSelectSearch={onSelectSearch} />
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user