Store Review filters in URL params

This commit is contained in:
mccahan 2026-01-22 12:20:26 -07:00
parent 5fdb56a106
commit 72e7f7c834
No known key found for this signature in database
GPG Key ID: 4AD93D9FB6994DFA
4 changed files with 124 additions and 42 deletions

View File

@ -1,6 +1,6 @@
import { FilterType } from "@/types/filter"; import { FilterType } from "@/types/filter";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
function getStringifiedArgs(filter: FilterType) { function getStringifiedArgs(filter: FilterType) {
const search: { [key: string]: string } = {}; const search: { [key: string]: string } = {};
@ -49,12 +49,49 @@ export default function useApiFilter<
export function useApiFilterArgs<F extends FilterType>( export function useApiFilterArgs<F extends FilterType>(
arrayKeys: string[], arrayKeys: string[],
ignoredKeys?: string[],
): useApiFilterReturn<F> { ): useApiFilterReturn<F> {
const [rawParams, setRawParams] = useSearchParams(); const [rawParams] = useSearchParams();
const location = useLocation();
const navigate = useNavigate();
const setFilter = useCallback( const setFilter = useCallback(
(newFilter: F) => setRawParams(getStringifiedArgs(newFilter)), (newFilter: F) => {
[setRawParams], const stringifiedFilter = getStringifiedArgs(newFilter);
// If ignoredKeys is provided, ignore params that aren't managed by this hook
let updated: URLSearchParams;
if (ignoredKeys) {
updated = new URLSearchParams();
// Keep params that aren't ignored by this hook
rawParams.forEach((value, key) => {
if (!ignoredKeys.includes(key)) {
updated.set(key, value);
}
});
// Add/update managed params from the new filter
Object.entries(stringifiedFilter).forEach(([key, value]) => {
updated.set(key, value);
});
} else {
// Original behavior: replace all params
updated = new URLSearchParams(stringifiedFilter);
}
// Use navigate to preserve the hash
const search = updated.toString();
navigate(
{
pathname: location.pathname,
search: search ? `?${search}` : "",
hash: location.hash,
},
{ replace: true },
);
},
[rawParams, ignoredKeys, navigate, location.pathname, location.hash],
); );
const filter = useMemo<F>(() => { const filter = useMemo<F>(() => {

View File

@ -145,9 +145,13 @@ export function useHashState<S extends string>(): [
const setHash = useCallback( const setHash = useCallback(
(value: S | undefined) => { (value: S | undefined) => {
if (!value) { if (!value) {
navigate(location.pathname); navigate(location.pathname + location.search, {
state: location.state,
});
} else { } else {
navigate(`${location.pathname}#${value}`, { state: location.state }); navigate(`${location.pathname}${location.search}#${value}`, {
state: location.state,
});
} }
}, },
// we know that these deps are correct // we know that these deps are correct

View File

@ -1,9 +1,12 @@
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import useApiFilter 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 { useTimezone } from "@/hooks/use-date-utils"; import { useTimezone } from "@/hooks/use-date-utils";
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state"; import {
import { useUserPersistence } from "@/hooks/use-user-persistence"; useHashState,
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 {
@ -25,6 +28,7 @@ import { RecordingView } from "@/views/recording/RecordingView";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import useSWR from "swr"; import useSWR from "swr";
export default function Events() { export default function Events() {
@ -37,14 +41,71 @@ export default function Events() {
// recordings viewer // recordings viewer
const [severity, setSeverity] = useOverlayState<ReviewSeverity>( // showReviewed from URL params
"severity", const [searchParams] = useSearchParams();
"alert", const location = useLocation();
const navigate = useNavigate();
// severity from URL hash
const [hash, setHash] = useHashState<string>();
const severity = useMemo<ReviewSeverity>(() => {
if (hash === "motion") {
return "significant_motion";
} else if (hash === "detections") {
return "detection";
}
return "alert";
}, [hash]);
const setSeverity = useCallback(
(newSeverity: ReviewSeverity) => {
// Clicking the same tab again clears all filters (cameras, date, labels, zones, etc.)
// This provides users a quick way to reset their view
const isSameTab = newSeverity === severity;
let newHash: string;
if (newSeverity === "significant_motion") {
newHash = "motion";
} else if (newSeverity === "detection") {
newHash = "detections";
} else {
newHash = "alerts";
}
if (isSameTab) {
// Clear filters by navigating without search params
navigate(`${location.pathname}#${newHash}`, { state: location.state });
} else {
setHash(newHash);
}
},
[severity, setHash, navigate, location.pathname, location.state],
); );
const [showReviewed, setShowReviewed] = useUserPersistence( const showReviewed = useMemo(() => {
"showReviewed", return searchParams.get("showReviewed") === "true";
false, }, [searchParams]);
const setShowReviewed = useCallback(
(show: boolean) => {
const updated = new URLSearchParams(searchParams);
if (show) {
updated.set("showReviewed", "true");
} else {
updated.delete("showReviewed");
}
const search = updated.toString();
navigate(
{
pathname: location.pathname,
search: search ? `?${search}` : "",
hash: location.hash,
},
{ replace: true },
);
},
[searchParams, navigate, location.pathname, location.hash],
); );
const [recording, setRecording] = useOverlayState<RecordingStartingPoint>( const [recording, setRecording] = useOverlayState<RecordingStartingPoint>(
@ -104,31 +165,10 @@ export default function Events() {
// review filter // review filter
const [reviewFilter, setReviewFilter, reviewSearchParams] = const [reviewFilter, setReviewFilter, reviewSearchParams] =
useApiFilter<ReviewFilter>(); useApiFilterArgs<ReviewFilter>(
["cameras", "labels", "zones"],
useSearchEffect("cameras", (cameras: string) => { ["cameras", "labels", "zones", "before", "after", "showAll"],
setReviewFilter({ );
...reviewFilter,
cameras: cameras.includes(",") ? cameras.split(",") : [cameras],
});
return true;
});
useSearchEffect("labels", (labels: string) => {
setReviewFilter({
...reviewFilter,
labels: labels.includes(",") ? labels.split(",") : [labels],
});
return true;
});
useSearchEffect("zones", (zones: string) => {
setReviewFilter({
...reviewFilter,
zones: zones.includes(",") ? zones.split(",") : [zones],
});
return true;
});
useSearchEffect("group", (reviewGroup) => { useSearchEffect("group", (reviewGroup) => {
if (config && reviewGroup && reviewGroup != "default") { if (config && reviewGroup && reviewGroup != "default") {

View File

@ -291,8 +291,9 @@ export default function EventView({
size="sm" size="sm"
value={severityToggle} value={severityToggle}
onValueChange={(value: ReviewSeverity) => onValueChange={(value: ReviewSeverity) =>
value ? setSeverityToggle(value) : null // If the user clicks the same tab twice, clear filters as a handy shortcut
} // don't allow the severity to be unselected setSeverityToggle(value || severityToggle)
}
> >
<ToggleGroupItem <ToggleGroupItem
className={cn(severityToggle != "alert" && "text-muted-foreground")} className={cn(severityToggle != "alert" && "text-muted-foreground")}