diff --git a/web/src/hooks/use-api-filter.ts b/web/src/hooks/use-api-filter.ts index f9baead6e..5dda3292e 100644 --- a/web/src/hooks/use-api-filter.ts +++ b/web/src/hooks/use-api-filter.ts @@ -1,6 +1,6 @@ import { FilterType } from "@/types/filter"; import { useCallback, useMemo, useState } from "react"; -import { useSearchParams } from "react-router-dom"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; function getStringifiedArgs(filter: FilterType) { const search: { [key: string]: string } = {}; @@ -49,12 +49,49 @@ export default function useApiFilter< export function useApiFilterArgs( arrayKeys: string[], + ignoredKeys?: string[], ): useApiFilterReturn { - const [rawParams, setRawParams] = useSearchParams(); + const [rawParams] = useSearchParams(); + const location = useLocation(); + const navigate = useNavigate(); const setFilter = useCallback( - (newFilter: F) => setRawParams(getStringifiedArgs(newFilter)), - [setRawParams], + (newFilter: F) => { + 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(() => { diff --git a/web/src/hooks/use-overlay-state.tsx b/web/src/hooks/use-overlay-state.tsx index 34389c5cf..556220f58 100644 --- a/web/src/hooks/use-overlay-state.tsx +++ b/web/src/hooks/use-overlay-state.tsx @@ -145,9 +145,13 @@ export function useHashState(): [ const setHash = useCallback( (value: S | undefined) => { if (!value) { - navigate(location.pathname); + navigate(location.pathname + location.search, { + state: location.state, + }); } else { - navigate(`${location.pathname}#${value}`, { state: location.state }); + navigate(`${location.pathname}${location.search}#${value}`, { + state: location.state, + }); } }, // we know that these deps are correct diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index a9867cf58..d663f627f 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -1,9 +1,12 @@ 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 { useTimezone } from "@/hooks/use-date-utils"; -import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state"; -import { useUserPersistence } from "@/hooks/use-user-persistence"; +import { + useHashState, + useOverlayState, + useSearchEffect, +} from "@/hooks/use-overlay-state"; import { FrigateConfig } from "@/types/frigateConfig"; import { RecordingStartingPoint } from "@/types/record"; import { @@ -25,6 +28,7 @@ import { RecordingView } from "@/views/recording/RecordingView"; import axios from "axios"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import useSWR from "swr"; export default function Events() { @@ -37,14 +41,71 @@ export default function Events() { // recordings viewer - const [severity, setSeverity] = useOverlayState( - "severity", - "alert", + // showReviewed from URL params + const [searchParams] = useSearchParams(); + const location = useLocation(); + const navigate = useNavigate(); + + // severity from URL hash + const [hash, setHash] = useHashState(); + + const severity = useMemo(() => { + 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( - "showReviewed", - false, + const showReviewed = useMemo(() => { + return searchParams.get("showReviewed") === "true"; + }, [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( @@ -104,31 +165,10 @@ export default function Events() { // review filter const [reviewFilter, setReviewFilter, reviewSearchParams] = - useApiFilter(); - - useSearchEffect("cameras", (cameras: string) => { - 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; - }); + useApiFilterArgs( + ["cameras", "labels", "zones"], + ["cameras", "labels", "zones", "before", "after", "showAll"], + ); useSearchEffect("group", (reviewGroup) => { if (config && reviewGroup && reviewGroup != "default") { diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 417c3231d..4ab4ccc4d 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -291,8 +291,9 @@ export default function EventView({ size="sm" value={severityToggle} onValueChange={(value: ReviewSeverity) => - value ? setSeverityToggle(value) : null - } // don't allow the severity to be unselected + // If the user clicks the same tab twice, clear filters as a handy shortcut + setSeverityToggle(value || severityToggle) + } >