diff --git a/web/e2e/specs/explore.spec.ts b/web/e2e/specs/explore.spec.ts index 442a6d8d88..4886a719fa 100644 --- a/web/e2e/specs/explore.spec.ts +++ b/web/e2e/specs/explore.spec.ts @@ -216,6 +216,18 @@ test.describe("Explore — content @high", () => { // Similarity search URL param // --------------------------------------------------------------------------- +test.describe("Explore: back button @high", () => { + test("direct visits do not show a back button", async ({ frigateApp }) => { + await frigateApp.goto("/explore?labels=person"); + await expect(frigateApp.page.getByLabel("Labels").first()).toBeVisible({ + timeout: 10_000, + }); + await expect( + frigateApp.page.getByRole("button", { name: "Go back" }), + ).toHaveCount(0); + }); +}); + test.describe("Explore — similarity search (desktop) @high", () => { test.skip( ({ frigateApp }) => frigateApp.isMobile, diff --git a/web/e2e/specs/face-library.spec.ts b/web/e2e/specs/face-library.spec.ts index e499178b7b..db3742a1cd 100644 --- a/web/e2e/specs/face-library.spec.ts +++ b/web/e2e/specs/face-library.spec.ts @@ -30,40 +30,51 @@ function groupedFacesMock() { }); } -async function installGroupedFaces(app: FrigateApp) { +const GROUPED_EVENT = { + id: GROUPED_EVENT_ID, + label: "person", + sub_label: null, + camera: "front_door", + start_time: 1775487131.3863528, + end_time: 1775487161.3863528, + false_positive: false, + zones: ["front_yard"], + thumbnail: null, + has_clip: true, + has_snapshot: true, + retain_indefinitely: false, + plus_id: null, + model_hash: "abc123", + detector_type: "cpu", + model_type: "ssd", + data: { + top_score: 0.92, + score: 0.92, + region: [0.1, 0.1, 0.5, 0.8], + box: [0.2, 0.15, 0.45, 0.75], + area: 0.18, + ratio: 0.6, + type: "object", + path_data: [], + }, +}; + +async function installGroupedFaces( + app: FrigateApp, + opts: { withEventIds?: boolean } = {}, +) { await app.api.install({ - events: [ - { - id: GROUPED_EVENT_ID, - label: "person", - sub_label: null, - camera: "front_door", - start_time: 1775487131.3863528, - end_time: 1775487161.3863528, - false_positive: false, - zones: ["front_yard"], - thumbnail: null, - has_clip: true, - has_snapshot: true, - retain_indefinitely: false, - plus_id: null, - model_hash: "abc123", - detector_type: "cpu", - model_type: "ssd", - data: { - top_score: 0.92, - score: 0.92, - region: [0.1, 0.1, 0.5, 0.8], - box: [0.2, 0.15, 0.45, 0.75], - area: 0.18, - ratio: 0.6, - type: "object", - path_data: [], - }, - }, - ], + events: [GROUPED_EVENT], faces: groupedFacesMock(), }); + + // api-mocker does not cover /api/event_ids, which the card needs to link to + // Explore. Registered after install so it takes precedence. + if (opts.withEventIds) { + await app.page.route("**/api/event_ids**", (route) => + route.fulfill({ json: [GROUPED_EVENT] }), + ); + } } async function openGroupedFaceDialog(app: FrigateApp): Promise { @@ -512,6 +523,34 @@ test.describe("FaceSelectionDialog @high", () => { }); }); +test.describe("Face Library: return from Explore @high", () => { + test("Explore back button returns to an outlined collection", async ({ + frigateApp, + }) => { + await installGroupedFaces(frigateApp, { withEventIds: true }); + await frigateApp.goto("/faces"); + + // Mobile opens the collection as a MobilePage, which has no dialog role + const card = frigateApp.page + .locator('img[src*="clips/faces/train/"]') + .first() + .locator("xpath=.."); + await card.click(); + await frigateApp.page.getByLabel("View in Explore").click(); + await expect(frigateApp.page).toHaveURL( + new RegExp(`/explore\\?event_id=${GROUPED_EVENT_ID}`), + ); + + const back = frigateApp.page.getByRole("button", { name: "Go back" }); + await expect(back).toBeVisible({ timeout: 5_000 }); + await back.click(); + await expect(frigateApp.page).toHaveURL(/\/faces/); + + await expect(card).toHaveClass(/outline-selected/, { timeout: 5_000 }); + await expect(card).not.toHaveClass(/outline-selected/, { timeout: 5_000 }); + }); +}); + test.describe("Face Library — mobile @high @mobile", () => { test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only"); diff --git a/web/src/components/card/ClassificationCard.tsx b/web/src/components/card/ClassificationCard.tsx index 4fed7e2385..b1c7b9e0a8 100644 --- a/web/src/components/card/ClassificationCard.tsx +++ b/web/src/components/card/ClassificationCard.tsx @@ -1,12 +1,20 @@ import { baseUrl } from "@/api/baseUrl"; import useContextMenu from "@/hooks/use-contextmenu"; +import { useOverlayState } from "@/hooks/use-overlay-state"; import { cn } from "@/lib/utils"; import { ClassificationItemData, ClassificationThreshold, ClassifiedEvent, } from "@/types/classification"; -import { forwardRef, useEffect, useMemo, useRef, useState } from "react"; +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; import { isDesktop, isIOS, isMobile, isMobileOnly } from "react-device-detect"; import { useTranslation } from "react-i18next"; import TimeAgo from "../dynamic/TimeAgo"; @@ -16,6 +24,7 @@ import { LuSearch, LuInfo } from "react-icons/lu"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { useNavigate } from "react-router-dom"; import { HiSquare2Stack } from "react-icons/hi2"; +import scrollIntoView from "scroll-into-view-if-needed"; import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay"; import { Dialog, @@ -85,9 +94,14 @@ export const ClassificationCard = forwardRef< // interaction + const cardRef = useRef(null); const imgRef = useRef(null); - useContextMenu(imgRef, () => { + useImperativeHandle(ref, () => cardRef.current!); + + // Listen on the whole card, since overlays cover most of the image + + useContextMenu(cardRef, () => { onClick(data, true); }); @@ -101,9 +115,9 @@ export const ClassificationCard = forwardRef< return (
{ - e.preventDefault(); - e.stopPropagation(); - onClick(data, true); - }} + style={isIOS ? { WebkitTouchCallout: "none" } : undefined} > setImageLoaded(true)} @@ -156,7 +158,7 @@ export const ClassificationCard = forwardRef<
)}
-
+
(null); + const [returnEventId, setReturnEventId] = useOverlayState( + "returnEventId", + ); + const [highlighted, setHighlighted] = useState(false); + + useEffect(() => { + if (!returnEventId || classifiedEvent?.id !== returnEventId) { + return; + } + + setReturnEventId(undefined, true); + setHighlighted(true); + }, [classifiedEvent?.id, returnEventId, setReturnEventId]); + + useEffect(() => { + if (!highlighted) { + return; + } + + if (cardRef.current) { + scrollIntoView(cardRef.current, { + block: "center", + behavior: "smooth", + scrollMode: "if-needed", + }); + } + + const timeout = setTimeout(() => setHighlighted(false), 3000); + return () => clearTimeout(timeout); + }, [highlighted]); + // If the component unmounts while the detail overlay is open, we need to // pop the history state that was pushed by useHistoryBack, otherwise it // leaves a stale entry that breaks back navigation. @@ -308,9 +345,10 @@ export function GroupedClassificationCard({ return ( <> - +
{ - navigate(`/explore?event_id=${classifiedEvent.id}`); + setReturnEventId(classifiedEvent.id, true); + navigate(`/explore?event_id=${classifiedEvent.id}`, { + state: { canGoBack: true }, + }); }} > diff --git a/web/src/components/overlay/detail/TrackingDetails.tsx b/web/src/components/overlay/detail/TrackingDetails.tsx index 1e6d3e6306..7e95cec656 100644 --- a/web/src/components/overlay/detail/TrackingDetails.tsx +++ b/web/src/components/overlay/detail/TrackingDetails.tsx @@ -845,6 +845,7 @@ export function TrackingDetails({
{event.data.recognized_license_plate} diff --git a/web/src/components/timeline/DetailStream.tsx b/web/src/components/timeline/DetailStream.tsx index 2bed0ab356..221b85c0e9 100644 --- a/web/src/components/timeline/DetailStream.tsx +++ b/web/src/components/timeline/DetailStream.tsx @@ -727,6 +727,7 @@ function EventList({
{event.data.recognized_license_plate} diff --git a/web/src/components/timeline/EventMenu.tsx b/web/src/components/timeline/EventMenu.tsx index 7696424c62..343cfe5913 100644 --- a/web/src/components/timeline/EventMenu.tsx +++ b/web/src/components/timeline/EventMenu.tsx @@ -135,7 +135,9 @@ export default function EventMenu({ { - navigate(`/explore?event_id=${event.id}`); + navigate(`/explore?event_id=${event.id}`, { + state: { canGoBack: true }, + }); }} > {t("details.item.button.viewInExplore")} @@ -177,6 +179,7 @@ export default function EventMenu({ else navigate( `/explore?search_type=similarity&event_id=${event.id}`, + { state: { canGoBack: true } }, ); }} > diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index 0becce1cab..279907a8b8 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -9,7 +9,7 @@ import { cn } from "@/lib/utils"; import { FrigateConfig } from "@/types/frigateConfig"; import { SearchFilter, SearchResult, SearchSource } from "@/types/search"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { isMobileOnly } from "react-device-detect"; +import { isDesktop, isMobileOnly } from "react-device-detect"; import { LuImage, LuSearchX, LuText } from "react-icons/lu"; import useSWR from "swr"; import ExploreView from "../explore/ExploreView"; @@ -32,7 +32,9 @@ import { TooltipPortal } from "@radix-ui/react-tooltip"; import SearchActionGroup from "@/components/filter/SearchActionGroup"; import { Trans, useTranslation } from "react-i18next"; import { use24HourTime } from "@/hooks/use-date-utils"; -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { IoMdArrowRoundBack } from "react-icons/io"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; type SearchViewProps = { @@ -80,6 +82,10 @@ export default function SearchView({ }); const is24Hour = use24HourTime(config); const navigate = useNavigate(); + const location = useLocation(); + + // set by in-app links, since an iOS home screen app has no back gesture + const canGoBack = location.state?.canGoBack === true; const { data: exploreEvents } = useSWR( (!searchFilter || Object.keys(searchFilter).length === 0) && @@ -535,17 +541,39 @@ export default function SearchView({ isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0", )} > - {config?.semantic_search?.enabled && ( -
- + {(canGoBack || config?.semantic_search?.enabled) && ( +
+ {canGoBack && ( + + )} + {config?.semantic_search?.enabled && ( +
+ +
+ )}
)} diff --git a/web/src/views/settings/TriggerView.tsx b/web/src/views/settings/TriggerView.tsx index 57d76704ed..f5845b7337 100644 --- a/web/src/views/settings/TriggerView.tsx +++ b/web/src/views/settings/TriggerView.tsx @@ -574,6 +574,7 @@ export default function TriggerView({