Various Tweaks (#22609)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* Mark items as reviewed as a group with keyboard

* Improve handling of half model regions

* update viewport meta tag to prevent user scaling

fixes https://github.com/blakeblackshear/frigate/issues/22017

* add small animation to collapsible shadcn elements

* add proxy auth env var tests

* Improve search effect

* Fix mobile back navigation losing overlay state on classification page

* undo historyBack changes

* fix classification history navigation

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
Nicolas Mowen
2026-03-24 13:53:39 -06:00
committed by GitHub
co-authored by Josh Hawkins
parent 334245bd3c
commit 4b42039568
9 changed files with 169 additions and 58 deletions
+20 -1
View File
@@ -6,7 +6,7 @@ import {
ClassificationThreshold,
ClassifiedEvent,
} from "@/types/classification";
import { forwardRef, useMemo, useRef, useState } from "react";
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
import { isDesktop, isIOS, isMobile, isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next";
import TimeAgo from "../dynamic/TimeAgo";
@@ -216,6 +216,25 @@ export function GroupedClassificationCard({
const { t } = useTranslation(["views/explore", i18nLibrary]);
const [detailOpen, setDetailOpen] = useState(false);
// 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.
const detailOpenRef = useRef(detailOpen);
useEffect(() => {
detailOpenRef.current = detailOpen;
}, [detailOpen]);
useEffect(() => {
return () => {
// Only pop the state if we are still sitting on the overlayOpen history entry.
// This prevents the unmount from undoing cross-page routing if the unmount
// was caused by navigating away to a different view.
if (detailOpenRef.current && window.history.state?.overlayOpen) {
window.history.back();
}
};
}, []);
// data
const bestItem = useMemo<ClassificationItemData | undefined>(() => {
+24 -5
View File
@@ -1,9 +1,28 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import * as React from "react";
const Collapsible = CollapsiblePrimitive.Root
import { cn } from "@/lib/utils";
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
const CollapsibleContent = React.forwardRef<
React.ComponentRef<typeof CollapsiblePrimitive.CollapsibleContent>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
>(({ className, children, ...props }, ref) => (
<CollapsiblePrimitive.CollapsibleContent
ref={ref}
className={cn(
"overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
className,
)}
{...props}
>
{children}
</CollapsiblePrimitive.CollapsibleContent>
));
CollapsibleContent.displayName =
CollapsiblePrimitive.CollapsibleContent.displayName;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+43 -21
View File
@@ -1,4 +1,11 @@
import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { usePersistence } from "./use-persistence";
import { useUserPersistence } from "./use-user-persistence";
@@ -200,36 +207,51 @@ export function useSearchEffect(
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [pendingRemoval, setPendingRemoval] = useState(false);
const processedRef = useRef<string | null>(null);
const param = useMemo(() => {
const param = searchParams.get(key);
if (!param) {
return undefined;
}
return [key, decodeURIComponent(param)];
}, [searchParams, key]);
const currentParam = searchParams.get(key);
// Process the param via callback (once per unique param value)
useEffect(() => {
if (!param) {
if (currentParam == null || currentParam === processedRef.current) {
return;
}
const remove = callback(param[1]);
const decoded = decodeURIComponent(currentParam);
const shouldRemove = callback(decoded);
if (remove) {
navigate(location.pathname + location.hash, {
state: location.state,
replace: true,
});
if (shouldRemove) {
processedRef.current = currentParam;
setPendingRemoval(true);
}
}, [currentParam, callback, key]);
// Remove the search param in a separate render cycle so that any state
// changes from the callback (e.g., overlay state navigations) are already
// reflected in location.state before we navigate to strip the param.
useEffect(() => {
if (!pendingRemoval) {
return;
}
setPendingRemoval(false);
navigate(location.pathname + location.hash, {
state: location.state,
replace: true,
});
}, [
param,
location.state,
pendingRemoval,
navigate,
location.pathname,
location.hash,
callback,
navigate,
location.state,
]);
// Reset tracking when param is removed from the URL
useEffect(() => {
if (currentParam == null) {
processedRef.current = null;
}
}, [currentParam]);
}
+12 -8
View File
@@ -409,8 +409,11 @@ export default function Events() {
// review status
const markAllItemsAsReviewed = useCallback(
async (currentItems: ReviewSegment[]) => {
const markItemsAsReviewed = useCallback(
async (
currentItems: ReviewSegment[],
itemsToMarkReviewed: ReviewSegment[] | undefined = undefined,
) => {
if (currentItems.length == 0) {
return;
}
@@ -435,13 +438,14 @@ export default function Events() {
{ revalidate: false, populateCache: true },
);
const itemsToMarkReviewed = currentItems
?.filter((seg) => seg.end_time)
?.map((seg) => seg.id);
const reviewList =
itemsToMarkReviewed?.map((seg) => seg.id) ||
currentItems?.filter((seg) => seg.end_time)?.map((seg) => seg.id) ||
[];
if (itemsToMarkReviewed.length > 0) {
if (reviewList.length > 0) {
await axios.post(`reviews/viewed`, {
ids: itemsToMarkReviewed,
ids: reviewList,
reviewed: true,
});
reloadData();
@@ -611,7 +615,7 @@ export default function Events() {
setShowReviewed={setShowReviewed}
setSeverity={setSeverity}
markItemAsReviewed={markItemAsReviewed}
markAllItemsAsReviewed={markAllItemsAsReviewed}
markItemsAsReviewed={markItemsAsReviewed}
onOpenRecording={setRecording}
motionPreviewsCamera={motionPreviewsCamera ?? null}
setMotionPreviewsCamera={(camera) =>
+13 -12
View File
@@ -110,7 +110,10 @@ type EventViewProps = {
setShowReviewed: (show: boolean) => void;
setSeverity: (severity: ReviewSeverity) => void;
markItemAsReviewed: (review: ReviewSegment) => void;
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
markItemsAsReviewed: (
currentItems: ReviewSegment[],
itemsToMarkReviewed?: ReviewSegment[] | undefined,
) => void;
onOpenRecording: (recordingInfo: RecordingStartingPoint) => void;
motionPreviewsCamera: string | null;
setMotionPreviewsCamera: (camera: string | null) => void;
@@ -132,7 +135,7 @@ export default function EventView({
setShowReviewed,
setSeverity,
markItemAsReviewed,
markAllItemsAsReviewed,
markItemsAsReviewed,
onOpenRecording,
motionPreviewsCamera,
setMotionPreviewsCamera,
@@ -498,7 +501,7 @@ export default function EventView({
loading={severity != severityToggle}
emptyCardData={emptyCardData}
markItemAsReviewed={markItemAsReviewed}
markAllItemsAsReviewed={markAllItemsAsReviewed}
markItemsAsReviewed={markItemsAsReviewed}
onSelectReview={onSelectReview}
onSelectAllReviews={onSelectAllReviews}
setSelectedReviews={setSelectedReviews}
@@ -549,7 +552,10 @@ type DetectionReviewProps = {
loading: boolean;
emptyCardData: EmptyCardData;
markItemAsReviewed: (review: ReviewSegment) => void;
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
markItemsAsReviewed: (
currentItems: ReviewSegment[],
itemsToMarkReviewed?: ReviewSegment[] | undefined,
) => void;
onSelectReview: (
review: ReviewSegment,
ctrl: boolean,
@@ -573,7 +579,7 @@ function DetectionReview({
loading,
emptyCardData,
markItemAsReviewed,
markAllItemsAsReviewed,
markItemsAsReviewed,
onSelectReview,
onSelectAllReviews,
setSelectedReviews,
@@ -788,12 +794,7 @@ function DetectionReview({
break;
case "r":
if (selectedReviews.length > 0 && !modifiers.repeat) {
currentItems?.forEach((item) => {
if (selectedReviews.some((r) => r.id === item.id)) {
item.has_been_reviewed = true;
markItemAsReviewed(item);
}
});
markItemsAsReviewed(currentItems || [], selectedReviews);
setSelectedReviews([]);
return true;
}
@@ -903,7 +904,7 @@ function DetectionReview({
variant="select"
onClick={() => {
setSelectedReviews([]);
markAllItemsAsReviewed(currentItems ?? []);
markItemsAsReviewed(currentItems ?? []);
}}
>
{t("markTheseItemsAsReviewed")}