From c0ba98e26fa5fc103a84235d870a9ee68a4fa4a1 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Wed, 4 Dec 2024 09:54:10 -0600
Subject: [PATCH] Explore sorting (#15342)

* backend

* add type and params

* radio group in ui

* ensure search_type is cleared on reset
---
 frigate/api/event.py                          |  22 +-
 .../components/filter/SearchFilterGroup.tsx   | 206 +++++++++++++++++-
 web/src/components/input/InputWithTags.tsx    |   4 +
 .../overlay/dialog/SearchFilterDialog.tsx     |   2 +-
 web/src/pages/Explore.tsx                     |  11 +-
 web/src/types/search.ts                       |  11 +
 6 files changed, 241 insertions(+), 15 deletions(-)

diff --git a/frigate/api/event.py b/frigate/api/event.py
index fafa28272..dc98d094e 100644
--- a/frigate/api/event.py
+++ b/frigate/api/event.py
@@ -248,6 +248,8 @@ def events(params: EventsQueryParams = Depends()):
             order_by = Event.start_time.asc()
         elif sort == "date_desc":
             order_by = Event.start_time.desc()
+        else:
+            order_by = Event.start_time.desc()
     else:
         order_by = Event.start_time.desc()
 
@@ -582,19 +584,17 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
 
         processed_events.append(processed_event)
 
-    # Sort by search distance if search_results are available, otherwise by start_time as default
-    if search_results:
+    if (sort is None or sort == "relevance") and search_results:
         processed_events.sort(key=lambda x: x.get("search_distance", float("inf")))
+    elif min_score is not None and max_score is not None and sort == "score_asc":
+        processed_events.sort(key=lambda x: x["score"])
+    elif min_score is not None and max_score is not None and sort == "score_desc":
+        processed_events.sort(key=lambda x: x["score"], reverse=True)
+    elif sort == "date_asc":
+        processed_events.sort(key=lambda x: x["start_time"])
     else:
-        if sort == "score_asc":
-            processed_events.sort(key=lambda x: x["score"])
-        elif sort == "score_desc":
-            processed_events.sort(key=lambda x: x["score"], reverse=True)
-        elif sort == "date_asc":
-            processed_events.sort(key=lambda x: x["start_time"])
-        else:
-            # "date_desc" default
-            processed_events.sort(key=lambda x: x["start_time"], reverse=True)
+        # "date_desc" default
+        processed_events.sort(key=lambda x: x["start_time"], reverse=True)
 
     # Limit the number of events returned
     processed_events = processed_events[:limit]
diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx
index 5f3755e15..e8599895d 100644
--- a/web/src/components/filter/SearchFilterGroup.tsx
+++ b/web/src/components/filter/SearchFilterGroup.tsx
@@ -15,13 +15,15 @@ import {
   SearchFilter,
   SearchFilters,
   SearchSource,
+  SearchSortType,
 } from "@/types/search";
 import { DateRange } from "react-day-picker";
 import { cn } from "@/lib/utils";
-import { MdLabel } from "react-icons/md";
+import { MdLabel, MdSort } from "react-icons/md";
 import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
 import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
 import { CalendarRangeFilterButton } from "./CalendarFilterButton";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
 
 type SearchFilterGroupProps = {
   className: string;
@@ -107,6 +109,25 @@ export default function SearchFilterGroup({
     [config, allLabels, allZones],
   );
 
+  const availableSortTypes = useMemo(() => {
+    const sortTypes = ["date_asc", "date_desc"];
+    if (filter?.min_score || filter?.max_score) {
+      sortTypes.push("score_desc", "score_asc");
+    }
+    if (filter?.event_id || filter?.query) {
+      sortTypes.push("relevance");
+    }
+    return sortTypes as SearchSortType[];
+  }, [filter]);
+
+  const defaultSortType = useMemo<SearchSortType>(() => {
+    if (filter?.query || filter?.event_id) {
+      return "relevance";
+    } else {
+      return "date_desc";
+    }
+  }, [filter]);
+
   const groups = useMemo(() => {
     if (!config) {
       return [];
@@ -179,6 +200,16 @@ export default function SearchFilterGroup({
         filterValues={filterValues}
         onUpdateFilter={onUpdateFilter}
       />
+      {filters.includes("sort") && Object.keys(filter ?? {}).length > 0 && (
+        <SortTypeButton
+          availableSortTypes={availableSortTypes ?? []}
+          defaultSortType={defaultSortType}
+          selectedSortType={filter?.sort}
+          updateSortType={(newSort) => {
+            onUpdateFilter({ ...filter, sort: newSort });
+          }}
+        />
+      )}
     </div>
   );
 }
@@ -362,3 +393,176 @@ export function GeneralFilterContent({
     </>
   );
 }
+
+type SortTypeButtonProps = {
+  availableSortTypes: SearchSortType[];
+  defaultSortType: SearchSortType;
+  selectedSortType: SearchSortType | undefined;
+  updateSortType: (sortType: SearchSortType | undefined) => void;
+};
+function SortTypeButton({
+  availableSortTypes,
+  defaultSortType,
+  selectedSortType,
+  updateSortType,
+}: SortTypeButtonProps) {
+  const [open, setOpen] = useState(false);
+  const [currentSortType, setCurrentSortType] = useState<
+    SearchSortType | undefined
+  >(selectedSortType as SearchSortType);
+
+  // ui
+
+  useEffect(() => {
+    setCurrentSortType(selectedSortType);
+    // only refresh when state changes
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [selectedSortType]);
+
+  const trigger = (
+    <Button
+      size="sm"
+      variant={
+        selectedSortType != defaultSortType && selectedSortType != undefined
+          ? "select"
+          : "default"
+      }
+      className="flex items-center gap-2 capitalize"
+      aria-label="Labels"
+    >
+      <MdSort
+        className={`${selectedSortType != defaultSortType && selectedSortType != undefined ? "text-selected-foreground" : "text-secondary-foreground"}`}
+      />
+      <div
+        className={`${selectedSortType != defaultSortType && selectedSortType != undefined ? "text-selected-foreground" : "text-primary"}`}
+      >
+        Sort
+      </div>
+    </Button>
+  );
+  const content = (
+    <SortTypeContent
+      availableSortTypes={availableSortTypes ?? []}
+      defaultSortType={defaultSortType}
+      selectedSortType={selectedSortType}
+      currentSortType={currentSortType}
+      setCurrentSortType={setCurrentSortType}
+      updateSortType={updateSortType}
+      onClose={() => setOpen(false)}
+    />
+  );
+
+  return (
+    <PlatformAwareDialog
+      trigger={trigger}
+      content={content}
+      contentClassName={
+        isDesktop
+          ? "scrollbar-container h-auto max-h-[80dvh] overflow-y-auto"
+          : "max-h-[75dvh] overflow-hidden p-4"
+      }
+      open={open}
+      onOpenChange={(open) => {
+        if (!open) {
+          setCurrentSortType(selectedSortType);
+        }
+
+        setOpen(open);
+      }}
+    />
+  );
+}
+
+type SortTypeContentProps = {
+  availableSortTypes: SearchSortType[];
+  defaultSortType: SearchSortType;
+  selectedSortType: SearchSortType | undefined;
+  currentSortType: SearchSortType | undefined;
+  updateSortType: (sort_type: SearchSortType | undefined) => void;
+  setCurrentSortType: (sort_type: SearchSortType | undefined) => void;
+  onClose: () => void;
+};
+export function SortTypeContent({
+  availableSortTypes,
+  defaultSortType,
+  selectedSortType,
+  currentSortType,
+  updateSortType,
+  setCurrentSortType,
+  onClose,
+}: SortTypeContentProps) {
+  const sortLabels = {
+    date_asc: "Date (Ascending)",
+    date_desc: "Date (Descending)",
+    score_asc: "Object Score (Ascending)",
+    score_desc: "Object Score (Descending)",
+    relevance: "Relevance",
+  };
+
+  return (
+    <>
+      <div className="overflow-x-hidden">
+        <div className="my-2.5 flex flex-col gap-2.5">
+          <RadioGroup
+            value={
+              Array.isArray(currentSortType)
+                ? currentSortType?.[0]
+                : (currentSortType ?? defaultSortType)
+            }
+            defaultValue={defaultSortType}
+            onValueChange={(value) =>
+              setCurrentSortType(value as SearchSortType)
+            }
+            className="w-full space-y-1"
+          >
+            {availableSortTypes.map((value) => (
+              <div className="flex flex-row gap-2">
+                <RadioGroupItem
+                  key={value}
+                  value={value}
+                  id={`sort-${value}`}
+                  className={
+                    value == (currentSortType ?? defaultSortType)
+                      ? "bg-selected from-selected/50 to-selected/90 text-selected"
+                      : "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
+                  }
+                />
+                <Label
+                  htmlFor={`sort-${value}`}
+                  className="flex cursor-pointer items-center space-x-2"
+                >
+                  <span>{sortLabels[value]}</span>
+                </Label>
+              </div>
+            ))}
+          </RadioGroup>
+        </div>
+      </div>
+      <DropdownMenuSeparator />
+      <div className="flex items-center justify-evenly p-2">
+        <Button
+          aria-label="Apply"
+          variant="select"
+          onClick={() => {
+            if (selectedSortType != currentSortType) {
+              updateSortType(currentSortType);
+            }
+
+            onClose();
+          }}
+        >
+          Apply
+        </Button>
+        <Button
+          aria-label="Reset"
+          onClick={() => {
+            setCurrentSortType(undefined);
+            updateSortType(undefined);
+          }}
+        >
+          Reset
+        </Button>
+      </div>
+    </>
+  );
+}
diff --git a/web/src/components/input/InputWithTags.tsx b/web/src/components/input/InputWithTags.tsx
index 8f60bb73e..d5904b2a5 100644
--- a/web/src/components/input/InputWithTags.tsx
+++ b/web/src/components/input/InputWithTags.tsx
@@ -18,6 +18,7 @@ import {
   FilterType,
   SavedSearchQuery,
   SearchFilter,
+  SearchSortType,
   SearchSource,
 } from "@/types/search";
 import useSuggestions from "@/hooks/use-suggestions";
@@ -323,6 +324,9 @@ export default function InputWithTags({
           case "event_id":
             newFilters.event_id = value;
             break;
+          case "sort":
+            newFilters.sort = value as SearchSortType;
+            break;
           default:
             // Handle array types (cameras, labels, subLabels, zones)
             if (!newFilters[type]) newFilters[type] = [];
diff --git a/web/src/components/overlay/dialog/SearchFilterDialog.tsx b/web/src/components/overlay/dialog/SearchFilterDialog.tsx
index 845c3bc1a..65109591b 100644
--- a/web/src/components/overlay/dialog/SearchFilterDialog.tsx
+++ b/web/src/components/overlay/dialog/SearchFilterDialog.tsx
@@ -175,7 +175,7 @@ export default function SearchFilterDialog({
               time_range: undefined,
               zones: undefined,
               sub_labels: undefined,
-              search_type: ["thumbnail", "description"],
+              search_type: undefined,
               min_score: undefined,
               max_score: undefined,
               has_snapshot: undefined,
diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx
index 2bf2bb022..ce2560868 100644
--- a/web/src/pages/Explore.tsx
+++ b/web/src/pages/Explore.tsx
@@ -116,6 +116,7 @@ export default function Explore() {
           is_submitted: searchSearchParams["is_submitted"],
           has_clip: searchSearchParams["has_clip"],
           event_id: searchSearchParams["event_id"],
+          sort: searchSearchParams["sort"],
           limit:
             Object.keys(searchSearchParams).length == 0 ? API_LIMIT : undefined,
           timezone,
@@ -148,6 +149,7 @@ export default function Explore() {
         is_submitted: searchSearchParams["is_submitted"],
         has_clip: searchSearchParams["has_clip"],
         event_id: searchSearchParams["event_id"],
+        sort: searchSearchParams["sort"],
         timezone,
         include_thumbnails: 0,
       },
@@ -165,12 +167,17 @@ export default function Explore() {
 
     const [url, params] = searchQuery;
 
-    // If it's not the first page, use the last item's start_time as the 'before' parameter
+    const isAscending = params.sort?.includes("date_asc");
+
     if (pageIndex > 0 && previousPageData) {
       const lastDate = previousPageData[previousPageData.length - 1].start_time;
       return [
         url,
-        { ...params, before: lastDate.toString(), limit: API_LIMIT },
+        {
+          ...params,
+          [isAscending ? "after" : "before"]: lastDate.toString(),
+          limit: API_LIMIT,
+        },
       ];
     }
 
diff --git a/web/src/types/search.ts b/web/src/types/search.ts
index fafedad10..1d8de1611 100644
--- a/web/src/types/search.ts
+++ b/web/src/types/search.ts
@@ -6,6 +6,7 @@ const SEARCH_FILTERS = [
   "zone",
   "sub",
   "source",
+  "sort",
 ] as const;
 export type SearchFilters = (typeof SEARCH_FILTERS)[number];
 export const DEFAULT_SEARCH_FILTERS: SearchFilters[] = [
@@ -16,10 +17,18 @@ export const DEFAULT_SEARCH_FILTERS: SearchFilters[] = [
   "zone",
   "sub",
   "source",
+  "sort",
 ];
 
 export type SearchSource = "similarity" | "thumbnail" | "description";
 
+export type SearchSortType =
+  | "date_asc"
+  | "date_desc"
+  | "score_asc"
+  | "score_desc"
+  | "relevance";
+
 export type SearchResult = {
   id: string;
   camera: string;
@@ -65,6 +74,7 @@ export type SearchFilter = {
   time_range?: string;
   search_type?: SearchSource[];
   event_id?: string;
+  sort?: SearchSortType;
 };
 
 export const DEFAULT_TIME_RANGE_AFTER = "00:00";
@@ -86,6 +96,7 @@ export type SearchQueryParams = {
   query?: string;
   page?: number;
   time_range?: string;
+  sort?: SearchSortType;
 };
 
 export type SearchQuery = [string, SearchQueryParams] | null;