mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-29 07:09:03 +03:00
Miscellaneous Fixes (#20973)
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
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
CI / AMD64 Build (push) Waiting to run
This commit is contained in:
@@ -9,7 +9,7 @@ import useSWR from "swr";
|
||||
import { MdHome } from "react-icons/md";
|
||||
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { Button, buttonVariants } from "../ui/button";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { LuPencil, LuPlus } from "react-icons/lu";
|
||||
import {
|
||||
@@ -87,6 +87,8 @@ type CameraGroupSelectorProps = {
|
||||
export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
const { t } = useTranslation(["components/camera"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const allowedCameras = useAllowedCameras();
|
||||
const isCustomRole = useIsCustomRole();
|
||||
|
||||
// tooltip
|
||||
|
||||
@@ -119,10 +121,22 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(config.camera_groups).sort(
|
||||
(a, b) => a[1].order - b[1].order,
|
||||
);
|
||||
}, [config]);
|
||||
const allGroups = Object.entries(config.camera_groups);
|
||||
|
||||
// If custom role, filter out groups where user has no accessible cameras
|
||||
if (isCustomRole) {
|
||||
return allGroups
|
||||
.filter(([, groupConfig]) => {
|
||||
// Check if user has access to at least one camera in this group
|
||||
return groupConfig.cameras.some((cameraName) =>
|
||||
allowedCameras.includes(cameraName),
|
||||
);
|
||||
})
|
||||
.sort((a, b) => a[1].order - b[1].order);
|
||||
}
|
||||
|
||||
return allGroups.sort((a, b) => a[1].order - b[1].order);
|
||||
}, [config, allowedCameras, isCustomRole]);
|
||||
|
||||
// add group
|
||||
|
||||
@@ -139,6 +153,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
activeGroup={group}
|
||||
setGroup={setGroup}
|
||||
deleteGroup={deleteGroup}
|
||||
isCustomRole={isCustomRole}
|
||||
/>
|
||||
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
|
||||
<div
|
||||
@@ -206,14 +221,16 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
className="bg-secondary text-muted-foreground"
|
||||
aria-label={t("group.add")}
|
||||
size="xs"
|
||||
onClick={() => setAddGroup(true)}
|
||||
>
|
||||
<LuPlus className="size-4 text-primary" />
|
||||
</Button>
|
||||
{!isCustomRole && (
|
||||
<Button
|
||||
className="bg-secondary text-muted-foreground"
|
||||
aria-label={t("group.add")}
|
||||
size="xs"
|
||||
onClick={() => setAddGroup(true)}
|
||||
>
|
||||
<LuPlus className="size-4 text-primary" />
|
||||
</Button>
|
||||
)}
|
||||
{isMobile && <ScrollBar orientation="horizontal" className="h-0" />}
|
||||
</div>
|
||||
</Scroller>
|
||||
@@ -228,6 +245,7 @@ type NewGroupDialogProps = {
|
||||
activeGroup?: string;
|
||||
setGroup: (value: string | undefined, replace?: boolean | undefined) => void;
|
||||
deleteGroup: () => void;
|
||||
isCustomRole?: boolean;
|
||||
};
|
||||
function NewGroupDialog({
|
||||
open,
|
||||
@@ -236,6 +254,7 @@ function NewGroupDialog({
|
||||
activeGroup,
|
||||
setGroup,
|
||||
deleteGroup,
|
||||
isCustomRole,
|
||||
}: NewGroupDialogProps) {
|
||||
const { t } = useTranslation(["components/camera"]);
|
||||
const { mutate: updateConfig } = useSWR<FrigateConfig>("config");
|
||||
@@ -261,6 +280,12 @@ function NewGroupDialog({
|
||||
`${activeGroup}-draggable-layout`,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setEditState("none");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// callbacks
|
||||
|
||||
const onDeleteGroup = useCallback(
|
||||
@@ -349,13 +374,7 @@ function NewGroupDialog({
|
||||
position="top-center"
|
||||
closeButton={true}
|
||||
/>
|
||||
<Overlay
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
setEditState("none");
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<Overlay open={open} onOpenChange={setOpen}>
|
||||
<Content
|
||||
className={cn(
|
||||
"scrollbar-container overflow-y-auto",
|
||||
@@ -371,28 +390,30 @@ function NewGroupDialog({
|
||||
>
|
||||
<Title>{t("group.label")}</Title>
|
||||
<Description className="sr-only">{t("group.edit")}</Description>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute",
|
||||
isDesktop && "right-6 top-10",
|
||||
isMobile && "absolute right-0 top-4",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
{!isCustomRole && (
|
||||
<div
|
||||
className={cn(
|
||||
isDesktop &&
|
||||
"size-6 rounded-md bg-secondary-foreground p-1 text-background",
|
||||
isMobile && "text-secondary-foreground",
|
||||
"absolute",
|
||||
isDesktop && "right-6 top-10",
|
||||
isMobile && "absolute right-0 top-4",
|
||||
)}
|
||||
aria-label={t("group.add")}
|
||||
onClick={() => {
|
||||
setEditState("add");
|
||||
}}
|
||||
>
|
||||
<LuPlus />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className={cn(
|
||||
isDesktop &&
|
||||
"size-6 rounded-md bg-secondary-foreground p-1 text-background",
|
||||
isMobile && "text-secondary-foreground",
|
||||
)}
|
||||
aria-label={t("group.add")}
|
||||
onClick={() => {
|
||||
setEditState("add");
|
||||
}}
|
||||
>
|
||||
<LuPlus />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Header>
|
||||
<div className="flex flex-col gap-4 md:gap-3">
|
||||
{currentGroups.map((group) => (
|
||||
@@ -401,6 +422,7 @@ function NewGroupDialog({
|
||||
group={group}
|
||||
onDeleteGroup={() => onDeleteGroup(group[0])}
|
||||
onEditGroup={() => onEditGroup(group)}
|
||||
isReadOnly={isCustomRole}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -512,12 +534,14 @@ type CameraGroupRowProps = {
|
||||
group: [string, CameraGroupConfig];
|
||||
onDeleteGroup: () => void;
|
||||
onEditGroup: () => void;
|
||||
isReadOnly?: boolean;
|
||||
};
|
||||
|
||||
export function CameraGroupRow({
|
||||
group,
|
||||
onDeleteGroup,
|
||||
onEditGroup,
|
||||
isReadOnly,
|
||||
}: CameraGroupRowProps) {
|
||||
const { t } = useTranslation(["components/camera"]);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@@ -564,7 +588,7 @@ export function CameraGroupRow({
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{isMobile && (
|
||||
{isMobile && !isReadOnly && (
|
||||
<>
|
||||
<DropdownMenu modal={!isDesktop}>
|
||||
<DropdownMenuTrigger>
|
||||
@@ -589,7 +613,7 @@ export function CameraGroupRow({
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
{!isMobile && (
|
||||
{!isMobile && !isReadOnly && (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -807,6 +807,15 @@ function ObjectDetailsTab({
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
const isEventsKey = useCallback((key: unknown): boolean => {
|
||||
const candidate = Array.isArray(key) ? key[0] : key;
|
||||
const EVENTS_KEY_PATTERNS = ["events", "events/search", "events/explore"];
|
||||
return (
|
||||
typeof candidate === "string" &&
|
||||
EVENTS_KEY_PATTERNS.some((p) => candidate.includes(p))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const updateDescription = useCallback(() => {
|
||||
if (!search) {
|
||||
return;
|
||||
@@ -821,11 +830,7 @@ function ObjectDetailsTab({
|
||||
});
|
||||
}
|
||||
mutate(
|
||||
(key) =>
|
||||
typeof key === "string" &&
|
||||
(key.includes("events") ||
|
||||
key.includes("events/search") ||
|
||||
key.includes("events/explore")),
|
||||
(key) => isEventsKey(key),
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||
mapSearchResults(currentData, (event) =>
|
||||
event.id === search.id
|
||||
@@ -838,6 +843,7 @@ function ObjectDetailsTab({
|
||||
revalidate: false,
|
||||
},
|
||||
);
|
||||
setSearch({ ...search, data: { ...search.data, description: desc } });
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
@@ -854,7 +860,7 @@ function ObjectDetailsTab({
|
||||
);
|
||||
setDesc(search.data.description);
|
||||
});
|
||||
}, [desc, search, mutate, t, mapSearchResults]);
|
||||
}, [desc, search, mutate, t, mapSearchResults, isEventsKey, setSearch]);
|
||||
|
||||
const regenerateDescription = useCallback(
|
||||
(source: "snapshot" | "thumbnails") => {
|
||||
@@ -921,11 +927,7 @@ function ObjectDetailsTab({
|
||||
});
|
||||
|
||||
mutate(
|
||||
(key) =>
|
||||
typeof key === "string" &&
|
||||
(key.includes("events") ||
|
||||
key.includes("events/search") ||
|
||||
key.includes("events/explore")),
|
||||
(key) => isEventsKey(key),
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||
mapSearchResults(currentData, (event) =>
|
||||
event.id === search.id
|
||||
@@ -972,7 +974,7 @@ function ObjectDetailsTab({
|
||||
);
|
||||
});
|
||||
},
|
||||
[search, apiHost, mutate, setSearch, t, mapSearchResults],
|
||||
[search, apiHost, mutate, setSearch, t, mapSearchResults, isEventsKey],
|
||||
);
|
||||
|
||||
// recognized plate
|
||||
@@ -996,11 +998,7 @@ function ObjectDetailsTab({
|
||||
});
|
||||
|
||||
mutate(
|
||||
(key) =>
|
||||
typeof key === "string" &&
|
||||
(key.includes("events") ||
|
||||
key.includes("events/search") ||
|
||||
key.includes("events/explore")),
|
||||
(key) => isEventsKey(key),
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||
mapSearchResults(currentData, (event) =>
|
||||
event.id === search.id
|
||||
@@ -1047,7 +1045,7 @@ function ObjectDetailsTab({
|
||||
);
|
||||
});
|
||||
},
|
||||
[search, apiHost, mutate, setSearch, t, mapSearchResults],
|
||||
[search, apiHost, mutate, setSearch, t, mapSearchResults, isEventsKey],
|
||||
);
|
||||
|
||||
// speech transcription
|
||||
@@ -1103,12 +1101,9 @@ function ObjectDetailsTab({
|
||||
});
|
||||
|
||||
setState("submitted");
|
||||
setSearch({ ...search, plus_id: "new_upload" });
|
||||
mutate(
|
||||
(key) =>
|
||||
typeof key === "string" &&
|
||||
(key.includes("events") ||
|
||||
key.includes("events/search") ||
|
||||
key.includes("events/explore")),
|
||||
(key) => isEventsKey(key),
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||
mapSearchResults(currentData, (event) =>
|
||||
event.id === search.id
|
||||
@@ -1122,7 +1117,7 @@ function ObjectDetailsTab({
|
||||
},
|
||||
);
|
||||
},
|
||||
[search, mutate, mapSearchResults],
|
||||
[search, mutate, mapSearchResults, setSearch, isEventsKey],
|
||||
);
|
||||
|
||||
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -6,51 +6,199 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Event } from "@/types/event";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { ObjectSnapshotTab } from "../detail/SearchDetailDialog";
|
||||
import { isDesktop, isMobile, isSafari } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { FaCheckCircle } from "react-icons/fa";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
||||
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import useImageLoaded from "@/hooks/use-image-loaded";
|
||||
|
||||
type FrigatePlusDialogProps = {
|
||||
export type FrigatePlusDialogProps = {
|
||||
upload?: Event;
|
||||
dialog?: boolean;
|
||||
onClose: () => void;
|
||||
onEventUploaded: () => void;
|
||||
};
|
||||
|
||||
export function FrigatePlusDialog({
|
||||
upload,
|
||||
dialog = true,
|
||||
onClose,
|
||||
onEventUploaded,
|
||||
}: FrigatePlusDialogProps) {
|
||||
if (!upload) {
|
||||
return;
|
||||
}
|
||||
if (dialog) {
|
||||
return (
|
||||
<Dialog
|
||||
open={upload != undefined}
|
||||
onOpenChange={(open) => (!open ? onClose() : null)}
|
||||
const { t, i18n } = useTranslation(["components/dialog"]);
|
||||
|
||||
type SubmissionState = "reviewing" | "uploading" | "submitted";
|
||||
const [state, setState] = useState<SubmissionState>(
|
||||
upload?.plus_id ? "submitted" : "reviewing",
|
||||
);
|
||||
useEffect(() => {
|
||||
setState(upload?.plus_id ? "submitted" : "reviewing");
|
||||
}, [upload?.plus_id]);
|
||||
|
||||
const onSubmitToPlus = useCallback(
|
||||
async (falsePositive: boolean) => {
|
||||
if (!upload) return;
|
||||
falsePositive
|
||||
? axios.put(`events/${upload.id}/false_positive`)
|
||||
: axios.post(`events/${upload.id}/plus`, { include_annotation: 1 });
|
||||
setState("submitted");
|
||||
onEventUploaded();
|
||||
},
|
||||
[upload, onEventUploaded],
|
||||
);
|
||||
|
||||
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
|
||||
const showCard =
|
||||
!!upload &&
|
||||
upload.data.type === "object" &&
|
||||
upload.plus_id !== "not_enabled" &&
|
||||
upload.end_time &&
|
||||
upload.label !== "on_demand";
|
||||
|
||||
if (!dialog || !upload) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(open) => (!open ? onClose() : null)}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"scrollbar-container overflow-y-auto",
|
||||
isDesktop &&
|
||||
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
|
||||
isMobile && "px-4",
|
||||
)}
|
||||
>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"scrollbar-container overflow-y-auto",
|
||||
isDesktop &&
|
||||
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
|
||||
isMobile && "px-4",
|
||||
)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="sr-only">Submit to Frigate+</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Submit this snapshot to Frigate+
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ObjectSnapshotTab
|
||||
search={upload}
|
||||
onEventUploaded={onEventUploaded}
|
||||
<DialogHeader>
|
||||
<DialogTitle className="sr-only">Submit to Frigate+</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Submit this snapshot to Frigate+
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative size-full">
|
||||
<ImageLoadingIndicator
|
||||
className="absolute inset-0 aspect-video min-h-[60dvh] w-full"
|
||||
imgLoaded={imgLoaded}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
<div className={imgLoaded ? "visible" : "invisible"}>
|
||||
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
|
||||
<div className="flex flex-col space-y-3">
|
||||
<TransformComponent
|
||||
wrapperStyle={{ width: "100%", height: "100%" }}
|
||||
contentStyle={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{upload.id && (
|
||||
<div className="relative mx-auto">
|
||||
<img
|
||||
ref={imgRef}
|
||||
className="mx-auto max-h-[60dvh] rounded-lg bg-black object-contain"
|
||||
src={`${baseUrl}api/events/${upload.id}/snapshot.jpg`}
|
||||
alt={`${upload.label}`}
|
||||
loading={isSafari ? "eager" : "lazy"}
|
||||
onLoad={onImgLoad}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TransformComponent>
|
||||
|
||||
{showCard && (
|
||||
<Card className="p-1 text-sm md:p-2">
|
||||
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="text-lg leading-none">
|
||||
{t("explore.plus.submitToPlus.label")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("explore.plus.submitToPlus.desc")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:w-auto md:justify-end">
|
||||
{state === "reviewing" && (
|
||||
<>
|
||||
<div>
|
||||
{i18n.language === "en" ? (
|
||||
/^[aeiou]/i.test(upload.label || "") ? (
|
||||
<Trans
|
||||
ns="components/dialog"
|
||||
values={{ label: upload.label }}
|
||||
>
|
||||
explore.plus.review.question.ask_an
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans
|
||||
ns="components/dialog"
|
||||
values={{ label: upload.label }}
|
||||
>
|
||||
explore.plus.review.question.ask_a
|
||||
</Trans>
|
||||
)
|
||||
) : (
|
||||
<Trans
|
||||
ns="components/dialog"
|
||||
values={{
|
||||
untranslatedLabel: upload.label,
|
||||
translatedLabel: getTranslatedLabel(
|
||||
upload.label,
|
||||
),
|
||||
}}
|
||||
>
|
||||
explore.plus.review.question.ask_full
|
||||
</Trans>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-row gap-2">
|
||||
<Button
|
||||
className="flex-1 bg-success"
|
||||
aria-label={t("button.yes", { ns: "common" })}
|
||||
onClick={() => {
|
||||
setState("uploading");
|
||||
onSubmitToPlus(false);
|
||||
}}
|
||||
>
|
||||
{t("button.yes", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 text-white"
|
||||
aria-label={t("button.no", { ns: "common" })}
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setState("uploading");
|
||||
onSubmitToPlus(true);
|
||||
}}
|
||||
>
|
||||
{t("button.no", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{state === "uploading" && <ActivityIndicator />}
|
||||
{state === "submitted" && (
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<FaCheckCircle className="size-4 text-success" />
|
||||
{t("explore.plus.review.state.submitted")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -377,7 +377,7 @@ export default function Step1NameCamera({
|
||||
);
|
||||
return selectedBrand &&
|
||||
selectedBrand.value != "other" ? (
|
||||
<Popover>
|
||||
<Popover modal={true}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -600,7 +600,7 @@ export default function Step3StreamConfig({
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.roles")}
|
||||
</Label>
|
||||
<Popover>
|
||||
<Popover modal={true}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<LuInfo className="size-3" />
|
||||
@@ -670,7 +670,7 @@ export default function Step3StreamConfig({
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.featuresTitle")}
|
||||
</Label>
|
||||
<Popover>
|
||||
<Popover modal={true}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<LuInfo className="size-3" />
|
||||
|
||||
Reference in New Issue
Block a user