Miscellaneous fixes (0.17 beta) (#21934)
Some checks are pending
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

* improve chip tooltip display

- use formatList to use i18n separators instead of commas
- ensure the correct event type is used so sublabels are not run through normalization
- remove smart-capitalization classes as translated labels use i18n (which includes capitalization)
- give icons an optional key so that the console doesn't complain about duplication when rendering

* Add grace period for recording segment checks to prevent spurious ffmpeg restarts

* add admin precedence to proxy role_map resolution to prevent downgrade

* clean up

* formatting

* work around radix pointer events issue when dialog is opened from drawer

fixes https://github.com/blakeblackshear/frigate/discussions/21940

* prevent console warnings about missing titles and descriptions

make these invisible with sr-only

* remove duplicate language

* Adjust handling for device sizes

* Cleanup

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
Josh Hawkins 2026-02-12 14:42:08 -06:00 committed by GitHub
parent e1005ac2a5
commit 67e3f8eefa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 268 additions and 122 deletions

View File

@ -166,6 +166,10 @@ In this example:
- If no mapping matches, Frigate falls back to `default_role` if configured. - If no mapping matches, Frigate falls back to `default_role` if configured.
- If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name. - If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name.
**Note on matching semantics:**
- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups).
#### Port Considerations #### Port Considerations
**Authenticated Port (8971)** **Authenticated Port (8971)**

View File

@ -439,10 +439,11 @@ def resolve_role(
Determine the effective role for a request based on proxy headers and configuration. Determine the effective role for a request based on proxy headers and configuration.
Order of resolution: Order of resolution:
1. If a role header is defined in proxy_config.header_map.role: 1. If a role header is defined in proxy_config.header_map.role:
- If a role_map is configured, treat the header as group claims - If a role_map is configured, treat the header as group claims
(split by proxy_config.separator) and map to roles. (split by proxy_config.separator) and map to roles.
- If no role_map is configured, treat the header as role names directly. Admin matches short-circuit to admin.
- If no role_map is configured, treat the header as role names directly.
2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'. 2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'.
Args: Args:
@ -492,6 +493,12 @@ def resolve_role(
} }
logger.debug("Matched roles from role_map: %s", matched_roles) logger.debug("Matched roles from role_map: %s", matched_roles)
# If admin matches, prioritize it to avoid accidental downgrade when
# users belong to both admin and lower-privilege groups.
if "admin" in matched_roles and "admin" in config_roles:
logger.debug("Resolved role (with role_map) to 'admin'.")
return "admin"
if matched_roles: if matched_roles:
resolved = next( resolved = next(
(r for r in config_roles if r in matched_roles), validated_default (r for r in config_roles if r in matched_roles), validated_default

View File

@ -31,6 +31,21 @@ class TestProxyRoleResolution(unittest.TestCase):
role = resolve_role(headers, self.proxy_config, self.config_roles) role = resolve_role(headers, self.proxy_config, self.config_roles)
self.assertEqual(role, "admin") self.assertEqual(role, "admin")
def test_role_map_or_matching(self):
config = self.proxy_config
config.header_map.role_map = {
"admin": ["group_admin", "group_privileged"],
}
# OR semantics: a single matching group should map to the role
headers = {"x-remote-role": "group_admin"}
role = resolve_role(headers, config, self.config_roles)
self.assertEqual(role, "admin")
headers = {"x-remote-role": "group_admin|group_privileged"}
role = resolve_role(headers, config, self.config_roles)
self.assertEqual(role, "admin")
def test_direct_role_header_with_separator(self): def test_direct_role_header_with_separator(self):
config = self.proxy_config config = self.proxy_config
config.header_map.role_map = None # disable role_map config.header_map.role_map = None # disable role_map

View File

@ -214,6 +214,7 @@ class CameraWatchdog(threading.Thread):
self.latest_valid_segment_time: float = 0 self.latest_valid_segment_time: float = 0
self.latest_invalid_segment_time: float = 0 self.latest_invalid_segment_time: float = 0
self.latest_cache_segment_time: float = 0 self.latest_cache_segment_time: float = 0
self.record_enable_time: datetime | None = None
def _update_enabled_state(self) -> bool: def _update_enabled_state(self) -> bool:
"""Fetch the latest config and update enabled state.""" """Fetch the latest config and update enabled state."""
@ -261,6 +262,9 @@ class CameraWatchdog(threading.Thread):
def run(self) -> None: def run(self) -> None:
if self._update_enabled_state(): if self._update_enabled_state():
self.start_all_ffmpeg() self.start_all_ffmpeg()
# If recording is enabled at startup, set the grace period timer
if self.config.record.enabled:
self.record_enable_time = datetime.now().astimezone(timezone.utc)
time.sleep(self.sleeptime) time.sleep(self.sleeptime)
while not self.stop_event.wait(self.sleeptime): while not self.stop_event.wait(self.sleeptime):
@ -270,13 +274,15 @@ class CameraWatchdog(threading.Thread):
self.logger.debug(f"Enabling camera {self.config.name}") self.logger.debug(f"Enabling camera {self.config.name}")
self.start_all_ffmpeg() self.start_all_ffmpeg()
# reset all timestamps # reset all timestamps and record the enable time for grace period
self.latest_valid_segment_time = 0 self.latest_valid_segment_time = 0
self.latest_invalid_segment_time = 0 self.latest_invalid_segment_time = 0
self.latest_cache_segment_time = 0 self.latest_cache_segment_time = 0
self.record_enable_time = datetime.now().astimezone(timezone.utc)
else: else:
self.logger.debug(f"Disabling camera {self.config.name}") self.logger.debug(f"Disabling camera {self.config.name}")
self.stop_all_ffmpeg() self.stop_all_ffmpeg()
self.record_enable_time = None
# update camera status # update camera status
self.requestor.send_data( self.requestor.send_data(
@ -361,6 +367,12 @@ class CameraWatchdog(threading.Thread):
if self.config.record.enabled and "record" in p["roles"]: if self.config.record.enabled and "record" in p["roles"]:
now_utc = datetime.now().astimezone(timezone.utc) now_utc = datetime.now().astimezone(timezone.utc)
# Check if we're within the grace period after enabling recording
# Grace period: 90 seconds allows time for ffmpeg to start and create first segment
in_grace_period = self.record_enable_time is not None and (
now_utc - self.record_enable_time
) < timedelta(seconds=90)
latest_cache_dt = ( latest_cache_dt = (
datetime.fromtimestamp( datetime.fromtimestamp(
self.latest_cache_segment_time, tz=timezone.utc self.latest_cache_segment_time, tz=timezone.utc
@ -386,10 +398,16 @@ class CameraWatchdog(threading.Thread):
) )
# ensure segments are still being created and that they have valid video data # ensure segments are still being created and that they have valid video data
cache_stale = now_utc > (latest_cache_dt + timedelta(seconds=120)) # Skip checks during grace period to allow segments to start being created
valid_stale = now_utc > (latest_valid_dt + timedelta(seconds=120)) cache_stale = not in_grace_period and now_utc > (
latest_cache_dt + timedelta(seconds=120)
)
valid_stale = not in_grace_period and now_utc > (
latest_valid_dt + timedelta(seconds=120)
)
invalid_stale_condition = ( invalid_stale_condition = (
self.latest_invalid_segment_time > 0 self.latest_invalid_segment_time > 0
and not in_grace_period
and now_utc > (latest_invalid_dt + timedelta(seconds=120)) and now_utc > (latest_invalid_dt + timedelta(seconds=120))
and self.latest_valid_segment_time and self.latest_valid_segment_time
<= self.latest_invalid_segment_time <= self.latest_invalid_segment_time

View File

@ -1,6 +1,7 @@
{ {
"restart": { "restart": {
"title": "Are you sure you want to restart Frigate?", "title": "Are you sure you want to restart Frigate?",
"description": "This will briefly stop Frigate while it restarts.",
"button": "Restart", "button": "Restart",
"restarting": { "restarting": {
"title": "Frigate is Restarting", "title": "Frigate is Restarting",

View File

@ -19,6 +19,8 @@ import { Button } from "../ui/button";
import { FaCircleCheck } from "react-icons/fa6"; import { FaCircleCheck } from "react-icons/fa6";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
import { formatList } from "@/utils/stringUtil";
type AnimatedEventCardProps = { type AnimatedEventCardProps = {
event: ReviewSegment; event: ReviewSegment;
@ -50,26 +52,37 @@ export function AnimatedEventCard({
fetchPreviews: !currentHour, fetchPreviews: !currentHour,
}); });
const getEventType = useCallback(
(text: string) => {
if (event.data.sub_labels?.includes(text)) return "manual";
if (event.data.audio.includes(text)) return "audio";
return "object";
},
[event],
);
const tooltipText = useMemo(() => { const tooltipText = useMemo(() => {
if (event?.data?.metadata?.title) { if (event?.data?.metadata?.title) {
return event.data.metadata.title; return event.data.metadata.title;
} }
return ( return (
`${[ `${formatList(
...new Set([ [
...(event.data.objects || []), ...new Set([
...(event.data.sub_labels || []), ...(event.data.objects || []).map((text) =>
...(event.data.audio || []), text.replace("-verified", ""),
]), ),
] ...(event.data.sub_labels || []),
.filter((item) => item !== undefined && !item.includes("-verified")) ...(event.data.audio || []),
.map((text) => text.charAt(0).toUpperCase() + text.substring(1)) ]),
.sort() ]
.join(", ") .filter((item) => item !== undefined)
.replaceAll("-verified", "")} ` + t("detected") .map((text) => getTranslatedLabel(text, getEventType(text)))
.sort(),
)} ` + t("detected")
); );
}, [event, t]); }, [event, getEventType, t]);
// visibility // visibility

View File

@ -33,13 +33,14 @@ import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { Button, buttonVariants } from "../ui/button"; import { Button, buttonVariants } from "../ui/button";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LuCircle } from "react-icons/lu"; import { LuCircle } from "react-icons/lu";
import { MdAutoAwesome } from "react-icons/md"; import { MdAutoAwesome } from "react-icons/md";
import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip"; import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip";
import { getTranslatedLabel } from "@/utils/i18n";
import { formatList } from "@/utils/stringUtil";
type ReviewCardProps = { type ReviewCardProps = {
event: ReviewSegment; event: ReviewSegment;
@ -123,6 +124,12 @@ export default function ReviewCard({
} }
}, [bypassDialogRef, onDelete]); }, [bypassDialogRef, onDelete]);
const getEventType = (text: string) => {
if (event.data.sub_labels?.includes(text)) return "manual";
if (event.data.audio.includes(text)) return "audio";
return "object";
};
const content = ( const content = (
<div <div
className="relative flex w-full cursor-pointer flex-col gap-1.5" className="relative flex w-full cursor-pointer flex-col gap-1.5"
@ -197,20 +204,20 @@ export default function ReviewCard({
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="smart-capitalize"> <TooltipContent className="smart-capitalize">
{[ {formatList(
...new Set([ [
...(event.data.objects || []), ...new Set([
...(event.data.sub_labels || []), ...(event.data.objects || []).map((text) =>
...(event.data.audio || []), text.replace("-verified", ""),
]), ),
] ...(event.data.sub_labels || []),
.filter( ...(event.data.audio || []),
(item) => item !== undefined && !item.includes("-verified"), ]),
) ]
.map((text) => capitalizeFirstLetter(text)) .filter((item) => item !== undefined)
.sort() .map((text) => getTranslatedLabel(text, getEventType(text)))
.join(", ") .sort(),
.replaceAll("-verified", "")} )}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<TimeAgo <TimeAgo

View File

@ -42,12 +42,20 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerTitle,
DrawerTrigger,
} from "../ui/drawer";
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogDescription,
DialogPortal, DialogPortal,
DialogTitle,
DialogTrigger, DialogTrigger,
} from "../ui/dialog"; } from "../ui/dialog";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
@ -194,6 +202,16 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
: "max-h-[75dvh] overflow-hidden p-2" : "max-h-[75dvh] overflow-hidden p-2"
} }
> >
{!isDesktop && (
<>
<DrawerTitle className="sr-only">
{t("menu.settings")}
</DrawerTitle>
<DrawerDescription className="sr-only">
{t("menu.settings")}
</DrawerDescription>
</>
)}
<div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden"> <div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden">
{isMobile && ( {isMobile && (
<div className="mb-2"> <div className="mb-2">
@ -355,6 +373,16 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
: "scrollbar-container max-h-[75dvh] w-[92%] overflow-y-scroll rounded-lg md:rounded-2xl" : "scrollbar-container max-h-[75dvh] w-[92%] overflow-y-scroll rounded-lg md:rounded-2xl"
} }
> >
{!isDesktop && (
<>
<DialogTitle className="sr-only">
{t("menu.languages")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("menu.languages")}
</DialogDescription>
</>
)}
<span tabIndex={0} className="sr-only" /> <span tabIndex={0} className="sr-only" />
{languages.map(({ code, label }) => ( {languages.map(({ code, label }) => (
<MenuItem <MenuItem
@ -395,6 +423,16 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl" isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
} }
> >
{!isDesktop && (
<>
<DialogTitle className="sr-only">
{t("menu.darkMode.label")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("menu.darkMode.label")}
</DialogDescription>
</>
)}
<span tabIndex={0} className="sr-only" /> <span tabIndex={0} className="sr-only" />
<MenuItem <MenuItem
className={ className={
@ -472,6 +510,16 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl" isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
} }
> >
{!isDesktop && (
<>
<DialogTitle className="sr-only">
{t("menu.theme.label")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("menu.theme.label")}
</DialogDescription>
</>
)}
<span tabIndex={0} className="sr-only" /> <span tabIndex={0} className="sr-only" />
{colorSchemes.map((scheme) => ( {colorSchemes.map((scheme) => (
<MenuItem <MenuItem

View File

@ -4,6 +4,7 @@ import {
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
@ -37,6 +38,12 @@ export default function RestartDialog({
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false); const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
const [countdown, setCountdown] = useState(60); const [countdown, setCountdown] = useState(60);
const clearBodyPointerEvents = () => {
if (typeof document !== "undefined") {
document.body.style.pointerEvents = "";
}
};
useEffect(() => { useEffect(() => {
setRestartDialogOpen(isOpen); setRestartDialogOpen(isOpen);
}, [isOpen]); }, [isOpen]);
@ -74,14 +81,25 @@ export default function RestartDialog({
<> <>
<AlertDialog <AlertDialog
open={restartDialogOpen} open={restartDialogOpen}
onOpenChange={() => { onOpenChange={(open) => {
setRestartDialogOpen(false); if (!open) {
onClose(); setRestartDialogOpen(false);
onClose();
clearBodyPointerEvents();
}
}} }}
> >
<AlertDialogContent> <AlertDialogContent
onCloseAutoFocus={(event) => {
event.preventDefault();
clearBodyPointerEvents();
}}
>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{t("restart.title")}</AlertDialogTitle> <AlertDialogTitle>{t("restart.title")}</AlertDialogTitle>
<AlertDialogDescription className="sr-only">
{t("restart.description")}
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel> <AlertDialogCancel>

View File

@ -371,22 +371,23 @@ export default function LivePlayer({
</TooltipTrigger> </TooltipTrigger>
</div> </div>
<TooltipPortal> <TooltipPortal>
<TooltipContent className="smart-capitalize"> <TooltipContent>
{formatList( {formatList(
[ [
...new Set([ ...new Set(
...(objects || []).map(({ label, sub_label }) => (objects || [])
label.endsWith("verified") .map(({ label, sub_label }) => {
? sub_label const isManual = label.endsWith("verified");
: label.replaceAll("_", " "), const text = isManual ? sub_label : label;
), const type = isManual ? "manual" : "object";
]), return getTranslatedLabel(text, type);
] })
.filter((label) => label?.includes("-verified") == false) .filter(
.map((label) => (translated) =>
getTranslatedLabel(label.replace("-verified", "")), translated && !translated.includes("-verified"),
) ),
.sort(), ),
].sort(),
)} )}
</TooltipContent> </TooltipContent>
</TooltipPortal> </TooltipPortal>

View File

@ -28,6 +28,7 @@ import { useTranslation } from "react-i18next";
import { FaExclamationTriangle } from "react-icons/fa"; import { FaExclamationTriangle } from "react-icons/fa";
import { MdOutlinePersonSearch } from "react-icons/md"; import { MdOutlinePersonSearch } from "react-icons/md";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { formatList } from "@/utils/stringUtil";
type PreviewPlayerProps = { type PreviewPlayerProps = {
review: ReviewSegment; review: ReviewSegment;
@ -182,9 +183,8 @@ export default function PreviewThumbnailPlayer({
); );
const getEventType = (text: string) => { const getEventType = (text: string) => {
if (review.data.objects.includes(text)) return "object";
if (review.data.audio.includes(text)) return "audio";
if (review.data.sub_labels?.includes(text)) return "manual"; if (review.data.sub_labels?.includes(text)) return "manual";
if (review.data.audio.includes(text)) return "audio";
return "object"; return "object";
}; };
@ -268,13 +268,16 @@ export default function PreviewThumbnailPlayer({
className={`flex items-start justify-between space-x-1 ${playingBack ? "hidden" : ""} bg-gradient-to-br ${review.has_been_reviewed ? "bg-green-600 from-green-600 to-green-700" : "bg-gray-500 from-gray-400 to-gray-500"} z-0`} className={`flex items-start justify-between space-x-1 ${playingBack ? "hidden" : ""} bg-gradient-to-br ${review.has_been_reviewed ? "bg-green-600 from-green-600 to-green-700" : "bg-gray-500 from-gray-400 to-gray-500"} z-0`}
onClick={() => onClick(review, false, true)} onClick={() => onClick(review, false, true)}
> >
{review.data.objects.sort().map((object) => { {review.data.objects
return getIconForLabel( .sort()
object, .map((object, idx) =>
"object", getIconForLabel(
"size-3 text-white", object,
); "object",
})} "size-3 text-white",
`${object}-${idx}`,
),
)}
{review.data.audio.map((audio) => { {review.data.audio.map((audio) => {
return getIconForLabel( return getIconForLabel(
audio, audio,
@ -288,23 +291,25 @@ export default function PreviewThumbnailPlayer({
</div> </div>
</TooltipTrigger> </TooltipTrigger>
</div> </div>
<TooltipContent className="smart-capitalize"> <TooltipContent>
{review.data.metadata {review.data.metadata
? review.data.metadata.title ? review.data.metadata.title
: [ : formatList(
...new Set([ [
...(review.data.objects || []), ...new Set([
...(review.data.sub_labels || []), ...(review.data.objects || []).map((text) =>
...(review.data.audio || []), text.replace("-verified", ""),
]), ),
] ...(review.data.sub_labels || []),
.filter( ...(review.data.audio || []),
(item) => ]),
item !== undefined && !item.includes("-verified"), ]
) .filter((item) => item !== undefined)
.map((text) => getTranslatedLabel(text, getEventType(text))) .map((text) =>
.sort() getTranslatedLabel(text, getEventType(text)),
.join(", ")} )
.sort(),
)}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{!!( {!!(

View File

@ -26,6 +26,5 @@ export const supportedLanguageKeys = [
"lt", "lt",
"uk", "uk",
"cs", "cs",
"sk",
"hu", "hu",
]; ];

View File

@ -62,83 +62,86 @@ export function getIconForLabel(
label: string, label: string,
type: EventType = "object", type: EventType = "object",
className?: string, className?: string,
key?: string,
) { ) {
const iconKey = key || label;
if (label.endsWith("-verified")) { if (label.endsWith("-verified")) {
return getVerifiedIcon(label, className, type); return getVerifiedIcon(label, className, type, iconKey);
} else if (label.endsWith("-plate")) { } else if (label.endsWith("-plate")) {
return getRecognizedPlateIcon(label, className, type); return getRecognizedPlateIcon(label, className, type, iconKey);
} }
switch (label) { switch (label) {
// objects // objects
case "bear": case "bear":
return <GiPolarBear key={label} className={className} />; return <GiPolarBear key={iconKey} className={className} />;
case "bicycle": case "bicycle":
return <FaBicycle key={label} className={className} />; return <FaBicycle key={iconKey} className={className} />;
case "bird": case "bird":
return <PiBirdFill key={label} className={className} />; return <PiBirdFill key={iconKey} className={className} />;
case "boat": case "boat":
return <GiSailboat key={label} className={className} />; return <GiSailboat key={iconKey} className={className} />;
case "bus": case "bus":
case "school_bus": case "school_bus":
return <FaBus key={label} className={className} />; return <FaBus key={iconKey} className={className} />;
case "car": case "car":
case "vehicle": case "vehicle":
return <FaCarSide key={label} className={className} />; return <FaCarSide key={iconKey} className={className} />;
case "cat": case "cat":
return <FaCat key={label} className={className} />; return <FaCat key={iconKey} className={className} />;
case "deer": case "deer":
return <GiDeer key={label} className={className} />; return <GiDeer key={iconKey} className={className} />;
case "animal": case "animal":
case "bark": case "bark":
case "dog": case "dog":
return <FaDog key={label} className={className} />; return <FaDog key={iconKey} className={className} />;
case "fox": case "fox":
return <GiFox key={label} className={className} />; return <GiFox key={iconKey} className={className} />;
case "goat": case "goat":
return <GiGoat key={label} className={className} />; return <GiGoat key={iconKey} className={className} />;
case "horse": case "horse":
return <FaHorse key={label} className={className} />; return <FaHorse key={iconKey} className={className} />;
case "kangaroo": case "kangaroo":
return <GiKangaroo key={label} className={className} />; return <GiKangaroo key={iconKey} className={className} />;
case "license_plate": case "license_plate":
return <LuScanBarcode key={label} className={className} />; return <LuScanBarcode key={iconKey} className={className} />;
case "motorcycle": case "motorcycle":
return <FaMotorcycle key={label} className={className} />; return <FaMotorcycle key={iconKey} className={className} />;
case "mouse": case "mouse":
return <FaMouse key={label} className={className} />; return <FaMouse key={iconKey} className={className} />;
case "package": case "package":
return <LuBox key={label} className={className} />; return <LuBox key={iconKey} className={className} />;
case "person": case "person":
return <BsPersonWalking key={label} className={className} />; return <BsPersonWalking key={iconKey} className={className} />;
case "rabbit": case "rabbit":
return <GiRabbit key={label} className={className} />; return <GiRabbit key={iconKey} className={className} />;
case "raccoon": case "raccoon":
return <GiRaccoonHead key={label} className={className} />; return <GiRaccoonHead key={iconKey} className={className} />;
case "robot_lawnmower": case "robot_lawnmower":
return <FaHockeyPuck key={label} className={className} />; return <FaHockeyPuck key={iconKey} className={className} />;
case "sports_ball": case "sports_ball":
return <FaFootballBall key={label} className={className} />; return <FaFootballBall key={iconKey} className={className} />;
case "skunk": case "skunk":
return <GiSquirrel key={label} className={className} />; return <GiSquirrel key={iconKey} className={className} />;
case "squirrel": case "squirrel":
return <LuIcons.LuSquirrel key={label} className={className} />; return <LuIcons.LuSquirrel key={iconKey} className={className} />;
case "umbrella": case "umbrella":
return <FaUmbrella key={label} className={className} />; return <FaUmbrella key={iconKey} className={className} />;
case "waste_bin": case "waste_bin":
return <FaRegTrashAlt key={label} className={className} />; return <FaRegTrashAlt key={iconKey} className={className} />;
// audio // audio
case "crying": case "crying":
case "laughter": case "laughter":
case "scream": case "scream":
case "speech": case "speech":
case "yell": case "yell":
return <MdRecordVoiceOver key={label} className={className} />; return <MdRecordVoiceOver key={iconKey} className={className} />;
case "fire_alarm": case "fire_alarm":
return <FaFire key={label} className={className} />; return <FaFire key={iconKey} className={className} />;
// sub labels // sub labels
case "amazon": case "amazon":
return <FaAmazon key={label} className={className} />; return <FaAmazon key={iconKey} className={className} />;
case "an_post": case "an_post":
case "canada_post": case "canada_post":
case "dpd": case "dpd":
@ -148,20 +151,20 @@ export function getIconForLabel(
case "postnord": case "postnord":
case "purolator": case "purolator":
case "royal_mail": case "royal_mail":
return <GiPostStamp key={label} className={className} />; return <GiPostStamp key={iconKey} className={className} />;
case "dhl": case "dhl":
return <FaDhl key={label} className={className} />; return <FaDhl key={iconKey} className={className} />;
case "fedex": case "fedex":
return <FaFedex key={label} className={className} />; return <FaFedex key={iconKey} className={className} />;
case "ups": case "ups":
return <FaUps key={label} className={className} />; return <FaUps key={iconKey} className={className} />;
case "usps": case "usps":
return <FaUsps key={label} className={className} />; return <FaUsps key={iconKey} className={className} />;
default: default:
if (type === "audio") { if (type === "audio") {
return <GiSoundWaves key={label} className={className} />; return <GiSoundWaves key={iconKey} className={className} />;
} }
return <LuLassoSelect key={label} className={className} />; return <LuLassoSelect key={iconKey} className={className} />;
} }
} }
@ -169,11 +172,12 @@ function getVerifiedIcon(
label: string, label: string,
className?: string, className?: string,
type: EventType = "object", type: EventType = "object",
key?: string,
) { ) {
const simpleLabel = label.substring(0, label.lastIndexOf("-")); const simpleLabel = label.substring(0, label.lastIndexOf("-"));
return ( return (
<div key={label} className="relative flex items-center"> <div key={key} className="relative flex items-center">
{getIconForLabel(simpleLabel, type, className)} {getIconForLabel(simpleLabel, type, className)}
<FaCheckCircle className="absolute -bottom-0.5 -right-0.5 size-2" /> <FaCheckCircle className="absolute -bottom-0.5 -right-0.5 size-2" />
</div> </div>
@ -184,11 +188,12 @@ function getRecognizedPlateIcon(
label: string, label: string,
className?: string, className?: string,
type: EventType = "object", type: EventType = "object",
key?: string,
) { ) {
const simpleLabel = label.substring(0, label.lastIndexOf("-")); const simpleLabel = label.substring(0, label.lastIndexOf("-"));
return ( return (
<div key={label} className="relative inline-flex items-center"> <div key={key} className="relative inline-flex items-center">
{getIconForLabel(simpleLabel, type, className)} {getIconForLabel(simpleLabel, type, className)}
<LuScanBarcode className="absolute -bottom-0.5 -right-0.5 size-2" /> <LuScanBarcode className="absolute -bottom-0.5 -right-0.5 size-2" />
</div> </div>

View File

@ -33,7 +33,12 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { isDesktop, isMobile } from "react-device-detect"; import {
isDesktop,
isMobile,
isMobileOnly,
isTablet,
} from "react-device-detect";
import { IoMdArrowRoundBack } from "react-icons/io"; import { IoMdArrowRoundBack } from "react-icons/io";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
@ -738,7 +743,7 @@ export function RecordingView({
aspectRatio: getCameraAspect(mainCamera), aspectRatio: getCameraAspect(mainCamera),
}} }}
> >
{isDesktop && ( {(isDesktop || isTablet) && (
<GenAISummaryDialog <GenAISummaryDialog
review={activeReviewItem} review={activeReviewItem}
onOpen={onAnalysisOpen} onOpen={onAnalysisOpen}
@ -1001,7 +1006,7 @@ function Timeline({
), ),
)} )}
> >
{isMobile && timelineType == "timeline" && ( {isMobileOnly && timelineType == "timeline" && (
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen}> <GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen}>
<GenAISummaryChip review={activeReviewItem} /> <GenAISummaryChip review={activeReviewItem} />
</GenAISummaryDialog> </GenAISummaryDialog>