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(() => { + 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 && ( + { + onUpdateFilter({ ...filter, sort: newSort }); + }} + /> + )} ); } @@ -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 = ( + + ); + const content = ( + setOpen(false)} + /> + ); + + return ( + { + 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 ( + <> +
+
+ + setCurrentSortType(value as SearchSortType) + } + className="w-full space-y-1" + > + {availableSortTypes.map((value) => ( +
+ + +
+ ))} +
+
+
+ +
+ + +
+ + ); +} 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;