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;