mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-01-31 00:18:55 +01:00
Explore sorting (#15342)
* backend * add type and params * radio group in ui * ensure search_type is cleared on reset
This commit is contained in:
parent
a5a7cd3107
commit
c0ba98e26f
@ -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]
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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] = [];
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user