Merge branch 'dev' into dev

This commit is contained in:
Dmytro Marchuk
2026-06-07 08:10:15 +03:00
committed by GitHub
708 changed files with 44080 additions and 7465 deletions
+3 -1
View File
@@ -78,7 +78,9 @@ function DefaultAppView() {
className={cn(
"absolute right-0 top-0 overflow-hidden",
isMobile
? `bottom-${isPWA ? 16 : 12} left-0 md:bottom-16 landscape:bottom-14 landscape:md:bottom-16`
? isPWA
? "bottom-[calc(3rem+env(safe-area-inset-bottom))] left-0 pt-[env(safe-area-inset-top)] md:bottom-[calc(4rem+env(safe-area-inset-bottom))] landscape:pl-[env(safe-area-inset-left)] landscape:pr-[env(safe-area-inset-right)]"
: "bottom-12 left-0 md:bottom-16"
: "bottom-8 left-[52px]",
)}
>
+11
View File
@@ -10,15 +10,21 @@ export function WsProvider({ children }: { children: ReactNode }) {
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectAttempt = useRef(0);
const unmounted = useRef(false);
const pendingSends = useRef<Map<string, unknown>>(new Map());
const sendJsonMessage = useCallback((msg: unknown) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(msg));
} else if (msg && typeof msg === "object" && "topic" in msg) {
// Sends issued before the socket reaches OPEN (or during a reconnect
// window) are buffered here and flushed in onopen
pendingSends.current.set(String((msg as { topic: unknown }).topic), msg);
}
}, []);
useEffect(() => {
unmounted.current = false;
const queue = pendingSends.current;
function connect() {
if (unmounted.current) return;
@@ -31,6 +37,10 @@ export function WsProvider({ children }: { children: ReactNode }) {
ws.send(
JSON.stringify({ topic: "onConnect", message: "", retain: false }),
);
for (const queued of queue.values()) {
ws.send(JSON.stringify(queued));
}
queue.clear();
};
ws.onmessage = (event: MessageEvent) => {
@@ -64,6 +74,7 @@ export function WsProvider({ children }: { children: ReactNode }) {
ws.onerror = null;
ws.close();
}
queue.clear();
resetWsStore();
};
}, [wsUrl]);
+2 -2
View File
@@ -107,7 +107,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
<FormLabel>{t("form.user")}</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
autoFocus
autoCapitalize="off"
autoCorrect="off"
@@ -125,7 +125,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
<FormLabel>{t("form.password")}</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
type="password"
{...field}
/>
+2 -1
View File
@@ -6,6 +6,7 @@ import {
isRedirectingToLogin,
setRedirectingToLogin,
} from "@/api/auth-redirect";
import { baseUrl } from "@/api/baseUrl";
export default function ProtectedRoute({
requiredRoles,
@@ -24,7 +25,7 @@ export default function ProtectedRoute({
!isRedirectingToLogin()
) {
setRedirectingToLogin(true);
window.location.href = "/login";
window.location.href = `${baseUrl}login`;
}
}, [auth.isLoading, auth.isAuthenticated, auth.user]);
@@ -14,8 +14,8 @@ const BlurredIconButton = forwardRef<HTMLDivElement, BlurredIconButtonProps>(
)}
{...rest}
>
<div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-0 blur-sm transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" />
<div className="relative z-10 cursor-pointer text-white/85 hover:text-white">
<div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-30 blur-md transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" />
<div className="relative z-10 cursor-pointer text-white/85 drop-shadow-[0_1px_1px_rgba(0,0,0,0.9)] hover:text-white">
{children}
</div>
</div>
+72 -3
View File
@@ -32,6 +32,9 @@ import { FaFolder, FaVideo } from "react-icons/fa";
import { HiSquare2Stack } from "react-icons/hi2";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import useContextMenu from "@/hooks/use-contextmenu";
import axios from "axios";
import { toast } from "sonner";
import { useNavigate } from "react-router-dom";
type CaseCardProps = {
className: string;
@@ -123,11 +126,63 @@ export function ExportCard({
onAssignToCase,
onRemoveFromCase,
}: ExportCardProps) {
const { t } = useTranslation(["views/exports"]);
const { t } = useTranslation(["views/exports", "views/replay"]);
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0,
);
const [isStartingReplay, setIsStartingReplay] = useState(false);
const handleDebugReplay = useCallback(() => {
setIsStartingReplay(true);
axios
.post("debug_replay/start_from_export", {
export_id: exportedRecording.id,
})
.then((response) => {
if (response.status === 202 || response.status === 200) {
navigate("/replay");
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
if (error.response?.status === 409) {
toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), {
position: "top-center",
closeButton: true,
dismissible: false,
action: (
<a
href={`${baseUrl}replay`}
target="_blank"
rel="noopener noreferrer"
>
<Button>
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
</Button>
</a>
),
});
} else {
toast.error(
t("dialog.toast.error", {
ns: "views/replay",
error: errorMessage,
}),
{ position: "top-center" },
);
}
})
.finally(() => {
setIsStartingReplay(false);
});
}, [exportedRecording.id, navigate, t]);
// Resync the skeleton state whenever the backing export changes. The
// list keys by id now, so in practice the component remounts instead
@@ -202,7 +257,7 @@ export function ExportCard({
{editName && (
<>
<Input
className="text-md mt-3"
className="mt-3"
type="search"
placeholder={editName?.original}
value={
@@ -220,7 +275,6 @@ export function ExportCard({
<DialogFooter>
<Button
aria-label={t("editExport.saveExport")}
size="sm"
variant="select"
disabled={(editName?.update?.length ?? 0) == 0}
onClick={() => submitRename()}
@@ -301,6 +355,21 @@ export function ExportCard({
{t("tooltip.downloadVideo")}
</a>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("title", { ns: "views/replay" })}
disabled={isStartingReplay}
onClick={(e) => {
e.stopPropagation();
handleDebugReplay();
}}
>
{isStartingReplay
? t("dialog.starting", { ns: "views/replay" })
: t("title", { ns: "views/replay" })}
</DropdownMenuItem>
)}
{isAdmin && onAssignToCase && (
<DropdownMenuItem
className="cursor-pointer"
+5 -1
View File
@@ -94,7 +94,11 @@ export default function ReviewCard({
toast.success(t("export.toast.success"), {
position: "top-center",
action: (
<a href="/export" target="_blank" rel="noopener noreferrer">
<a
href={`${baseUrl}export`}
target="_blank"
rel="noopener noreferrer"
>
<Button>{t("export.toast.view")}</Button>
</a>
),
@@ -14,7 +14,7 @@ type SettingsGroupCardProps = {
export function SettingsGroupCard({ title, children }: SettingsGroupCardProps) {
return (
<div className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4">
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
<div className="border-b border-border/60 pb-4 font-semibold text-primary-variant">
{title}
</div>
{children}
@@ -1,4 +1,5 @@
import { useApiHost } from "@/api";
import { baseUrl } from "@/api/baseUrl";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
@@ -79,7 +80,7 @@ export function ChatAttachmentChip({
<Tooltip>
<TooltipTrigger asChild>
<a
href={`/explore?event_id=${eventId}`}
href={`${baseUrl}explore?event_id=${eventId}`}
target="_blank"
rel="noopener noreferrer"
className={cn(
+147
View File
@@ -0,0 +1,147 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FaArrowUpLong, FaStop } from "react-icons/fa6";
import { LuBrain } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
import { ChatQuickReplies } from "@/components/chat/ChatQuickReplies";
import { ChatPaperclipButton } from "@/components/chat/ChatPaperclipButton";
type ChatComposerProps = {
input: string;
setInput: (value: string) => void;
sendMessage: (textOverride?: string) => void;
placeholder: string;
supportsThinking: boolean;
thinkingEnabled: boolean;
setThinkingEnabled: (value: boolean | undefined) => void;
isLoading?: boolean;
onStop?: () => void;
attachedEventId?: string | null;
onClearAttachment?: () => void;
onAttach?: (eventId: string) => void;
recentEventIds?: string[];
large?: boolean;
};
export function ChatComposer({
input,
setInput,
sendMessage,
placeholder,
supportsThinking,
thinkingEnabled,
setThinkingEnabled,
isLoading = false,
onStop,
attachedEventId,
onClearAttachment,
onAttach,
recentEventIds,
large = false,
}: ChatComposerProps) {
const { t } = useTranslation(["views/chat"]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
const showPaperclip = !!onAttach;
const showStop = isLoading && !!onStop;
return (
<div className="flex w-full flex-col items-stretch justify-center gap-2 rounded-xl bg-secondary p-3">
{attachedEventId && onClearAttachment && (
<div className="flex items-center">
<ChatAttachmentChip
eventId={attachedEventId}
mode="composer"
onRemove={onClearAttachment}
/>
</div>
)}
{attachedEventId && (
<ChatQuickReplies
onSend={(text) => sendMessage(text)}
disabled={isLoading}
/>
)}
<div className="flex w-full flex-row items-center gap-2">
{showPaperclip && (
<ChatPaperclipButton
recentEventIds={recentEventIds ?? []}
onAttach={onAttach!}
disabled={isLoading || attachedEventId != null}
/>
)}
{supportsThinking && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="sm"
variant={thinkingEnabled ? "select" : "ghost"}
aria-pressed={thinkingEnabled}
aria-label={t("thinking.toggle")}
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-full p-0",
!thinkingEnabled && "text-secondary-foreground",
)}
onClick={() => setThinkingEnabled(!thinkingEnabled)}
disabled={isLoading}
>
<LuBrain className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("thinking.toggle")}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Input
className={cn(
"w-full flex-1 border-transparent bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
large && "h-12 text-base",
)}
placeholder={placeholder}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
aria-busy={isLoading}
/>
{showStop ? (
<Button
variant="destructive"
className="size-10 shrink-0 rounded-full"
onClick={onStop}
>
<FaStop className="size-3" />
</Button>
) : (
<Button
variant="select"
className="size-10 shrink-0 rounded-full"
disabled={!input.trim() || isLoading}
onClick={() => sendMessage()}
>
<FaArrowUpLong className="size-4" />
</Button>
)}
</div>
</div>
);
}
@@ -1,4 +1,5 @@
import { useApiHost } from "@/api";
import { baseUrl } from "@/api/baseUrl";
import { useTranslation } from "react-i18next";
import { LuExternalLink } from "react-icons/lu";
import {
@@ -54,7 +55,7 @@ export function ChatEventThumbnailsRow({
<Tooltip>
<TooltipTrigger asChild>
<a
href={`/explore?event_id=${event.id}`}
href={`${baseUrl}explore?event_id=${event.id}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
+2 -2
View File
@@ -48,7 +48,7 @@ export default function ChatSettings({
<div className="my-3 space-y-5 py-3 md:mt-0 md:py-0">
<div className="space-y-3">
<div className="space-y-0.5">
<div className="text-md">{t("settings.show_stats.title")}</div>
<div>{t("settings.show_stats.title")}</div>
<div className="text-xs text-muted-foreground">
{t("settings.show_stats.desc")}
</div>
@@ -77,7 +77,7 @@ export default function ChatSettings({
<DropdownMenuSeparator />
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<Label htmlFor="auto-scroll" className="text-md cursor-pointer">
<Label htmlFor="auto-scroll" className="cursor-pointer">
{t("settings.auto_scroll.title")}
</Label>
<div className="text-xs text-muted-foreground">
+21 -26
View File
@@ -1,15 +1,22 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FaArrowUpLong } from "react-icons/fa6";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import type { StartingRequest } from "@/types/chat";
import { ChatComposer } from "@/components/chat/ChatComposer";
type ChatStartingStateProps = {
onSendMessage: (message: string) => void;
supportsThinking: boolean;
thinkingEnabled: boolean;
setThinkingEnabled: (value: boolean | undefined) => void;
};
export function ChatStartingState({ onSendMessage }: ChatStartingStateProps) {
export function ChatStartingState({
onSendMessage,
supportsThinking,
thinkingEnabled,
setThinkingEnabled,
}: ChatStartingStateProps) {
const { t } = useTranslation(["views/chat"]);
const [input, setInput] = useState("");
@@ -36,20 +43,13 @@ export function ChatStartingState({ onSendMessage }: ChatStartingStateProps) {
onSendMessage(prompt);
};
const handleSubmit = () => {
const text = input.trim();
const handleSend = (textOverride?: string) => {
const text = (textOverride ?? input).trim();
if (!text) return;
onSendMessage(text);
setInput("");
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<div className="flex size-full flex-col items-center justify-center gap-6 p-8">
<div className="flex flex-col items-center gap-2">
@@ -77,22 +77,17 @@ export function ChatStartingState({ onSendMessage }: ChatStartingStateProps) {
</div>
</div>
<div className="flex w-full max-w-2xl flex-row items-center gap-2 rounded-xl bg-secondary p-3">
<Input
className="h-12 w-full flex-1 border-transparent bg-transparent text-base shadow-none focus-visible:ring-0 dark:bg-transparent"
<div className="w-full max-w-2xl">
<ChatComposer
input={input}
setInput={setInput}
sendMessage={handleSend}
placeholder={t("placeholder")}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
supportsThinking={supportsThinking}
thinkingEnabled={thinkingEnabled}
setThinkingEnabled={setThinkingEnabled}
large
/>
<Button
variant="select"
className="size-10 shrink-0 rounded-full"
disabled={!input.trim()}
onClick={handleSubmit}
>
<FaArrowUpLong size="18" />
</Button>
</div>
</div>
);
+101
View File
@@ -0,0 +1,101 @@
import { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { LuBrain, LuChevronDown, LuChevronRight } from "react-icons/lu";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
type ReasoningBubbleProps = {
/** The accumulated reasoning text from the model. */
reasoning: string;
/**
* Whether the assistant has begun producing the user-facing answer.
* While false the reasoning is still streaming and we keep the panel
* open with a "Thinking…" label. Once true, the panel auto-collapses
* so the answer is the primary focus, but stays expandable.
*/
answerStarted: boolean;
};
export function ReasoningBubble({
reasoning,
answerStarted,
}: ReasoningBubbleProps) {
const { t } = useTranslation(["views/chat"]);
// Open while the model is still mid-thought (no answer tokens yet);
// once the answer begins, collapse on its own but let the user reopen.
const [open, setOpen] = useState(true);
const userInteractedRef = useRef(false);
const lastAutoState = useRef(true);
useEffect(() => {
if (userInteractedRef.current) return;
const desired = !answerStarted;
if (desired !== lastAutoState.current) {
lastAutoState.current = desired;
setOpen(desired);
}
}, [answerStarted]);
const handleOpenChange = (next: boolean) => {
userInteractedRef.current = true;
setOpen(next);
};
const label = !answerStarted
? t("reasoning.active")
: open
? t("reasoning.hide")
: t("reasoning.show");
return (
<div className="self-start rounded-2xl bg-muted/60 px-3 py-2 text-muted-foreground">
<TooltipProvider>
<Collapsible open={open} onOpenChange={handleOpenChange}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-auto w-full min-w-0 justify-start gap-2 whitespace-normal p-0 text-left text-xs hover:bg-transparent"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<LuBrain
className={cn(
"size-3 shrink-0",
!answerStarted && "animate-pulse",
)}
/>
</div>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
{answerStarted &&
(open ? (
<LuChevronDown className="ml-auto size-3 shrink-0" />
) : (
<LuChevronRight className="ml-auto size-3 shrink-0" />
))}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="scrollbar-container mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-sans text-xs leading-relaxed">
{reasoning}
</pre>
</CollapsibleContent>
</Collapsible>
</TooltipProvider>
</div>
);
}
@@ -485,7 +485,7 @@ export default function ClassificationModelEditDialog({
<FormControl>
<div className="flex items-center gap-2">
<Input
className="text-md h-8"
className="h-8"
placeholder={t(
"wizard.step1.classPlaceholder",
)}
@@ -214,7 +214,7 @@ export default function Step1NameAndDefine({
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
className="h-8"
placeholder={t("wizard.step1.namePlaceholder")}
{...field}
/>
@@ -457,7 +457,7 @@ export default function Step1NameAndDefine({
<FormControl>
<div className="flex items-center gap-2">
<Input
className="text-md h-8"
className="h-8"
placeholder={t("wizard.step1.classPlaceholder")}
{...field}
/>
@@ -489,7 +489,7 @@ export default function Step1NameAndDefine({
</form>
</Form>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={onCancel} className="sm:flex-1">
{t("button.cancel", { ns: "common" })}
</Button>
@@ -14,6 +14,7 @@ import Konva from "konva";
import { useResizeObserver } from "@/hooks/resize-observer";
import { useApiHost } from "@/api";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
import { isReplayCamera } from "@/utils/cameraUtil";
import Heading from "@/components/ui/heading";
import { isMobile } from "react-device-detect";
import { cn } from "@/lib/utils";
@@ -50,6 +51,7 @@ export default function Step2StateArea({
const [imageLoaded, setImageLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const popoverContainerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const stageRef = useRef<Konva.Stage>(null);
const rectRef = useRef<Konva.Rect>(null);
@@ -67,6 +69,7 @@ export default function Step2StateArea({
([name, cam]) =>
cam.enabled &&
cam.enabled_in_config &&
!isReplayCamera(name) &&
!selectedCameraNames.includes(name),
)
.map(([name]) => ({
@@ -222,7 +225,7 @@ export default function Step2StateArea({
const canContinue = cameraAreas.length > 0;
return (
<div className="flex flex-col gap-4">
<div ref={popoverContainerRef} className="flex flex-col gap-4">
<div
className={cn(
"flex gap-4 overflow-hidden",
@@ -253,6 +256,7 @@ export default function Step2StateArea({
className="scrollbar-container w-64 border bg-background p-3 shadow-lg"
align="start"
sideOffset={5}
container={popoverContainerRef.current}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-2">
@@ -456,7 +460,7 @@ export default function Step2StateArea({
</div>
</div>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
@@ -1,4 +1,4 @@
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { useState, useEffect, useCallback, useMemo } from "react";
import ActivityIndicator from "@/components/indicators/activity-indicator";
@@ -540,7 +540,7 @@ export default function Step3ChooseExamples({
</AlertDialogCancel>
<AlertDialogAction
onClick={doRefresh}
className="bg-destructive text-white hover:bg-destructive/90"
className={cn(buttonVariants({ variant: "destructive" }))}
>
{t("button.continue", { ns: "common" })}
</AlertDialogAction>
@@ -693,7 +693,7 @@ export default function Step3ChooseExamples({
)}
{!isTraining && (
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={handleBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
@@ -15,13 +15,13 @@ const severityVariantMap: Record<
function SeverityIcon({ severity }: { severity: string }) {
switch (severity) {
case "info":
return <LuInfo className="size-4" />;
return <LuInfo className="size-4 shrink-0" />;
case "warning":
return <LuTriangleAlert className="size-4" />;
return <LuTriangleAlert className="size-4 shrink-0" />;
case "error":
return <LuCircleAlert className="size-4" />;
return <LuCircleAlert className="size-4 shrink-0" />;
default:
return <LuInfo className="size-4" />;
return <LuInfo className="size-4 shrink-0" />;
}
}
@@ -18,11 +18,11 @@ const severityVariantMap: Record<
function SeverityIcon({ severity }: { severity: MessageSeverity }) {
switch (severity) {
case "info":
return <LuInfo className="size-4" />;
return <LuInfo className="size-4 shrink-0" />;
case "warning":
return <LuTriangleAlert className="size-4" />;
return <LuTriangleAlert className="size-4 shrink-0" />;
case "error":
return <LuCircleAlert className="size-4" />;
return <LuCircleAlert className="size-4 shrink-0" />;
}
}
@@ -44,7 +44,7 @@ export function ConfigMessageBanner({ messages }: ConfigMessageBannerProps) {
className="flex items-center [&>svg+div]:translate-y-0 [&>svg]:static [&>svg~*]:pl-2"
>
<SeverityIcon severity={msg.severity} />
<AlertDescription>{t(msg.messageKey)}</AlertDescription>
<AlertDescription>{t(msg.messageKey, msg.values)}</AlertDescription>
</Alert>
))}
</div>
@@ -0,0 +1,13 @@
import { createContext } from "react";
import type { FieldConditionalMessage } from "./section-configs/types";
// Provides currently-active field messages to FieldTemplate without going
// through RJSF's per-field uiSchema. RJSF caches state.uiSchema across renders
// in a way that can leave stale ui:messages attached to a field when the
// triggering condition flips back to false (see processPendingChange in
// @rjsf/core Form.js — formData is updated immediately, uiSchema is not).
// useContext re-runs consumers directly on provider value change, sidestepping
// that staleness.
export const FieldMessagesContext = createContext<FieldConditionalMessage[]>(
[],
);
@@ -0,0 +1,13 @@
import { createContext } from "react";
import type { ConfigSectionData } from "@/types/configForm";
// Mirrors the current section's in-flight form data so widgets can react
// to changes that RJSF wouldn't otherwise re-render them for. RJSF's
// Form memoizes SchemaField via deep equality and, in some transitions
// (notably reverting a field to its saved value), can skip re-rendering
// a widget even though the form data it depends on changed. useContext
// re-runs consumers directly on every provider value update, sidestepping
// that.
export const LiveFormDataContext = createContext<ConfigSectionData | null>(
null,
);
@@ -19,7 +19,7 @@ const birdseye: SectionConfigOverrides = {
],
restartRequired: [],
fieldOrder: ["enabled", "mode", "order"],
hiddenFields: [],
hiddenFields: ["order"],
advancedFields: [],
overrideFields: ["enabled", "mode"],
uiSchema: {
@@ -56,6 +56,7 @@ const birdseye: SectionConfigOverrides = {
uiSchema: {
mode: {
"ui:size": "xs",
"ui:after": { render: "BirdseyeCameraReorder" },
},
},
},
@@ -2,7 +2,7 @@ import type { SectionConfigOverrides } from "./types";
const database: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/advanced#database",
sectionDocs: "/configuration/advanced/system#database",
restartRequired: ["path"],
fieldOrder: ["path"],
advancedFields: [],
@@ -13,6 +13,103 @@ const detect: SectionConfigOverrides = {
},
],
fieldMessages: [
{
key: "detect-resolution-not-multiple-of-four",
field: "width",
position: "before",
messageKey: "configMessages.detect.resolutionShouldBeMultipleOfFour",
severity: "warning",
condition: (ctx) => {
const width = ctx.formData?.width as number | null | undefined;
const height = ctx.formData?.height as number | null | undefined;
const isEvenButNotFour = (v: unknown) =>
typeof v === "number" && v % 2 === 0 && v % 4 !== 0;
return isEvenButNotFour(width) || isEvenButNotFour(height);
},
},
{
key: "detect-global-resolution-multiple-cameras",
field: "width",
position: "before",
messageKey: "configMessages.detect.globalResolutionMultipleCameras",
severity: "info",
condition: (ctx) => {
if (ctx.level !== "global") return false;
const width = ctx.formData?.width as number | null | undefined;
const height = ctx.formData?.height as number | null | undefined;
if (typeof width !== "number" && typeof height !== "number") {
return false;
}
const cameraCount = Object.keys(ctx.fullConfig?.cameras ?? {}).length;
return cameraCount > 1;
},
},
{
key: "detect-resolution-high",
field: "width",
position: "before",
messageKey: "configMessages.detect.resolutionHigh",
severity: "warning",
condition: (ctx) => {
const width = ctx.formData?.width as number | null | undefined;
const height = ctx.formData?.height as number | null | undefined;
if (typeof width !== "number" || typeof height !== "number") {
return false;
}
return Math.min(width, height) > 1080;
},
},
{
key: "detect-square-resolution",
field: "width",
position: "before",
messageKey: "configMessages.detect.squareResolution",
severity: "warning",
condition: (ctx) => {
const width = ctx.formData?.width as number | null | undefined;
const height = ctx.formData?.height as number | null | undefined;
return (
typeof width === "number" &&
typeof height === "number" &&
width > 0 &&
width === height
);
},
},
{
key: "detect-aspect-ratio-mismatch",
field: "width",
position: "before",
messageKey: "configMessages.detect.aspectRatioMismatch",
severity: "warning",
condition: (ctx) => {
const newWidth = ctx.formData?.width as number | null | undefined;
const newHeight = ctx.formData?.height as number | null | undefined;
if (typeof newWidth !== "number" || typeof newHeight !== "number") {
return false;
}
const saved =
ctx.level === "camera"
? ctx.fullCameraConfig?.detect
: ctx.fullConfig?.detect;
const savedWidth = saved?.width;
const savedHeight = saved?.height;
if (
typeof savedWidth !== "number" ||
typeof savedHeight !== "number" ||
savedWidth <= 0 ||
savedHeight <= 0
) {
return false;
}
if (newWidth === savedWidth && newHeight === savedHeight) {
return false;
}
const newRatio = newWidth / newHeight;
const savedRatio = savedWidth / savedHeight;
return Math.abs(newRatio - savedRatio) > 0.01;
},
},
{
key: "fps-greater-than-five",
field: "fps",
@@ -27,6 +124,31 @@ const detect: SectionConfigOverrides = {
return detectFps != null && streamFps != null && detectFps > 5;
},
},
{
key: "max-frames-set",
field: "stationary.max_frames",
messageKey: "configMessages.detect.maxFramesSet",
severity: "warning",
position: "after",
condition: (ctx) => {
const stationary = ctx.formData?.stationary as
| {
max_frames?: {
default?: number | null;
objects?: Record<string, number>;
} | null;
}
| null
| undefined;
const maxFrames = stationary?.max_frames;
if (!maxFrames) return false;
return (
typeof maxFrames.default === "number" ||
(maxFrames.objects != null &&
Object.keys(maxFrames.objects).length > 0)
);
},
},
],
fieldOrder: [
"enabled",
@@ -37,9 +159,9 @@ const detect: SectionConfigOverrides = {
"max_disappeared",
"annotation_offset",
"stationary",
"interval",
"threshold",
"max_frames",
"stationary.interval",
"stationary.threshold",
"stationary.max_frames",
],
restartRequired: [],
fieldGroups: {
@@ -72,6 +194,22 @@ const detect: SectionConfigOverrides = {
"max_disappeared",
],
},
replay: {
restartRequired: [],
fieldOrder: ["width", "height", "fps"],
fieldGroups: {
resolution: ["width", "height", "fps"],
},
hiddenFields: [
"enabled",
"enabled_in_config",
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
],
advancedFields: [],
},
};
export default detect;
@@ -2,7 +2,7 @@ import type { SectionConfigOverrides } from "./types";
const environmentVars: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/advanced#environment_vars",
sectionDocs: "/configuration/advanced/system#environment_vars",
fieldOrder: [],
advancedFields: [],
uiSchema: {
@@ -2,7 +2,7 @@ import type { SectionConfigOverrides } from "./types";
const genai: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/genai/config",
sectionDocs: "/configuration/genai/genai_config",
advancedFields: ["*.base_url", "*.provider_options", "*.runtime_options"],
hiddenFields: ["genai.enabled_in_config"],
restartRequired: [],
@@ -24,6 +24,7 @@ const genai: SectionConfigOverrides = {
"ui:widget": "genaiRoles",
},
"*.api_key": {
"ui:widget": "password",
"ui:options": { size: "lg" },
},
"*.base_url": {
@@ -4,17 +4,26 @@ const live: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/live",
restartRequired: [],
fieldOrder: ["stream_name", "height", "quality"],
fieldOrder: ["streams", "height", "quality"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: ["height", "quality"],
},
global: {
restartRequired: ["stream_name", "height", "quality"],
restartRequired: ["streams", "height", "quality"],
hiddenFields: ["streams"],
},
camera: {
restartRequired: ["height", "quality"],
uiSchema: {
streams: {
"ui:field": "LiveStreamsField",
"ui:options": {
label: false,
suppressDescription: true,
},
},
},
},
};
@@ -2,7 +2,7 @@ import type { SectionConfigOverrides } from "./types";
const logger: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/advanced#logger",
sectionDocs: "/configuration/advanced/system#frigate-logger",
restartRequired: ["default", "logs"],
fieldOrder: ["default", "logs"],
advancedFields: ["logs"],
@@ -64,6 +64,7 @@ const mqtt: SectionConfigOverrides = {
liveValidate: true,
uiSchema: {
password: {
"ui:widget": "password",
"ui:options": { size: "xs" },
},
},
@@ -2,10 +2,12 @@ import type { SectionConfigOverrides } from "./types";
const networking: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/advanced",
sectionDocs: "/configuration/advanced/system#network-configuration",
fieldDocs: {
"listen.internal": "/configuration/advanced#listen-on-different-ports",
"listen.external": "/configuration/advanced#listen-on-different-ports",
"listen.internal":
"/configuration/advanced/system#listen-on-different-ports",
"listen.external":
"/configuration/advanced/system#listen-on-different-ports",
},
restartRequired: ["ipv6.enabled", "listen.internal", "listen.external"],
fieldOrder: [],
@@ -1,12 +1,60 @@
import type { FrigateConfig } from "@/types/frigateConfig";
import type { HiddenFieldContext } from "@/types/configForm";
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
import type { SectionConfigOverrides } from "./types";
// Attribute labels (face, license_plate, Frigate+ couriers like DHL/Amazon,
// etc.) are populated into objects.filters by the backend even when the
// model can't actually detect them. They aren't user-settable, so hide any
// `filters.<attr>` patterns from forms and override comparisons.
const hideAttributeFilters = (config: FrigateConfig): string[] =>
(config.model?.all_attributes ?? []).map((attr) => `filters.${attr}`);
// etc.) are populated into objects.filters by the backend for every
// attribute the model knows about.
//
// - Untracked attributes: hide the whole `filters.<attr>` collapsible.
// - Tracked attributes: strip the FilterConfig fields we don't expose
// (`threshold`, `min_ratio`, `max_ratio`) from the form data so RJSF
// doesn't surface them as ad-hoc additionalProperties entries under the
// restricted AttributeFilter schema (see modifySchemaForSection objects
// branch). The data is sanitized out symmetrically from the baseline
// too, so power-user YAML values for those fields are preserved on save
// (buildOverrides only emits diffs of fields the form has seen).
const ATTRIBUTE_FILTER_HIDDEN_SUBFIELDS = [
"threshold",
"min_ratio",
"max_ratio",
];
const hideAttributeFilters = ({
fullConfig,
fullCameraConfig,
level,
formData,
}: HiddenFieldContext): string[] => {
const trackFromForm = Array.isArray(
(formData as { track?: unknown } | undefined)?.track,
)
? (formData as { track: string[] }).track
: undefined;
const track =
trackFromForm ??
(level !== "global" ? fullCameraConfig?.objects?.track : undefined) ??
fullConfig.objects?.track ??
[];
const attrs = getEffectiveAttributeLabels(
fullConfig,
fullCameraConfig,
level,
);
const hidden: string[] = [];
for (const attr of attrs) {
if (!track.includes(attr)) {
hidden.push(`filters.${attr}`);
} else {
for (const field of ATTRIBUTE_FILTER_HIDDEN_SUBFIELDS) {
hidden.push(`filters.${attr}.${field}`);
}
}
}
return hidden;
};
const objects: SectionConfigOverrides = {
base: {
@@ -25,10 +25,31 @@ const onvif: SectionConfigOverrides = {
advancedFields: ["tls_insecure", "ignore_time_mismatch"],
overrideFields: [],
restartRequired: ["autotracking.calibrate_on_startup"],
fieldMessages: [
{
key: "autotracking-no-zones",
field: "autotracking.required_zones",
messageKey: "configMessages.onvif.autotrackingNoZones",
severity: "error",
position: "before",
condition: (ctx) => {
if (ctx.level !== "camera") return false;
const zones = ctx.fullCameraConfig?.zones;
return (
!zones ||
typeof zones !== "object" ||
Object.keys(zones).length === 0
);
},
},
],
uiSchema: {
host: {
"ui:options": { size: "sm" },
},
password: {
"ui:widget": "password",
},
profile: {
"ui:widget": "onvifProfile",
},
@@ -36,11 +57,16 @@ const onvif: SectionConfigOverrides = {
required_zones: {
"ui:widget": "zoneNames",
},
return_preset: {
"ui:options": { size: "sm" },
"ui:widget": "ptzPresets",
},
track: {
"ui:widget": "objectLabels",
},
zooming: {
"ui:options": {
size: "xs",
enumI18nPrefix: "onvif.autotracking.zooming",
},
},
@@ -18,8 +18,13 @@ const proxy: SectionConfigOverrides = {
"ui:options": { size: "lg" },
},
auth_secret: {
"ui:widget": "password",
"ui:options": { size: "md" },
},
default_role: {
"ui:widget": "defaultRole",
"ui:options": { size: "sm" },
},
header_map: {
"ui:after": { render: "ProxyRoleMap" },
},
@@ -46,7 +46,11 @@ const record: SectionConfigOverrides = {
uiSchema: {
export: {
hwaccel_args: {
"ui:options": { suppressMultiSchema: true, size: "lg" },
"ui:widget": "FfmpegArgsWidget",
"ui:options": {
suppressMultiSchema: true,
ffmpegPresetField: "hwaccel_args",
},
},
},
"alerts.retain.mode": {
@@ -35,6 +35,7 @@ const semanticSearch: SectionConfigOverrides = {
"ui:widget": "semanticSearchModel",
},
model_size: {
"ui:widget": "semanticSearchModelSize",
"ui:options": { size: "xs", enumI18nPrefix: "modelSize" },
},
},
@@ -2,7 +2,7 @@ import type { SectionConfigOverrides } from "./types";
const telemetry: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/reference",
sectionDocs: "/configuration/advanced/reference",
restartRequired: ["version_check"],
fieldOrder: ["network_interfaces", "stats", "version_check"],
advancedFields: [],
@@ -2,7 +2,7 @@ import type { SectionConfigOverrides } from "./types";
const timestampStyle: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/reference",
sectionDocs: "/configuration/advanced/reference",
restartRequired: [],
fieldOrder: ["position", "format", "thickness", "color"],
hiddenFields: ["effect", "enabled_in_config"],
@@ -24,6 +24,8 @@ export type ConditionalMessage = {
severity: MessageSeverity;
/** Function returning true when the message should be shown */
condition: (ctx: MessageConditionContext) => boolean;
/** Optional interpolation values passed to t() for {{var}} substitution */
values?: Record<string, unknown>;
};
/** Field-level conditional message, adds field targeting */
@@ -2,21 +2,15 @@ import type { SectionConfigOverrides } from "./types";
const ui: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/reference",
sectionDocs: "/configuration/advanced/reference",
restartRequired: [],
fieldOrder: ["dashboard", "order"],
hiddenFields: [],
hiddenFields: ["order"],
advancedFields: [],
overrideFields: [],
},
global: {
fieldOrder: [
"timezone",
"time_format",
"date_style",
"time_style",
"unit_system",
],
fieldOrder: ["timezone", "time_format", "unit_system"],
advancedFields: [],
restartRequired: ["unit_system"],
uiSchema: {
@@ -26,12 +20,6 @@ const ui: SectionConfigOverrides = {
time_format: {
"ui:options": { enumI18nPrefix: "ui.timeFormat" },
},
date_style: {
"ui:options": { enumI18nPrefix: "ui.TimeOrDateStyle" },
},
time_style: {
"ui:options": { enumI18nPrefix: "ui.TimeOrDateStyle" },
},
unit_system: {
"ui:options": { enumI18nPrefix: "ui.unitSystem" },
},
@@ -0,0 +1,36 @@
import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next";
import { isJsonObject } from "@/lib/utils";
import type { JsonObject } from "@/types/configForm";
export function validateDetectDimensions(
formData: unknown,
errors: FormValidation,
t: TFunction,
): FormValidation {
if (!isJsonObject(formData as JsonObject)) {
return errors;
}
const data = formData as JsonObject;
const width = data.width;
const height = data.height;
const widthErrors = errors.width as
| { addError?: (message: string) => void }
| undefined;
const heightErrors = errors.height as
| { addError?: (message: string) => void }
| undefined;
const message = t("detect.dimensionMustBeEven", { ns: "config/validation" });
if (typeof width === "number" && width % 2 !== 0) {
widthErrors?.addError?.(message);
}
if (typeof height === "number" && height % 2 !== 0) {
heightErrors?.addError?.(message);
}
return errors;
}
@@ -1,5 +1,6 @@
import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next";
import { validateDetectDimensions } from "./detect";
import { validateFfmpegInputRoles } from "./ffmpeg";
import { validateProxyRoleHeader } from "./proxy";
@@ -19,6 +20,10 @@ export function getSectionValidation({
level,
t,
}: SectionValidationOptions): SectionValidation | undefined {
if (sectionPath === "detect") {
return (formData, errors) => validateDetectDimensions(formData, errors, t);
}
if (sectionPath === "ffmpeg" && level === "camera") {
return (formData, errors) => validateFfmpegInputRoles(formData, errors, t);
}
@@ -0,0 +1,213 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import axios from "axios";
import { toast } from "sonner";
import useSWR from "swr";
import { Reorder, useDragControls } from "framer-motion";
import { LuCheck, LuGripVertical } from "react-icons/lu";
import { SplitCardRow } from "@/components/card/SettingsGroupCard";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { FrigateConfig } from "@/types/frigateConfig";
import { cn } from "@/lib/utils";
import type { SectionRendererProps } from "./registry";
const SAVED_INDICATOR_MS = 1500;
type SaveStatus = "idle" | "saving" | "saved";
export default function BirdseyeCameraReorder({
formContext,
}: SectionRendererProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const birdseyeCameras = useMemo(() => {
if (!config) return [];
return Object.keys(config.cameras)
.filter(
(name) =>
config.cameras[name].enabled_in_config &&
config.cameras[name].birdseye?.enabled !== false,
)
.sort((a, b) => {
const orderA = config.cameras[a].birdseye?.order ?? 0;
const orderB = config.cameras[b].birdseye?.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return a.localeCompare(b);
});
}, [config]);
const [orderedCameras, setOrderedCameras] =
useState<string[]>(birdseyeCameras);
const orderedCamerasRef = useRef(orderedCameras);
useEffect(() => {
orderedCamerasRef.current = orderedCameras;
}, [orderedCameras]);
useEffect(() => {
setOrderedCameras((prev) => {
if (
prev.length === birdseyeCameras.length &&
prev.every((cam, i) => cam === birdseyeCameras[i])
) {
return prev;
}
return birdseyeCameras;
});
}, [birdseyeCameras]);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const savedResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (savedResetTimerRef.current) {
clearTimeout(savedResetTimerRef.current);
}
};
}, []);
const handleDragEnd = useCallback(async () => {
const current = orderedCamerasRef.current;
if (
current.length === birdseyeCameras.length &&
current.every((cam, i) => cam === birdseyeCameras[i])
) {
return;
}
const cameraUpdates: Record<string, { birdseye: { order: number } }> = {};
current.forEach((cam, i) => {
cameraUpdates[cam] = { birdseye: { order: i * 10 } };
});
if (savedResetTimerRef.current) {
clearTimeout(savedResetTimerRef.current);
savedResetTimerRef.current = null;
}
setSaveStatus("saving");
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: { cameras: cameraUpdates },
});
await updateConfig();
setSaveStatus("saved");
savedResetTimerRef.current = setTimeout(() => {
setSaveStatus("idle");
savedResetTimerRef.current = null;
}, SAVED_INDICATOR_MS);
} catch (error) {
setOrderedCameras(birdseyeCameras);
setSaveStatus("idle");
const errorMessage =
axios.isAxiosError(error) &&
(error.response?.data?.message || error.response?.data?.detail)
? error.response?.data?.message || error.response?.data?.detail
: t("toast.save.error.noMessage", { ns: "common" });
toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), {
position: "top-center",
});
}
}, [birdseyeCameras, updateConfig, t]);
if (formContext?.level && formContext.level !== "global") {
return null;
}
if (!config || birdseyeCameras.length < 2) {
return null;
}
return (
<SplitCardRow
label={t("birdseye.cameraOrder.label", { ns: "views/settings" })}
description={t("birdseye.cameraOrder.description", {
ns: "views/settings",
})}
content={
<div className="max-w-md space-y-1.5">
<Reorder.Group
as="div"
axis="y"
values={orderedCameras}
onReorder={setOrderedCameras}
className="space-y-2 rounded-lg bg-secondary p-4"
>
{orderedCameras.map((camera) => (
<BirdseyeCameraRow
key={camera}
camera={camera}
onDragEnd={handleDragEnd}
/>
))}
</Reorder.Group>
<SaveStatusIndicator status={saveStatus} />
</div>
}
/>
);
}
type SaveStatusIndicatorProps = {
status: SaveStatus;
};
function SaveStatusIndicator({ status }: SaveStatusIndicatorProps) {
const { t } = useTranslation(["views/settings"]);
return (
<div
aria-live="polite"
className={cn(
"flex h-4 items-center justify-start gap-1 text-xs transition-opacity duration-200",
status === "idle" ? "opacity-0" : "opacity-100",
)}
>
{status === "saving" && (
<span className="text-muted-foreground">
{t("birdseye.cameraOrder.saving")}
</span>
)}
{status === "saved" && (
<span className="flex items-center gap-1 text-success">
<LuCheck className="size-3.5" />
{t("birdseye.cameraOrder.saved")}
</span>
)}
</div>
);
}
type BirdseyeCameraRowProps = {
camera: string;
onDragEnd: () => void;
};
function BirdseyeCameraRow({ camera, onDragEnd }: BirdseyeCameraRowProps) {
const { t } = useTranslation(["views/settings"]);
const controls = useDragControls();
return (
<Reorder.Item
as="div"
value={camera}
dragListener={false}
dragControls={controls}
onDragEnd={onDragEnd}
className="flex flex-row items-center gap-1"
>
<button
type="button"
onPointerDown={(e) => controls.start(e)}
className="-ml-1 cursor-grab touch-none rounded p-1 text-muted-foreground hover:text-primary active:cursor-grabbing"
aria-label={t("birdseye.cameraOrder.reorderHandle")}
>
<LuGripVertical className="size-4" />
</button>
<CameraNameLabel camera={camera} />
</Reorder.Item>
);
}
@@ -10,7 +10,6 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Toaster } from "@/components/ui/sonner";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { FrigateConfig } from "@/types/frigateConfig";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -49,6 +48,8 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Trans, useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { isPWA } from "@/utils/isPWA";
import { isIOS } from "react-device-detect";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import CustomSuspensionDialog from "@/components/overlay/dialog/CustomSuspensionDialog";
import { useIsAdmin } from "@/hooks/use-is-admin";
@@ -58,6 +59,7 @@ import isEqual from "lodash/isEqual";
import set from "lodash/set";
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
import { sanitizeSectionData } from "@/utils/configUtil";
import { isReplayCamera } from "@/utils/cameraUtil";
import type { SectionRendererProps } from "./registry";
const NOTIFICATION_SERVICE_WORKER = "/notifications-worker.js";
@@ -95,7 +97,7 @@ export default function NotificationsSettingsExtras({
return Object.values(config.cameras)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order)
.filter((c) => c.enabled_in_config);
.filter((c) => c.enabled_in_config && !isReplayCamera(c.name));
}, [config]);
const notificationCameras = useMemo(() => {
@@ -107,6 +109,7 @@ export default function NotificationsSettingsExtras({
.filter(
(conf) =>
conf.enabled_in_config &&
!isReplayCamera(conf.name) &&
conf.notifications &&
conf.notifications.enabled_in_config,
)
@@ -360,6 +363,7 @@ export default function NotificationsSettingsExtras({
Object.values(config.cameras).some(
(c) =>
c.enabled_in_config &&
!isReplayCamera(c.name) &&
c.notifications &&
c.notifications.enabled_in_config,
),
@@ -436,6 +440,12 @@ export default function NotificationsSettingsExtras({
}
if (!("Notification" in window) || !window.isSecureContext) {
// iOS only exposes web push to apps installed to the Home Screen, so a
// secure-context iOS browser tab that isn't an installed PWA has no
// Notification API. Android supports web push in a normal tab, so it never
// reaches this case and keeps the generic secure-context message.
const requiresPwaInstall = isIOS && window.isSecureContext && !isPWA;
return (
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
<div className="w-full max-w-5xl">
@@ -464,12 +474,21 @@ export default function NotificationsSettingsExtras({
{t("notification.notificationUnavailable.title")}
</AlertTitle>
<AlertDescription>
<Trans ns="views/settings">
notification.notificationUnavailable.desc
</Trans>
<Trans
ns="views/settings"
i18nKey={
requiresPwaInstall
? "notification.notificationUnavailable.descPwa"
: "notification.notificationUnavailable.desc"
}
/>
<div className="mt-3 flex items-center">
<Link
to={getLocaleDocUrl("configuration/authentication")}
to={getLocaleDocUrl(
requiresPwaInstall
? "configuration/notifications"
: "configuration/authentication",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
@@ -489,7 +508,6 @@ export default function NotificationsSettingsExtras({
return (
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
<div className={cn("w-full max-w-5xl space-y-6")}>
{isAdmin && (
@@ -519,7 +537,7 @@ export default function NotificationsSettingsExtras({
<FormControl>
<Input
id="notification-email"
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
placeholder={t(
"notification.email.placeholder",
)}
@@ -793,7 +811,7 @@ export function CameraNotificationSwitch({
)}
<div className="flex flex-col">
<CameraNameLabel
className="text-md cursor-pointer text-primary smart-capitalize"
className="cursor-pointer text-primary smart-capitalize"
htmlFor="camera"
camera={camera}
/>
@@ -3,6 +3,7 @@ import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
import ProxyRoleMap from "./ProxyRoleMap";
import NotificationsSettingsExtras from "./NotificationsSettingsExtras";
import BirdseyeCameraReorder from "./BirdseyeCameraReorder";
import type { ConfigFormContext } from "@/types/configForm";
// Props that will be injected into all section renderers
@@ -52,6 +53,9 @@ export const sectionRenderers: SectionRenderers = {
notifications: {
NotificationsSettingsExtras,
},
birdseye: {
BirdseyeCameraReorder,
},
};
export default sectionRenderers;
@@ -32,7 +32,7 @@ import { ProfileOverridesBadge } from "./ProfileOverridesBadge";
import { useSectionSchema } from "@/hooks/use-config-schema";
import type { FrigateConfig } from "@/types/frigateConfig";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import Heading from "@/components/ui/heading";
import get from "lodash/get";
@@ -86,6 +86,8 @@ import type {
} from "../section-configs/types";
import { useConfigMessages } from "@/hooks/use-config-messages";
import { ConfigMessageBanner } from "../ConfigMessageBanner";
import { FieldMessagesContext } from "../FieldMessagesContext";
import { LiveFormDataContext } from "../LiveFormDataContext";
export interface SectionConfig {
/** Field ordering within the section */
@@ -175,6 +177,9 @@ export interface BaseSectionProps {
isSavingAll?: boolean;
/** Callback when this section's saving state changes */
onSavingChange?: (isSaving: boolean) => void;
/** When true, render the form fields only; suppress the internal save/undo bar.
* The parent owns the save action and reads pending data via `onPendingDataChange`. */
embedded?: boolean;
}
export interface CreateSectionOptions {
@@ -211,6 +216,7 @@ export function ConfigSection({
onDeleteProfileSection,
isSavingAll = false,
onSavingChange,
embedded = false,
}: ConfigSectionProps) {
// For replay level, treat as camera-level config access
const effectiveLevel = level === "replay" ? "camera" : level;
@@ -304,11 +310,30 @@ export function ConfigSection({
// Get section schema using cached hook
const sectionSchema = useSectionSchema(sectionPath, effectiveLevel);
// Apply special case handling for sections with problematic schema defaults
// Apply special case handling for sections with problematic schema defaults.
// The HiddenFieldContext is built from `config` (saved state) only — not the
// in-flight raw section value — because the schema is computed before
// rawFormData is derived. The objects-branch fallback in
// modifySchemaForSection reads `track` from fullCameraConfig / fullConfig.
const modifiedSchema = useMemo(
() =>
modifySchemaForSection(sectionPath, level, sectionSchema ?? undefined),
[sectionPath, level, sectionSchema],
modifySchemaForSection(
sectionPath,
level,
sectionSchema ?? undefined,
config
? {
fullConfig: config,
fullCameraConfig:
effectiveLevel === "camera" && cameraName
? config.cameras?.[cameraName]
: undefined,
level,
cameraName,
}
: undefined,
),
[sectionPath, level, sectionSchema, config, effectiveLevel, cameraName],
);
// Get override status (camera vs global)
@@ -380,7 +405,19 @@ export function ConfigSection({
// When editing a profile, hide fields that require a restart since they
// cannot take effect via profile switching alone.
const effectiveHiddenFields = useMemo(() => {
const base = resolveHiddenFieldEntries(sectionConfig.hiddenFields, config);
const ctx = config
? {
fullConfig: config,
fullCameraConfig:
effectiveLevel === "camera" && cameraName
? config.cameras?.[cameraName]
: undefined,
level,
cameraName,
formData: rawFormData,
}
: undefined;
const base = resolveHiddenFieldEntries(sectionConfig.hiddenFields, ctx);
if (!profileName || !sectionConfig.restartRequired?.length) {
return base;
}
@@ -390,6 +427,10 @@ export function ConfigSection({
sectionConfig.hiddenFields,
sectionConfig.restartRequired,
config,
effectiveLevel,
cameraName,
level,
rawFormData,
]);
const sanitizeSectionData = useCallback(
@@ -588,44 +629,6 @@ export function ConfigSection({
messageContext,
);
// Merge field-level conditional messages into uiSchema
const effectiveUiSchema = useMemo(() => {
if (activeFieldMessages.length === 0) return sectionConfig.uiSchema;
const merged = { ...(sectionConfig.uiSchema ?? {}) };
for (const msg of activeFieldMessages) {
const segments = msg.field.split(".");
// Navigate to the nested uiSchema node, shallow-cloning along the way
let node = merged;
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
node[seg] = { ...(node[seg] as Record<string, unknown>) };
node = node[seg] as Record<string, unknown>;
}
const leafKey = segments[segments.length - 1];
const existing = node[leafKey] as Record<string, unknown> | undefined;
const existingMessages = ((existing?.["ui:messages"] as unknown[]) ??
[]) as Array<{
key: string;
messageKey: string;
severity: string;
position?: string;
}>;
node[leafKey] = {
...existing,
"ui:messages": [
...existingMessages,
{
key: msg.key,
messageKey: msg.messageKey,
severity: msg.severity,
position: msg.position ?? "before",
},
],
};
}
return merged;
}, [sectionConfig.uiSchema, activeFieldMessages]);
const currentOverrides = useMemo(() => {
if (!currentFormData || typeof currentFormData !== "object") {
return undefined;
@@ -739,6 +742,7 @@ export function ConfigSection({
"Settings saved successfully. Restart Frigate to apply your changes.",
}),
{
duration: 10000,
action: (
<a onClick={() => setRestartDialogOpen(true)}>
<Button>
@@ -994,175 +998,183 @@ export function ConfigSection({
const sectionContent = (
<div className="space-y-6">
<ConfigMessageBanner messages={activeMessages} />
<ConfigForm
key={formKey}
schema={modifiedSchema}
formData={currentFormData}
onChange={handleChange}
onValidationChange={setHasValidationErrors}
fieldOrder={sectionConfig.fieldOrder}
fieldGroups={sectionConfig.fieldGroups}
hiddenFields={effectiveHiddenFields}
advancedFields={sectionConfig.advancedFields}
liveValidate={sectionConfig.liveValidate}
uiSchema={effectiveUiSchema}
disabled={disabled || isSaving}
readonly={readonly}
showSubmit={false}
i18nNamespace={configNamespace}
customValidate={customValidate}
formContext={{
level: effectiveLevel,
cameraName,
globalValue,
cameraValue,
hasChanges,
extraHasChanges,
setExtraHasChanges,
overrides: uiOverrides as JsonValue | undefined,
formData: currentFormData as ConfigSectionData,
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
pendingDataBySection,
onPendingDataChange,
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
// For widgets that need access to full camera config (e.g., zone names)
fullCameraConfig:
effectiveLevel === "camera" && cameraName
? config?.cameras?.[cameraName]
: undefined,
fullConfig: config,
// When rendering camera-level sections, provide the section path so
// field templates can look up keys under the `config/cameras` namespace
// When using a consolidated global namespace, keys are nested
// under the section name (e.g., `audio.label`) so provide the
// section prefix to templates so they can attempt `${section}.${field}` lookups.
sectionI18nPrefix: sectionPath,
t,
renderers: wrappedRenderers,
sectionDocs: sectionConfig.sectionDocs,
fieldDocs: sectionConfig.fieldDocs,
hiddenFields: effectiveHiddenFields,
restartRequired: sectionConfig.restartRequired,
requiresRestart,
isProfile: !!profileName,
}}
/>
<FieldMessagesContext.Provider value={activeFieldMessages}>
<LiveFormDataContext.Provider
value={(currentFormData as ConfigSectionData | null) ?? null}
>
<ConfigForm
key={formKey}
schema={modifiedSchema}
formData={currentFormData}
onChange={handleChange}
onValidationChange={setHasValidationErrors}
fieldOrder={sectionConfig.fieldOrder}
fieldGroups={sectionConfig.fieldGroups}
hiddenFields={effectiveHiddenFields}
advancedFields={sectionConfig.advancedFields}
liveValidate={sectionConfig.liveValidate}
uiSchema={sectionConfig.uiSchema}
disabled={disabled || isSaving}
readonly={readonly}
showSubmit={false}
i18nNamespace={configNamespace}
customValidate={customValidate}
formContext={{
level: effectiveLevel,
cameraName,
globalValue,
cameraValue,
hasChanges,
extraHasChanges,
setExtraHasChanges,
overrides: uiOverrides as JsonValue | undefined,
formData: currentFormData as ConfigSectionData,
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
pendingDataBySection,
onPendingDataChange,
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
// For widgets that need access to full camera config (e.g., zone names)
fullCameraConfig:
effectiveLevel === "camera" && cameraName
? config?.cameras?.[cameraName]
: undefined,
fullConfig: config,
// When rendering camera-level sections, provide the section path so
// field templates can look up keys under the `config/cameras` namespace
// When using a consolidated global namespace, keys are nested
// under the section name (e.g., `audio.label`) so provide the
// section prefix to templates so they can attempt `${section}.${field}` lookups.
sectionI18nPrefix: sectionPath,
t,
renderers: wrappedRenderers,
sectionDocs: sectionConfig.sectionDocs,
fieldDocs: sectionConfig.fieldDocs,
hiddenFields: effectiveHiddenFields,
restartRequired: sectionConfig.restartRequired,
requiresRestart,
isProfile: !!profileName,
}}
/>
</LiveFormDataContext.Provider>
</FieldMessagesContext.Provider>
<div
className={cn(
"w-full border-t border-secondary bg-background pt-0",
!noStickyButtons && "sticky bottom-0 z-50",
)}
>
{!embedded && (
<div
className={cn(
"flex flex-col items-center gap-4 pt-2 md:flex-row",
hasChanges ? "justify-between" : "justify-end",
"w-full border-t border-secondary bg-background pt-0",
!noStickyButtons && "sticky bottom-0 z-50",
)}
>
{hasChanges && (
<div className="flex items-center gap-2">
<span className="text-sm text-unsaved">
{t("unsavedChanges", {
ns: "views/settings",
defaultValue: "You have unsaved changes",
})}
</span>
<SaveAllPreviewPopover
items={sectionPreviewItems}
className="h-7 w-7"
align="start"
side="top"
/>
</div>
)}
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
{((effectiveLevel === "camera" && isOverridden) ||
effectiveLevel === "global") &&
!hasChanges &&
!skipSave &&
!profileName && (
<Button
onClick={() => setIsResetDialogOpen(true)}
variant="outline"
disabled={isSaving || isResettingToDefault || disabled}
className="flex flex-1 gap-2"
>
{isResettingToDefault && (
<ActivityIndicator className="h-4 w-4" />
)}
{effectiveLevel === "global"
? t("button.resetToDefault", {
ns: "common",
defaultValue: "Reset to Default",
})
: t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
</Button>
)}
{profileName &&
profileOverridesSection &&
!hasChanges &&
!skipSave &&
onDeleteProfileSection && (
<Button
onClick={() => setIsDeleteProfileDialogOpen(true)}
variant="outline"
disabled={isSaving || disabled}
className="flex flex-1 gap-2"
>
{t("profiles.removeOverride", {
ns: "views/settings",
defaultValue: "Remove Profile Override",
})}
</Button>
)}
<div
className={cn(
"flex flex-col items-center gap-4 pt-2 md:flex-row",
hasChanges ? "justify-between" : "justify-end",
)}
>
{hasChanges && (
<div className="flex items-center gap-2">
<span className="text-sm text-unsaved">
{t("unsavedChanges", {
ns: "views/settings",
defaultValue: "You have unsaved changes",
})}
</span>
<SaveAllPreviewPopover
items={sectionPreviewItems}
className="h-7 w-7"
align="start"
side="top"
/>
</div>
)}
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
{((effectiveLevel === "camera" && isOverridden) ||
effectiveLevel === "global") &&
!hasChanges &&
!skipSave &&
!profileName && (
<Button
onClick={() => setIsResetDialogOpen(true)}
variant="outline"
disabled={isSaving || isResettingToDefault || disabled}
className="flex flex-1 gap-2"
>
{isResettingToDefault && (
<ActivityIndicator className="h-4 w-4" />
)}
{effectiveLevel === "global"
? t("button.resetToDefault", {
ns: "common",
defaultValue: "Reset to Default",
})
: t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
</Button>
)}
{profileName &&
profileOverridesSection &&
!hasChanges &&
!skipSave &&
onDeleteProfileSection && (
<Button
onClick={() => setIsDeleteProfileDialogOpen(true)}
variant="outline"
disabled={isSaving || disabled}
className="flex flex-1 gap-2"
>
{t("profiles.removeOverride", {
ns: "views/settings",
defaultValue: "Remove Profile Override",
})}
</Button>
)}
{hasChanges && (
<Button
onClick={handleReset}
variant="outline"
disabled={isSaving || isSavingAll || disabled}
className="flex min-w-36 flex-1 gap-2"
>
{t("button.undo", { ns: "common", defaultValue: "Undo" })}
</Button>
)}
<Button
onClick={handleReset}
variant="outline"
disabled={isSaving || isSavingAll || disabled}
onClick={handleSave}
variant="select"
disabled={
!hasChanges ||
hasValidationErrors ||
isSaving ||
isSavingAll ||
disabled
}
className="flex min-w-36 flex-1 gap-2"
>
{t("button.undo", { ns: "common", defaultValue: "Undo" })}
{isSaving ? (
<>
<ActivityIndicator className="h-4 w-4" />
{skipSave
? t("button.applying", {
ns: "common",
defaultValue: "Applying...",
})
: t("button.saving", {
ns: "common",
defaultValue: "Saving...",
})}
</>
) : skipSave ? (
t("button.apply", { ns: "common", defaultValue: "Apply" })
) : (
t("button.save", { ns: "common", defaultValue: "Save" })
)}
</Button>
)}
<Button
onClick={handleSave}
variant="select"
disabled={
!hasChanges ||
hasValidationErrors ||
isSaving ||
isSavingAll ||
disabled
}
className="flex min-w-36 flex-1 gap-2"
>
{isSaving ? (
<>
<ActivityIndicator className="h-4 w-4" />
{skipSave
? t("button.applying", {
ns: "common",
defaultValue: "Applying...",
})
: t("button.saving", {
ns: "common",
defaultValue: "Saving...",
})}
</>
) : skipSave ? (
t("button.apply", { ns: "common", defaultValue: "Apply" })
) : (
t("button.save", { ns: "common", defaultValue: "Save" })
)}
</Button>
</div>
</div>
</div>
</div>
)}
<AlertDialog open={isResetDialogOpen} onOpenChange={setIsResetDialogOpen}>
<AlertDialogContent>
@@ -1224,7 +1236,7 @@ export function ConfigSection({
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => {
onDeleteProfileSection?.();
setIsDeleteProfileDialogOpen(false);
@@ -1246,12 +1258,12 @@ export function ConfigSection({
<CollapsibleTrigger asChild>
<div className="flex cursor-pointer items-center justify-between">
<div className="flex items-center gap-3">
{isOpen ? (
<LuChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<LuChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<Heading as="h4">{title}</Heading>
<Heading
as="h4"
className={level === "replay" ? "text-base" : undefined}
>
{title}
</Heading>
{showOverrideIndicator &&
effectiveLevel === "camera" &&
(profileOverridesSection || isOverridden) &&
@@ -1281,12 +1293,17 @@ export function ConfigSection({
})}
</Badge>
)}
{isOpen ? (
<LuChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<LuChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pl-7">{sectionContent}</div>
<div className="pl-0">{sectionContent}</div>
</CollapsibleContent>
</div>
</Collapsible>
@@ -20,6 +20,7 @@ import type { ProfilesApiResponse } from "@/types/profile";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { formatList } from "@/utils/stringUtil";
import {
buildHiddenFieldContext,
getEffectiveHiddenFields,
pathMatchesHiddenPattern,
} from "@/utils/configUtil";
@@ -187,7 +188,7 @@ export function CameraOverridesBadge({ sectionPath, className }: Props) {
const hiddenFields = getEffectiveHiddenFields(
sectionPath,
"global",
config,
buildHiddenFieldContext(config, "global"),
);
if (hiddenFields.length === 0) return rawEntries;
return rawEntries
@@ -9,7 +9,8 @@
import { RJSFSchema } from "@rjsf/utils";
import { applySchemaDefaults } from "@/lib/config-schema";
import { isJsonObject } from "@/lib/utils";
import { JsonObject, JsonValue } from "@/types/configForm";
import { HiddenFieldContext, JsonObject, JsonValue } from "@/types/configForm";
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
/**
* Sections that require special handling at the global level.
@@ -37,13 +38,28 @@ export function isSpecialCaseSection(
*
* - detectors: Strip the "default" field to prevent RJSF from merging the
* default {"cpu": {"type": "cpu"}} with stored detector keys.
* - genai: Inject a default provider value on the additionalProperties shape.
* - objects: Promote tracked attribute labels (face, license_plate, courier
* logos) from `filters.additionalProperties` to explicit
* `filters.properties.<attr>` entries with a restricted FilterConfig
* shape, so RJSF renders just that one field for
* attribute filters. Non-attribute tracked labels (person, car, …)
* keep flowing through the unmodified `additionalProperties` and render
* the full FilterConfig form.
*/
export function modifySchemaForSection(
sectionPath: string,
level: string,
schema: RJSFSchema | undefined,
ctx?: HiddenFieldContext,
): RJSFSchema | undefined {
if (!schema || !isSpecialCaseSection(sectionPath, level)) {
if (!schema) return schema;
if (sectionPath === "objects") {
return modifyObjectsSchema(schema, ctx);
}
if (!isSpecialCaseSection(sectionPath, level)) {
return schema;
}
@@ -79,6 +95,175 @@ export function modifySchemaForSection(
return schema;
}
/**
* Build a stripped FilterConfig schema for tracked attribute filters
* (face, license_plate, etc.). Keeps only the fields meaningful for
* attribute detections — `min_score`, `min_area`, `max_area`. `threshold`
* and the ratio fields aren't exposed: attributes don't flow through
* `_is_false_positive` (no median-of-history check), and aspect-ratio
* filtering isn't a typical attribute-tuning knob.
*
* `min_area` and `max_area` are `Union[int, float]` in Pydantic which
* emits as `anyOf` in JSON schema; we flatten to a plain `number` so RJSF
* doesn't render the int/float type-selector dropdown for each attribute
* filter. The backend still accepts either int (pixels) or float
* (percentage) since the underlying FilterConfig union is unchanged.
*/
function buildAttributeFilterSchema(
filterConfigSchema: RJSFSchema,
attributeLabel: string,
): RJSFSchema {
const props = isJsonObject(
(filterConfigSchema as { properties?: unknown }).properties,
)
? (filterConfigSchema as { properties: Record<string, RJSFSchema> })
.properties
: undefined;
const minScoreSchema =
props && props.min_score ? props.min_score : { type: "number" };
const flattenToNumber = (src: RJSFSchema | undefined): RJSFSchema => {
if (!src) return { type: "number" };
const { anyOf: _anyOf, ...rest } = src as {
anyOf?: unknown;
[k: string]: unknown;
};
return { ...rest, type: "number" } as RJSFSchema;
};
return {
type: "object",
title: attributeLabel,
properties: {
min_score: minScoreSchema,
min_area: flattenToNumber(props && props.min_area),
max_area: flattenToNumber(props && props.max_area),
},
additionalProperties: false,
} as RJSFSchema;
}
function modifyObjectsSchema(
schema: RJSFSchema,
ctx: HiddenFieldContext | undefined,
): RJSFSchema {
if (!ctx) return schema;
const allAttributes = getEffectiveAttributeLabels(
ctx.fullConfig,
ctx.fullCameraConfig,
ctx.level,
);
// Resolve effective track at this scope, falling back through camera
// config then global config (matches hideAttributeFilters in objects.ts).
const trackFromForm = Array.isArray(
(ctx.formData as { track?: unknown } | undefined)?.track,
)
? (ctx.formData as { track: string[] }).track
: undefined;
const track =
trackFromForm ??
(ctx.level !== "global"
? ctx.fullCameraConfig?.objects?.track
: undefined) ??
ctx.fullConfig.objects?.track ??
[];
// Also promote any label that has a saved filter entry but isn't in
// `track` (e.g. the user toggled an object off but left a customized
// filter in YAML). Without this, RJSF falls back to the additional-
// properties Key/Value editor for those orphans.
const filtersSaved =
(ctx.level !== "global"
? ctx.fullCameraConfig?.objects?.filters
: undefined) ??
ctx.fullConfig.objects?.filters ??
{};
if (track.length === 0 && Object.keys(filtersSaved).length === 0) {
return schema;
}
const schemaProperties = isJsonObject(
(schema as { properties?: unknown }).properties,
)
? (schema as { properties: Record<string, RJSFSchema> }).properties
: undefined;
const filtersSchema =
schemaProperties && schemaProperties.filters
? schemaProperties.filters
: undefined;
if (!filtersSchema) return schema;
const filterEntrySchema = isJsonObject(
(filtersSchema as { additionalProperties?: unknown }).additionalProperties,
)
? (filtersSchema as { additionalProperties: RJSFSchema })
.additionalProperties
: undefined;
if (!filterEntrySchema) return schema;
const attributeSet = new Set(allAttributes);
const existingProperties = isJsonObject(
(filtersSchema as { properties?: unknown }).properties,
)
? (filtersSchema as { properties: Record<string, RJSFSchema> }).properties
: {};
// Promote every tracked label (and any orphaned filter entry) to an
// explicit property entry so RJSF renders it as a normal collapsible
// (no additionalProperties key/value editor UI). Attribute labels get a
// restricted shape with only `min_score`/`min_area`/`max_area`;
// non-attribute labels get the full FilterConfig. Sorted alphabetically
// so the filter collapsibles match the order of the sibling `track`
// switches.
const labelsToPromote = new Set<string>();
for (const label of track) {
if (typeof label === "string") labelsToPromote.add(label);
}
for (const key of Object.keys(filtersSaved)) {
// Skip attribute labels that aren't tracked — those are hidden
// entirely via hideAttributeFilters; promoting them would surface a
// collapsible we then have to hide separately.
if (attributeSet.has(key) && !labelsToPromote.has(key)) continue;
labelsToPromote.add(key);
}
const sortedTrackedLabels = [...labelsToPromote].sort((a, b) =>
a.localeCompare(b),
);
const updatedFilterProperties: Record<string, RJSFSchema> = {
...existingProperties,
};
for (const label of sortedTrackedLabels) {
if (attributeSet.has(label)) {
updatedFilterProperties[label] = buildAttributeFilterSchema(
filterEntrySchema,
label,
);
} else {
updatedFilterProperties[label] = {
...filterEntrySchema,
title: label,
} as RJSFSchema;
}
}
const updatedFiltersSchema: RJSFSchema = {
...filtersSchema,
properties: updatedFilterProperties,
};
return {
...schema,
properties: {
...schemaProperties,
filters: updatedFiltersSchema,
},
};
}
/**
* Get effective defaults for sections with special schema patterns.
*
@@ -0,0 +1,346 @@
import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
import { LuPlus, LuTrash2 } from "react-icons/lu";
import type { ConfigFormContext } from "@/types/configForm";
import get from "lodash/get";
import { isSubtreeModified } from "../utils";
type LiveStreamsData = Record<string, string>;
type StreamValueComboboxProps = {
id: string;
value: string;
options: string[];
disabled?: boolean;
readonly?: boolean;
onChange: (next: string) => void;
};
function StreamValueCombobox({
id,
value,
options,
disabled,
readonly,
onChange,
}: StreamValueComboboxProps) {
const { t } = useTranslation(["views/settings", "common"]);
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const trimmedSearch = searchValue.trim();
const matchesOption = useMemo(
() => options.some((o) => o.toLowerCase() === trimmedSearch.toLowerCase()),
[options, trimmedSearch],
);
const showCustomOption = trimmedSearch.length > 0 && !matchesOption;
const commit = (next: string) => {
onChange(next);
setSearchValue("");
setOpen(false);
};
const placeholder = t("configForm.liveStreams.go2rtcStreamPlaceholder", {
ns: "views/settings",
});
const searchPlaceholder = t("configForm.liveStreams.go2rtcStreamSearch", {
ns: "views/settings",
});
const noStreams = t("configForm.liveStreams.noGo2rtcStreams", {
ns: "views/settings",
});
const availableHeading = t("configForm.liveStreams.availableStreams", {
ns: "views/settings",
});
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearchValue("");
}}
>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || readonly}
className={cn(
"w-full justify-between font-normal",
!value && "text-muted-foreground",
)}
>
<span className="truncate">{value || placeholder}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput
placeholder={searchPlaceholder}
value={searchValue}
onValueChange={setSearchValue}
onKeyDown={(e) => {
if (e.key === "Enter" && showCustomOption) {
e.preventDefault();
commit(trimmedSearch);
}
}}
/>
<CommandList>
{showCustomOption && (
<CommandGroup>
<CommandItem
value={trimmedSearch}
onSelect={() => commit(trimmedSearch)}
>
<Plus className="mr-2 h-4 w-4" />
{t("configForm.liveStreams.useCustom", {
ns: "views/settings",
value: trimmedSearch,
})}
</CommandItem>
</CommandGroup>
)}
{options.length > 0 ? (
<CommandGroup heading={availableHeading}>
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={() => commit(option)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option ? "opacity-100" : "opacity-0",
)}
/>
{option}
</CommandItem>
))}
</CommandGroup>
) : !showCustomOption ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{noStreams}
</div>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function LiveStreamsField(props: FieldProps) {
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
const configNamespace =
formContext?.i18nNamespace ??
(formContext?.level === "camera" ? "config/cameras" : "config/global");
const { t: fallbackT } = useTranslation(["common", configNamespace]);
const t = formContext?.t ?? fallbackT;
const data: LiveStreamsData = useMemo(() => {
if (!formData || typeof formData !== "object" || Array.isArray(formData)) {
return {};
}
return formData as LiveStreamsData;
}, [formData]);
const entries = useMemo(() => Object.entries(data), [data]);
const id = idSchema?.$id ?? props.name;
const sectionPrefix = formContext?.sectionI18nPrefix;
const title =
t(`${sectionPrefix}.${id}.label`) ?? (schema as RJSFSchema).title;
const description =
t(`${sectionPrefix}.${id}.description`) ??
(schema as RJSFSchema).description;
const go2rtcStreamNames = useMemo<string[]>(() => {
const streams = formContext?.fullConfig?.go2rtc?.streams;
if (!streams || typeof streams !== "object") return [];
return Object.keys(streams).sort();
}, [formContext?.fullConfig?.go2rtc?.streams]);
const emptyPath = useMemo(() => [] as FieldPathList, []);
const fieldPath =
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
emptyPath;
const isModified = useMemo(() => {
const baselineRoot = formContext?.baselineFormData;
const baselineValue = baselineRoot
? get(baselineRoot, fieldPath)
: undefined;
return isSubtreeModified(
data,
baselineValue,
formContext?.overrides,
fieldPath,
formContext?.formData,
);
}, [fieldPath, formContext, data]);
const handleAddEntry = useCallback(() => {
const next = { ...data, "": "" };
onChange(next, fieldPath);
}, [data, fieldPath, onChange]);
const handleRemoveEntry = useCallback(
(key: string) => {
const next = { ...data };
delete next[key];
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const handleRenameKey = useCallback(
(oldKey: string, newKey: string) => {
if (oldKey === newKey) return;
const next: LiveStreamsData = {};
for (const [k, v] of Object.entries(data)) {
if (k === oldKey) {
next[newKey] = v;
} else {
next[k] = v;
}
}
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const handleUpdateValue = useCallback(
(key: string, value: string) => {
const next = { ...data, [key]: value };
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const baseId = idSchema?.$id || "live_streams";
const deleteLabel = t("button.delete", {
ns: "common",
defaultValue: "Delete",
});
const streamNameLabel = t("configForm.liveStreams.streamNameLabel", {
ns: "views/settings",
});
const streamNamePlaceholder = t(
"configForm.liveStreams.streamNamePlaceholder",
{ ns: "views/settings" },
);
const go2rtcStreamLabel = t("configForm.liveStreams.go2rtcStreamLabel", {
ns: "views/settings",
});
const addStreamLabel = t("configForm.liveStreams.addStream", {
ns: "views/settings",
});
return (
<Card className="w-full">
<CardHeader className="p-4">
<CardTitle className={cn("text-sm", isModified && "text-unsaved")}>
{title}
</CardTitle>
{description && (
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
)}
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
{entries.map(([key, value], entryIndex) => {
const entryId = `${baseId}-${entryIndex}`;
return (
<div
key={entryIndex}
className="grid grid-cols-12 items-end gap-2 rounded-md border p-3"
>
<div className="col-span-12 space-y-2 md:col-span-5">
<Label htmlFor={`${entryId}-key`}>{streamNameLabel}</Label>
<Input
id={`${entryId}-key`}
defaultValue={key}
placeholder={streamNamePlaceholder}
disabled={disabled || readonly}
onBlur={(e) => handleRenameKey(key, e.target.value)}
/>
</div>
<div className="col-span-10 space-y-2 md:col-span-6">
<Label htmlFor={`${entryId}-value`}>{go2rtcStreamLabel}</Label>
<StreamValueCombobox
id={`${entryId}-value`}
value={value}
options={go2rtcStreamNames}
disabled={disabled}
readonly={readonly}
onChange={(next) => handleUpdateValue(key, next)}
/>
</div>
<div className="col-span-2 flex justify-end md:col-span-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveEntry(key)}
disabled={disabled || readonly}
aria-label={deleteLabel}
title={deleteLabel}
className="shrink-0"
>
<LuTrash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
<div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddEntry}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{addStreamLabel}
</Button>
</div>
</CardContent>
</Card>
);
}
export default LiveStreamsField;
@@ -2,3 +2,4 @@
export { LayoutGridField } from "./LayoutGridField";
export { DetectorHardwareField } from "./DetectorHardwareField";
export { ReplaceRulesField } from "./ReplaceRulesField";
export { LiveStreamsField } from "./LiveStreamsField";
@@ -31,7 +31,10 @@ import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
import { CameraPathWidget } from "./widgets/CameraPathWidget";
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget";
import { SemanticSearchModelSizeWidget } from "./widgets/SemanticSearchModelSizeWidget";
import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget";
import { PTZPresetsWidget } from "./widgets/PTZPresetsWidget";
import { DefaultRoleWidget } from "./widgets/DefaultRoleWidget";
import { FieldTemplate } from "./templates/FieldTemplate";
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
@@ -50,6 +53,7 @@ import { ReplaceRulesField } from "./fields/ReplaceRulesField";
import { CameraInputsField } from "./fields/CameraInputsField";
import { DictAsYamlField } from "./fields/DictAsYamlField";
import { KnownPlatesField } from "./fields/KnownPlatesField";
import { LiveStreamsField } from "./fields/LiveStreamsField";
export interface FrigateTheme {
widgets: RegistryWidgetsType;
@@ -86,7 +90,10 @@ export const frigateTheme: FrigateTheme = {
timezoneSelect: TimezoneSelectWidget,
optionalField: OptionalFieldWidget,
semanticSearchModel: SemanticSearchModelWidget,
semanticSearchModelSize: SemanticSearchModelSizeWidget,
onvifProfile: OnvifProfileWidget,
ptzPresets: PTZPresetsWidget,
defaultRole: DefaultRoleWidget,
},
templates: {
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
@@ -107,5 +114,6 @@ export const frigateTheme: FrigateTheme = {
CameraInputsField: CameraInputsField,
DictAsYamlField: DictAsYamlField,
KnownPlatesField: KnownPlatesField,
LiveStreamsField: LiveStreamsField,
},
};
@@ -5,8 +5,9 @@ import {
getUiOptions,
ADDITIONAL_PROPERTY_FLAG,
} from "@rjsf/utils";
import { ComponentType, ReactNode } from "react";
import { ComponentType, ReactNode, useContext } from "react";
import { isValidElement } from "react";
import { FieldMessagesContext } from "../../FieldMessagesContext";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
@@ -95,6 +96,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
"views/settings",
]);
const { getLocaleDocUrl } = useDocDomain();
const allFieldMessages = useContext(FieldMessagesContext);
if (hidden) {
return <div className="hidden">{children}</div>;
@@ -384,21 +386,15 @@ export function FieldTemplate(props: FieldTemplateProps) {
const beforeContent = renderCustom(beforeSpec);
const afterContent = renderCustom(afterSpec);
// Render conditional field messages from ui:messages
const fieldMessageSpecs = uiSchema?.["ui:messages"] as
| Array<{
key: string;
messageKey: string;
severity: string;
position?: string;
}>
| undefined;
const beforeMessages = fieldMessageSpecs?.filter(
// Read field-level conditional messages from FieldMessagesContext
const fieldPathStr = pathSegments.join(".");
const fieldMessageSpecs = allFieldMessages.filter(
(m) => m.field === fieldPathStr,
);
const beforeMessages = fieldMessageSpecs.filter(
(m) => (m.position ?? "before") === "before",
);
const afterMessages = fieldMessageSpecs?.filter(
(m) => m.position === "after",
);
const afterMessages = fieldMessageSpecs.filter((m) => m.position === "after");
const beforeMessagesContent =
beforeMessages && beforeMessages.length > 0 ? (
<div className="space-y-2">
@@ -371,7 +371,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
key={group.groupKey}
className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4"
>
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
<div className="border-b border-border/60 pb-4 font-semibold text-primary-variant">
{group.label}
</div>
<div className="space-y-6">
@@ -6,6 +6,7 @@
*/
import type { ConfigFormContext } from "@/types/configForm";
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
@@ -70,12 +71,27 @@ export function buildTranslationPath(
(segment): segment is string => typeof segment === "string",
);
// Handle filters section - skip the dynamic filter object name
// Example: filters.person.threshold -> filters.threshold
// Handle filters section - skip the dynamic filter object name. Route
// to `filters_attribute.<field>` when the dynamic key is an attribute
// label (face, license_plate, courier logos) so attribute filter fields
// pick up the attribute-worded translations emitted by
// generate_config_translations.py.
// Example: filters.person.threshold -> filters.threshold
// Example: filters.face.min_area -> filters_attribute.min_area
const filtersIndex = stringSegments.indexOf("filters");
if (filtersIndex !== -1 && stringSegments.length > filtersIndex + 2) {
const filterKey = stringSegments[filtersIndex + 1];
const allAttributes = getEffectiveAttributeLabels(
formContext?.fullConfig,
formContext?.fullCameraConfig,
formContext?.level,
);
const sectionWord = allAttributes.includes(filterKey)
? "filters_attribute"
: "filters";
const normalized = [
...stringSegments.slice(0, filtersIndex + 1),
...stringSegments.slice(0, filtersIndex),
sectionWord,
...stringSegments.slice(filtersIndex + 2),
];
return normalized.join(".");
@@ -79,7 +79,7 @@ export function ArrayAsTextWidget(props: WidgetProps) {
return (
<Textarea
id={id}
className={cn("text-md", fieldClassName)}
className={cn(fieldClassName)}
value={text}
disabled={disabled || readonly}
rows={(options.rows as number) || 3}
@@ -124,7 +124,7 @@ export function CameraPathWidget(props: WidgetProps) {
<div className={cn("relative", fieldClassName)}>
<Input
id={id}
className={cn("text-md", canToggle ? "pr-10" : undefined)}
className={cn(canToggle ? "pr-10" : undefined)}
type="text"
value={displayValue}
disabled={disabled || readonly}
@@ -0,0 +1,56 @@
import { useMemo } from "react";
import type { WidgetProps } from "@rjsf/utils";
import { useTranslation } from "react-i18next";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ConfigFormContext } from "@/types/configForm";
import { getSizedFieldClassName } from "../utils";
const BUILT_IN_ROLES = ["admin", "viewer"];
export function DefaultRoleWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, schema, options, registry } =
props;
const { t } = useTranslation(["views/settings"]);
const fieldClassName = getSizedFieldClassName(options, "sm");
const formContext = registry?.formContext as ConfigFormContext | undefined;
const roles = useMemo<string[]>(() => {
const configured = Object.keys(formContext?.fullConfig?.auth?.roles ?? {});
// Keep admin/viewer first, then any custom roles in config order.
const custom = configured.filter((r) => !BUILT_IN_ROLES.includes(r));
return [...BUILT_IN_ROLES, ...custom];
}, [formContext]);
const selectedValue = typeof value === "string" && value ? value : "viewer";
const getLabel = (role: string) =>
BUILT_IN_ROLES.includes(role) ? t(`configForm.defaultRole.${role}`) : role;
return (
<Select
value={selectedValue}
onValueChange={onChange}
disabled={disabled || readonly}
>
<SelectTrigger id={id} className={fieldClassName}>
<SelectValue placeholder={schema.title} />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role} value={role}>
{getLabel(role)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default DefaultRoleWidget;
@@ -4,8 +4,11 @@ import { useState, useMemo, useEffect, useRef } from "react";
import type { WidgetProps } from "@rjsf/utils";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { Check, ChevronsUpDown } from "lucide-react";
import axios from "axios";
import { Check, ChevronsUpDown, Plus, RefreshCw } from "lucide-react";
import { LuCheck } from "react-icons/lu";
import { cn } from "@/lib/utils";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -19,9 +22,18 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { ConfigFormContext } from "@/types/configForm";
import type { ConfigFormContext, JsonObject } from "@/types/configForm";
import type { GenAIModelsResponse } from "@/types/chat";
import { getSizedFieldClassName } from "../utils";
type ProbeResponse =
| { success: true; models: string[] }
| { success: false; message: string };
type ProbeStatus = "idle" | "probing" | "success" | "error";
const PROBE_SUCCESS_INDICATOR_MS = 3000;
/**
* Extract the provider config entry name from the RJSF widget id.
* Widget ids look like "root_myProvider_model".
@@ -41,6 +53,7 @@ export function GenAIModelWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, options, registry } = props;
const { t } = useTranslation(["views/settings"]);
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const fieldClassName = getSizedFieldClassName(options, "sm");
const providerKey = useMemo(() => getProviderKey(id), [id]);
@@ -61,11 +74,12 @@ export function GenAIModelWidget(props: WidgetProps) {
return `${e.provider ?? ""}|${e.base_url ?? ""}`;
}, [providerKey, formContext?.fullConfig]);
const { data: allModels, mutate: mutateModels } = useSWR<
Record<string, string[]>
>("genai/models", {
revalidateOnFocus: false,
});
const { data: allModels, mutate: mutateModels } = useSWR<GenAIModelsResponse>(
"genai/models",
{
revalidateOnFocus: false,
},
);
// Revalidate models when the saved config fingerprint changes (e.g. after
// switching provider or base_url and saving).
@@ -77,78 +91,261 @@ export function GenAIModelWidget(props: WidgetProps) {
}
}, [configFingerprint, mutateModels]);
const models = useMemo(() => {
const fetchedModels = useMemo<string[]>(() => {
if (!allModels || !providerKey) return [];
return allModels[providerKey] ?? [];
return allModels[providerKey]?.models ?? [];
}, [allModels, providerKey]);
const [probeStatus, setProbeStatus] = useState<ProbeStatus>("idle");
const [probeError, setProbeError] = useState<string | null>(null);
const [probedModels, setProbedModels] = useState<string[] | null>(null);
const probeSuccessTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const probing = probeStatus === "probing";
// Reset probe results if the provider entry name changes
useEffect(() => {
setProbedModels(null);
setProbeError(null);
setProbeStatus("idle");
if (probeSuccessTimerRef.current) {
clearTimeout(probeSuccessTimerRef.current);
probeSuccessTimerRef.current = null;
}
}, [providerKey]);
useEffect(() => {
return () => {
if (probeSuccessTimerRef.current) {
clearTimeout(probeSuccessTimerRef.current);
}
};
}, []);
const models = probedModels ?? fetchedModels;
const trimmedSearch = searchValue.trim();
const matchesFetched = useMemo(
() => models.some((m) => m.toLowerCase() === trimmedSearch.toLowerCase()),
[models, trimmedSearch],
);
const showCustomOption = trimmedSearch.length > 0 && !matchesFetched;
// Read the live form values for this provider so probe sends the user's
// in-flight edits, not the saved config (which may not exist yet).
const formEntry = useMemo<JsonObject | null>(() => {
if (!providerKey) return null;
const formData = formContext?.formData as JsonObject | undefined;
const entry = formData?.[providerKey];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return null;
}
return entry as JsonObject;
}, [providerKey, formContext?.formData]);
const formProvider =
typeof formEntry?.provider === "string" ? formEntry.provider : null;
const canProbe = Boolean(formProvider) && !probing;
const probe = async () => {
if (!formEntry || !formProvider) return;
if (probeSuccessTimerRef.current) {
clearTimeout(probeSuccessTimerRef.current);
probeSuccessTimerRef.current = null;
}
setProbeStatus("probing");
setProbeError(null);
try {
const res = await axios.post<ProbeResponse>("genai/probe", {
provider: formProvider,
api_key:
typeof formEntry.api_key === "string" ? formEntry.api_key : null,
base_url:
typeof formEntry.base_url === "string" ? formEntry.base_url : null,
provider_options:
formEntry.provider_options &&
typeof formEntry.provider_options === "object" &&
!Array.isArray(formEntry.provider_options)
? (formEntry.provider_options as JsonObject)
: {},
});
if (res.data.success) {
setProbedModels(res.data.models);
setProbeStatus("success");
probeSuccessTimerRef.current = setTimeout(() => {
setProbeStatus("idle");
probeSuccessTimerRef.current = null;
}, PROBE_SUCCESS_INDICATOR_MS);
} else {
setProbedModels([]);
setProbeError(res.data.message);
setProbeStatus("error");
}
} catch {
setProbedModels(null);
setProbeError(
t("configForm.genaiModel.probeFailed", {
ns: "views/settings",
defaultValue: "Failed to probe models",
}),
);
setProbeStatus("error");
}
};
const commit = (next: string) => {
onChange(next);
setSearchValue("");
setOpen(false);
};
const currentLabel = typeof value === "string" && value ? value : undefined;
const refreshLabel = t("configForm.genaiModel.refresh", {
ns: "views/settings",
defaultValue: "Refresh models",
});
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || readonly}
className={cn(
"justify-between font-normal",
!currentLabel && "text-muted-foreground",
fieldClassName,
)}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearchValue("");
}}
>
{currentLabel ??
t("configForm.genaiModel.placeholder", {
ns: "views/settings",
defaultValue: "Select model…",
})}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput
placeholder={t("configForm.genaiModel.search", {
ns: "views/settings",
defaultValue: "Search models…",
})}
/>
<CommandList>
{models.length > 0 ? (
<CommandGroup>
{models.map((model) => (
<CommandItem
key={model}
value={model}
onSelect={() => {
onChange(model);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === model ? "opacity-100" : "opacity-0",
)}
/>
{model}
</CommandItem>
))}
</CommandGroup>
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("configForm.genaiModel.noModels", {
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || readonly}
className={cn(
"justify-between font-normal",
!currentLabel && "text-muted-foreground",
fieldClassName,
)}
>
{currentLabel ??
t("configForm.genaiModel.placeholder", {
ns: "views/settings",
defaultValue: "No models available",
defaultValue: "Select or enter a model…",
})}
</div>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput
placeholder={t("configForm.genaiModel.search", {
ns: "views/settings",
defaultValue: "Search or enter a model…",
})}
value={searchValue}
onValueChange={setSearchValue}
onKeyDown={(e) => {
if (e.key === "Enter" && showCustomOption) {
e.preventDefault();
commit(trimmedSearch);
}
}}
/>
<CommandList>
{showCustomOption && (
<CommandGroup>
<CommandItem
value={trimmedSearch}
onSelect={() => commit(trimmedSearch)}
>
<Plus className="mr-2 h-4 w-4" />
{t("configForm.genaiModel.useCustom", {
ns: "views/settings",
value: trimmedSearch,
defaultValue: 'Use "{{value}}"',
})}
</CommandItem>
</CommandGroup>
)}
{models.length > 0 ? (
<CommandGroup
heading={t("configForm.genaiModel.available", {
ns: "views/settings",
defaultValue: "Available models",
})}
>
{models.map((model) => (
<CommandItem
key={model}
value={model}
onSelect={() => commit(model)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === model ? "opacity-100" : "opacity-0",
)}
/>
{model}
</CommandItem>
))}
</CommandGroup>
) : !showCustomOption ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("configForm.genaiModel.noModels", {
ns: "views/settings",
defaultValue: "No models available",
})}
</div>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
disabled={!canProbe || disabled || readonly}
onClick={probe}
title={refreshLabel}
aria-label={refreshLabel}
>
{probing ? (
<ActivityIndicator className="h-4 w-4" size={16} />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
<div
aria-live="polite"
className={cn(
"flex items-center justify-start gap-1 text-xs transition-opacity duration-200",
probeStatus === "idle" || probeStatus === "probing"
? "opacity-0"
: "opacity-100",
)}
>
{probeStatus === "success" && (
<span className="flex items-center gap-1 text-success">
<LuCheck className="size-3.5" />
{t("configForm.genaiModel.fetchedModels", {
ns: "views/settings",
defaultValue: "Successfully fetched model list",
})}
</span>
)}
{probeStatus === "error" && probeError && (
<span className="text-destructive">{probeError}</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,151 @@
// Combobox widget for ONVIF PTZ preset fields (e.g. autotracking.return_preset).
// Fetches the camera's PTZ presets and shows them in a dropdown, while still
// allowing a typed custom value so existing presets that the camera does not
// report (such as "home") are preserved.
import { useState, useMemo } from "react";
import type { WidgetProps } from "@rjsf/utils";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { ConfigFormContext } from "@/types/configForm";
import type { CameraPtzInfo } from "@/types/ptz";
import { getSizedFieldClassName } from "../utils";
export function PTZPresetsWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, options, registry } = props;
const { t } = useTranslation(["views/settings"]);
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const fieldClassName = getSizedFieldClassName(options, "md");
const formContext = registry?.formContext as ConfigFormContext | undefined;
const cameraName = formContext?.cameraName;
const isCameraLevel = formContext?.level === "camera";
const hasOnvifHost = !!formContext?.fullCameraConfig?.onvif?.host;
const { data: ptzInfo } = useSWR<CameraPtzInfo>(
isCameraLevel && cameraName && hasOnvifHost
? `${cameraName}/ptz/info`
: null,
{
// ONVIF may not be initialized yet when the settings page loads,
// so retry until presets become available
refreshInterval: (data) =>
data?.presets && data.presets.length > 0 ? 0 : 5000,
},
);
const presets = useMemo<string[]>(() => ptzInfo?.presets ?? [], [ptzInfo]);
const trimmedSearch = searchValue.trim();
const matchesPreset = useMemo(
() => presets.some((p) => p.toLowerCase() === trimmedSearch.toLowerCase()),
[presets, trimmedSearch],
);
const showCustomOption = trimmedSearch.length > 0 && !matchesPreset;
const commit = (next: string) => {
onChange(next);
setSearchValue("");
setOpen(false);
};
const currentLabel = typeof value === "string" && value ? value : undefined;
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearchValue("");
}}
>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || readonly}
className={cn(
"justify-between font-normal",
!currentLabel && "text-muted-foreground",
fieldClassName,
)}
>
{currentLabel ?? t("configForm.ptzPresets.placeholder")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput
placeholder={t("configForm.ptzPresets.search")}
value={searchValue}
onValueChange={setSearchValue}
onKeyDown={(e) => {
if (e.key === "Enter" && showCustomOption) {
e.preventDefault();
commit(trimmedSearch);
}
}}
/>
<CommandList>
{showCustomOption && (
<CommandGroup>
<CommandItem
value={trimmedSearch}
onSelect={() => commit(trimmedSearch)}
>
<Plus className="mr-2 h-4 w-4" />
{t("configForm.ptzPresets.useCustom", {
value: trimmedSearch,
})}
</CommandItem>
</CommandGroup>
)}
{presets.length > 0 ? (
<CommandGroup heading={t("configForm.ptzPresets.available")}>
{presets.map((preset) => (
<CommandItem
key={preset}
value={preset}
onSelect={() => commit(preset)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === preset ? "opacity-100" : "opacity-0",
)}
/>
{preset}
</CommandItem>
))}
</CommandGroup>
) : !showCustomOption ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("configForm.ptzPresets.noPresets")}
</div>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -3,8 +3,10 @@ import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LuEye, LuEyeOff } from "react-icons/lu";
import { cn } from "@/lib/utils";
import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const";
import { getSizedFieldClassName } from "../utils";
export function PasswordWidget(props: WidgetProps) {
@@ -21,17 +23,31 @@ export function PasswordWidget(props: WidgetProps) {
options,
} = props;
const { t } = useTranslation(["common"]);
const [showPassword, setShowPassword] = useState(false);
const fieldClassName = getSizedFieldClassName(options, "sm");
// When the backend returns the sentinel, hide it visually and prompt the
// user that a value is already saved. The value stays as the sentinel in
// form state — backend /config/set strips it so the saved YAML is
// preserved when the user doesn't touch the field.
const isRedacted = value === REDACTED_CREDENTIAL_SENTINEL;
const displayValue = isRedacted ? "" : (value ?? "");
const effectivePlaceholder = isRedacted
? t("credentialField.savedPlaceholder", {
ns: "common",
defaultValue: "Saved — leave blank to keep current",
})
: placeholder || "";
return (
<div className={cn("relative", fieldClassName)}>
<Input
id={id}
type={showPassword ? "text" : "password"}
value={value ?? ""}
value={displayValue}
disabled={disabled || readonly}
placeholder={placeholder || ""}
placeholder={effectivePlaceholder}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
@@ -46,7 +62,7 @@ export function PasswordWidget(props: WidgetProps) {
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
disabled={disabled}
disabled={disabled || isRedacted}
>
{showPassword ? (
<LuEyeOff className="h-4 w-4" />
@@ -0,0 +1,57 @@
// Disables model_size and shows "N/A" when a GenAI provider is selected.
// Reads model via LiveFormDataContext so it re-runs even when RJSF's
// SchemaField memoization would skip this widget.
import type { WidgetProps } from "@rjsf/utils";
import { useContext, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Select,
SelectContent,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LiveFormDataContext } from "../../LiveFormDataContext";
import { getSizedFieldClassName } from "../utils";
import { SelectWidget } from "./SelectWidget";
export function SemanticSearchModelSizeWidget(props: WidgetProps) {
const { t } = useTranslation(["views/settings"]);
const liveFormData = useContext(LiveFormDataContext);
const model = liveFormData?.model;
const isProvider =
typeof model === "string" &&
model !== "" &&
model !== "jinav1" &&
model !== "jinav2";
// Clear model_size while on a provider (buildOverrides converts to ""
// which the backend treats as "remove"). Restore the schema default
// when returning to a Jina model so the field isn't left empty.
const { value, onChange, schema } = props;
const schemaDefault = schema?.default as string | undefined;
useEffect(() => {
if (isProvider && value !== undefined) {
onChange(undefined);
} else if (!isProvider && value === undefined && schemaDefault) {
onChange(schemaDefault);
}
}, [isProvider, value, onChange, schemaDefault]);
if (isProvider) {
const fieldClassName = getSizedFieldClassName(props.options ?? {}, "sm");
return (
<Select value="" disabled>
<SelectTrigger className={fieldClassName}>
<SelectValue
placeholder={t("configForm.semanticSearchModelSize.notApplicable", {
defaultValue: "Not applicable for GenAI providers",
})}
/>
</SelectTrigger>
<SelectContent />
</Select>
);
}
return <SelectWidget {...props} />;
}
@@ -26,7 +26,7 @@ export function TextWidget(props: WidgetProps) {
return (
<Input
id={id}
className={cn("text-md", fieldClassName)}
className={cn(fieldClassName)}
type="text"
value={value ?? ""}
disabled={disabled || readonly}
@@ -26,7 +26,7 @@ export function TextareaWidget(props: WidgetProps) {
return (
<Textarea
id={id}
className={cn("text-md", fieldClassName)}
className={cn(fieldClassName)}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
+243 -24
View File
@@ -8,13 +8,24 @@ import { isDesktop, isMobile } from "react-device-detect";
import useSWR from "swr";
import { MdHome } from "react-icons/md";
import { Button, buttonVariants } from "../ui/button";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { AnimatePresence, motion } from "framer-motion";
import { HiDotsHorizontal } from "react-icons/hi";
import { IoClose } from "react-icons/io5";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { LuPencil, LuPlus } from "react-icons/lu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
@@ -55,7 +66,6 @@ import { z } from "zod";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { ScrollArea, ScrollBar } from "../ui/scroll-area";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
@@ -144,7 +154,142 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
const [addGroup, setAddGroup] = useState(false);
const Scroller = isMobile ? ScrollArea : "div";
// mobile overflow reveal - the group strip sits left of the logo and is
// clipped (not scrollable) when there are too many groups, so render only
// the buttons that fully fit and surface a kebab next to the last visible
// one that expands a panel revealing all of them
const [expanded, setExpanded] = useState(false);
// null => all buttons fit, render them all with no kebab; a number => only
// that many fit alongside the kebab
const [visibleCount, setVisibleCount] = useState<number | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const measureRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
if (isDesktop) {
return;
}
const wrapper = wrapperRef.current;
const measure = measureRef.current;
if (!wrapper || !measure) {
return;
}
const gap = 8; // gap-2 between buttons in the strip
const wrapperGap = 4; // gap-1 between the strip and the kebab
const compute = () => {
const buttons = Array.from(measure.children) as HTMLElement[];
if (buttons.length === 0) {
return;
}
// the trailing child of the measurement row is a kebab clone
const kebab = buttons[buttons.length - 1];
const groupButtons = buttons.slice(0, -1);
const available = wrapper.clientWidth;
const fullWidth =
groupButtons.reduce((sum, el) => sum + el.offsetWidth, 0) +
Math.max(groupButtons.length - 1, 0) * gap;
if (fullWidth <= available) {
setVisibleCount(null);
return;
}
const budget = available - kebab.offsetWidth - wrapperGap;
let used = 0;
let count = 0;
for (const el of groupButtons) {
const next = (count === 0 ? 0 : gap) + el.offsetWidth;
if (used + next <= budget) {
used += next;
count += 1;
} else {
break;
}
}
setVisibleCount(Math.max(count, 1));
};
compute();
const observer = new ResizeObserver(compute);
observer.observe(wrapper);
return () => observer.disconnect();
}, [groups, isAdmin]);
const groupButtons = (afterSelect?: () => void) => {
const buttons = [
<Button
key="default-group"
className={cn(
"shrink-0",
group == "default"
? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60"
: "bg-secondary text-secondary-foreground",
)}
aria-label={t("menu.live.allCameras", { ns: "common" })}
size="sm"
onClick={() => {
if (group) {
setGroup("default", true);
}
afterSelect?.();
}}
>
<MdHome className="size-5" />
</Button>,
...groups.map(([name, config]) => (
<Button
key={name}
className={cn(
"shrink-0",
group == name
? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60"
: "bg-secondary text-secondary-foreground",
)}
aria-label={t("group.label")}
size="sm"
onClick={() => {
setGroup(name, group != "default");
afterSelect?.();
}}
>
{config && config.icon && isValidIconName(config.icon) && (
<IconRenderer icon={LuIcons[config.icon]} className="size-5" />
)}
</Button>
)),
];
if (isAdmin) {
buttons.push(
<Button
key="add-group"
className="shrink-0 bg-secondary text-muted-foreground"
aria-label={t("group.add")}
size="sm"
onClick={() => {
setAddGroup(true);
afterSelect?.();
}}
>
<LuPencil className="size-5 text-primary-variant" />
</Button>,
);
}
return buttons;
};
return (
<>
@@ -157,12 +302,11 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
deleteGroup={deleteGroup}
isAdmin={isAdmin}
/>
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
{isDesktop ? (
<div
className={cn(
"flex items-center justify-start gap-2",
"flex flex-col items-center justify-start gap-2",
className,
isDesktop ? "flex-col" : "whitespace-nowrap",
)}
>
<Tooltip open={tooltip == "default"}>
@@ -176,8 +320,8 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
aria-label={t("menu.live.allCameras", { ns: "common" })}
size="xs"
onClick={() => (group ? setGroup("default", true) : null)}
onMouseEnter={() => (isDesktop ? showTooltip("default") : null)}
onMouseLeave={() => (isDesktop ? showTooltip(undefined) : null)}
onMouseEnter={() => showTooltip("default")}
onMouseLeave={() => showTooltip(undefined)}
>
<MdHome className="size-4" />
</Button>
@@ -201,10 +345,8 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
aria-label={t("group.label")}
size="xs"
onClick={() => setGroup(name, group != "default")}
onMouseEnter={() => (isDesktop ? showTooltip(name) : null)}
onMouseLeave={() =>
isDesktop ? showTooltip(undefined) : null
}
onMouseEnter={() => showTooltip(name)}
onMouseLeave={() => showTooltip(undefined)}
>
{config && config.icon && isValidIconName(config.icon) && (
<IconRenderer
@@ -224,18 +366,97 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
})}
{isAdmin && (
<Tooltip open={tooltip == "edit"}>
<TooltipTrigger asChild>
<Button
className="bg-secondary text-muted-foreground"
aria-label={t("group.editGroups")}
size="xs"
onClick={() => setAddGroup(true)}
onMouseEnter={() => showTooltip("edit")}
onMouseLeave={() => showTooltip(undefined)}
>
<LuPencil className="size-4 text-primary-variant" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right">
{t("group.editGroups")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
</div>
) : (
<div
ref={wrapperRef}
className={cn("flex min-w-0 items-center gap-1", className)}
>
<div className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
{visibleCount == null
? groupButtons()
: groupButtons().slice(0, visibleCount)}
</div>
{visibleCount != null && (
<Button
className="bg-secondary text-muted-foreground"
aria-label={t("group.add")}
size="xs"
onClick={() => setAddGroup(true)}
variant="ghost"
size="sm"
className="shrink-0 px-2 text-secondary-foreground"
aria-label={t("group.showAll")}
onClick={() => setExpanded(true)}
>
<LuPlus className="size-4 text-primary" />
<HiDotsHorizontal className="size-5" />
</Button>
)}
{isMobile && <ScrollBar orientation="horizontal" className="h-0" />}
{/* invisible row used only to measure natural button widths so we
can render exactly the buttons that fully fit */}
<div
className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden"
aria-hidden
inert
>
<div ref={measureRef} className="flex w-max items-center gap-2">
{groupButtons()}
<Button variant="ghost" size="sm" className="px-2">
<HiDotsHorizontal className="size-5" />
</Button>
</div>
</div>
{expanded && (
<div
className="fixed inset-0 z-20"
onClick={() => setExpanded(false)}
/>
)}
<AnimatePresence>
{expanded && (
<motion.div
key="group-overlay"
className="absolute inset-x-0 top-0 z-30 bg-background py-1 shadow-lg"
initial={{ clipPath: "inset(0 100% 0 0)" }}
animate={{ clipPath: "inset(0 0% 0 0)" }}
exit={{ clipPath: "inset(0 100% 0 0)" }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<div className="flex flex-wrap items-center gap-2">
{groupButtons(() => setExpanded(false))}
<Button
variant="ghost"
size="sm"
className="ml-auto shrink-0 px-2 text-secondary-foreground"
aria-label={t("group.showLess")}
onClick={() => setExpanded(false)}
>
<IoClose className="size-5" />
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</Scroller>
)}
</>
);
}
@@ -847,7 +1068,7 @@ export function CameraGroupEdit({
<FormLabel>{t("group.name.label")}</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder={t("group.name.placeholder")}
{...field}
/>
@@ -973,10 +1194,9 @@ export function CameraGroupEdit({
<Separator className="my-2 flex bg-secondary" />
<div className="flex flex-row gap-2 py-5 md:pb-0">
<DialogFooter className="py-5 md:pb-0">
<Button
type="button"
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
>
@@ -985,7 +1205,6 @@ export function CameraGroupEdit({
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label={t("button.save", { ns: "common" })}
type="submit"
>
@@ -998,7 +1217,7 @@ export function CameraGroupEdit({
t("button.save", { ns: "common" })
)}
</Button>
</div>
</DialogFooter>
</form>
</Form>
);
@@ -40,7 +40,7 @@ export function LogSettingsButton({
<div className={cn("my-3 space-y-3 py-3 md:mt-0 md:py-0")}>
<div className="space-y-4">
<div className="space-y-0.5">
<div className="text-md">{t("filter")}</div>
<div>{t("filter")}</div>
<div className="space-y-1 text-xs text-muted-foreground">
{t("logSettings.filterBySeverity")}
</div>
@@ -53,7 +53,7 @@ export function LogSettingsButton({
<DropdownMenuSeparator />
<div className="space-y-4">
<div className="space-y-0.5">
<div className="text-md">{t("logSettings.loading.title")}</div>
<div>{t("logSettings.loading.title")}</div>
<div className="mt-2.5 flex flex-col gap-2.5">
<div className="space-y-1 text-xs text-muted-foreground">
{t("logSettings.loading.desc")}
@@ -144,11 +144,13 @@ export default function ReviewFilterGroup({
const filterValues = useMemo(
() => ({
cameras: allowedCameras.sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
),
cameras: allowedCameras
.filter((cam) => config?.cameras[cam]?.ui?.review !== false)
.sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
),
labels: Object.values(allLabels || {}),
zones: Object.values(allZones || {}),
}),
+32 -18
View File
@@ -56,18 +56,25 @@ export function CameraLineGraph({
});
}, [t, timeFormat]);
const updateTimesRef = useRef(updateTimes);
useEffect(() => {
updateTimesRef.current = updateTimes;
}, [updateTimes]);
const formatTime = useCallback(
(val: unknown) => {
return formatUnixTimestampToDateTime(
updateTimes[Math.round(val as number)],
{
timezone: config?.ui.timezone,
date_format: format,
locale,
},
);
const times = updateTimesRef.current;
const ts = times[Math.round(val as number)];
if (isNaN(ts)) {
return "";
}
return formatUnixTimestampToDateTime(ts, {
timezone: config?.ui.timezone,
date_format: format,
locale,
});
},
[config?.ui.timezone, format, locale, updateTimes],
[config?.ui.timezone, format, locale],
);
const options = useMemo(() => {
@@ -211,18 +218,25 @@ export function EventsPerSecondsLineGraph({
});
}, [t, timeFormat]);
const updateTimesRef = useRef(updateTimes);
useEffect(() => {
updateTimesRef.current = updateTimes;
}, [updateTimes]);
const formatTime = useCallback(
(val: unknown) => {
return formatUnixTimestampToDateTime(
updateTimes[Math.round(val as number) - 1],
{
timezone: config?.ui.timezone,
date_format: format,
locale,
},
);
const times = updateTimesRef.current;
const ts = times[Math.round(val as number) - 1];
if (isNaN(ts)) {
return "";
}
return formatUnixTimestampToDateTime(ts, {
timezone: config?.ui.timezone,
date_format: format,
locale,
});
},
[config?.ui.timezone, format, locale, updateTimes],
[config?.ui.timezone, format, locale],
);
const options = useMemo(() => {
+16 -9
View File
@@ -61,6 +61,11 @@ export function ThresholdBarGraph({
});
}, [t, timeFormat]);
const updateTimesRef = useRef(updateTimes);
useEffect(() => {
updateTimesRef.current = updateTimes;
}, [updateTimes]);
const formatTime = useCallback(
(val: unknown) => {
const dateIndex = Math.round(val as number);
@@ -69,16 +74,18 @@ export function ThresholdBarGraph({
if (dateIndex < 0) {
timeOffset = 5 * Math.abs(dateIndex);
}
return formatUnixTimestampToDateTime(
updateTimes[Math.max(1, dateIndex) - 1] - timeOffset,
{
timezone: config?.ui.timezone,
date_format: format,
locale,
},
);
const times = updateTimesRef.current;
const ts = times[Math.max(1, dateIndex) - 1] - timeOffset;
if (isNaN(ts)) {
return "";
}
return formatUnixTimestampToDateTime(ts, {
timezone: config?.ui.timezone,
date_format: format,
locale,
});
},
[config?.ui.timezone, format, locale, updateTimes],
[config?.ui.timezone, format, locale],
);
const options = useMemo(() => {
+1 -1
View File
@@ -119,7 +119,7 @@ export default function IconPicker({
placeholder={t("iconPicker.search.placeholder", {
ns: "components/icons",
})}
className="text-md mb-3 md:text-sm"
className="mb-3 md:text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
+3 -3
View File
@@ -6,9 +6,9 @@ export function LiveGridIcon({ layout }: LiveIconProps) {
return (
<div className="flex size-full flex-col gap-0.5 overflow-hidden rounded-md">
<div
className={`h-1 w-full ${layout == "grid" ? "bg-selected" : "bg-muted-foreground"}`}
className={`w-full flex-1 ${layout == "grid" ? "bg-selected" : "bg-muted-foreground"}`}
/>
<div className="flex h-1 w-full gap-0.5">
<div className="flex w-full flex-1 gap-0.5">
<div
className={`w-full ${layout == "grid" ? "bg-selected" : "bg-muted-foreground"}`}
/>
@@ -16,7 +16,7 @@ export function LiveGridIcon({ layout }: LiveIconProps) {
className={`w-full ${layout == "grid" ? "bg-selected" : "bg-muted-foreground"}`}
/>
</div>
<div className="flex h-1 w-full gap-0.5">
<div className="flex w-full flex-1 gap-0.5">
<div
className={`w-full ${layout == "grid" ? "bg-selected" : "bg-muted-foreground"}`}
/>
+1 -1
View File
@@ -696,7 +696,7 @@ export default function InputWithTags({
onFocus={handleInputFocus}
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
className="text-md h-9 pr-32"
className="h-9 pr-32"
placeholder={t("placeholder.search")}
/>
<div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5">
+16 -7
View File
@@ -26,6 +26,7 @@ type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
placeholderName?: string;
placeholderId?: string;
idVisible?: boolean;
idDisabled?: boolean;
};
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
@@ -41,6 +42,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
placeholderName,
placeholderId,
idVisible,
idDisabled,
}: NameAndIdFieldsProps<T>) {
const { t } = useTranslation(["common"]);
const { watch, setValue, trigger, formState } = useFormContext<T>();
@@ -59,6 +61,9 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
const effectiveProcessId = processId || defaultProcessId;
useEffect(() => {
if (idDisabled) {
return;
}
const subscription = watch((value, { name }) => {
if (name === nameField) {
hasUserTypedRef.current = true;
@@ -68,7 +73,15 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
}
});
return () => subscription.unsubscribe();
}, [watch, setValue, trigger, nameField, idField, effectiveProcessId]);
}, [
watch,
setValue,
trigger,
nameField,
idField,
effectiveProcessId,
idDisabled,
]);
// Auto-expand if there's an error on the ID field after user has typed
useEffect(() => {
@@ -99,11 +112,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
</span>
</div>
<FormControl>
<Input
className="text-md"
placeholder={placeholderName}
{...field}
/>
<Input placeholder={placeholderName} {...field} />
</FormControl>
{nameDescription && (
<FormDescription>{nameDescription}</FormDescription>
@@ -121,8 +130,8 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
<FormLabel>{idLabel ?? t("label.ID")}</FormLabel>
<FormControl>
<Input
className="text-md"
placeholder={placeholderId}
disabled={idDisabled}
{...field}
/>
</FormControl>
@@ -69,7 +69,6 @@ export function SaveSearchDialog({
</DialogHeader>
<Input
value={searchName}
className="text-md"
onChange={(e) => setSearchName(e.target.value)}
placeholder={t("search.saveSearch.placeholder")}
/>
@@ -88,7 +87,6 @@ export function SaveSearchDialog({
<Button
onClick={handleSave}
variant="select"
className="mb-2 md:mb-0"
aria-label={t("search.saveSearch.button.save.label")}
>
{t("button.save", { ns: "common" })}
+1 -1
View File
@@ -77,7 +77,7 @@ export default function TextEntry({
<FormControl>
<Input
{...field}
className="text-md w-full"
className="w-full"
placeholder={placeholder}
type="text"
/>
+10 -2
View File
@@ -82,9 +82,13 @@ import { MdCategory } from "react-icons/md";
type GeneralSettingsProps = {
className?: string;
large?: boolean;
};
export default function GeneralSettings({ className }: GeneralSettingsProps) {
export default function GeneralSettings({
className,
large,
}: GeneralSettingsProps) {
const { t } = useTranslation(["common", "views/settings"]);
const { getLocaleDocUrl } = useDocDomain();
const { data: profile } = useSWR("profile");
@@ -109,6 +113,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
"nb-NO": "nb",
"yue-Hant": "yue",
"zh-CN": "zhCN",
"zh-Hant": "zhHant",
"pt-BR": "ptBR",
};
@@ -224,10 +229,13 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
isDesktop
? "cursor-pointer rounded-lg bg-secondary text-secondary-foreground hover:bg-muted"
: "text-secondary-foreground",
large && "size-12",
className,
)}
>
<LuSettings className="size-5 md:m-[6px]" />
<LuSettings
className={cn("md:m-[6px]", large ? "size-6" : "size-5")}
/>
</div>
</TooltipTrigger>
<TooltipPortal>
+2 -2
View File
@@ -279,7 +279,7 @@ export default function LiveContextMenu({
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>
<div className="flex flex-col items-start gap-1 py-1 pl-2">
<div className="text-md text-primary-variant smart-capitalize">
<div className="text-primary-variant smart-capitalize">
<CameraNameLabel camera={camera} />
</div>
{preferredLiveMode == "jsmpeg" && isRestreamed && (
@@ -323,7 +323,7 @@ export default function LiveContextMenu({
onClick={() => sendEnabled(isEnabled ? "OFF" : "ON")}
>
<div className="text-primary">
{isEnabled ? t("camera.disable") : t("camera.enable")}
{isEnabled ? t("camera.turnOff") : t("camera.turnOn")}
</div>
</div>
</ContextMenuItem>
@@ -1,5 +1,6 @@
import { useState, ReactNode, useCallback } from "react";
import { SearchResult } from "@/types/search";
import { REVIEW_PADDING } from "@/types/review";
import { FrigateConfig } from "@/types/frigateConfig";
import { baseUrl } from "@/api/baseUrl";
import { toast } from "sonner";
@@ -94,8 +95,8 @@ export default function SearchResultActions({
axios
.post("debug_replay/start", {
camera: event.camera,
start_time: event.start_time,
end_time: event.end_time,
start_time: (event.start_time ?? 0) - REVIEW_PADDING,
end_time: (event.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
})
.then((response) => {
if (response.status === 202 || response.status === 200) {
@@ -116,7 +117,11 @@ export default function SearchResultActions({
closeButton: true,
dismissible: false,
action: (
<a href="/replay" target="_blank" rel="noopener noreferrer">
<a
href={`${baseUrl}replay`}
target="_blank"
rel="noopener noreferrer"
>
<Button>
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
</Button>
@@ -125,9 +130,15 @@ export default function SearchResultActions({
},
);
} else {
toast.error(t("dialog.toast.error", { error: errorMessage }), {
position: "top-center",
});
toast.error(
t("dialog.toast.error", {
ns: "views/replay",
error: errorMessage,
}),
{
position: "top-center",
},
);
}
})
.finally(() => {
@@ -201,7 +212,7 @@ export default function SearchResultActions({
<span>{t("itemMenu.addTrigger.label")}</span>
</MenuItem>
)}
{searchResult.has_clip && (
{isAdmin && searchResult.has_clip && (
<MenuItem
className="cursor-pointer"
aria-label={t("itemMenu.debugReplay.aria")}
+4 -3
View File
@@ -146,9 +146,10 @@ export function MobilePageContent({
<motion.div
ref={containerRef}
className={cn(
"fixed inset-0 z-50 mb-12 bg-background",
isPWA && "mb-16",
"landscape:mb-14 landscape:md:mb-16",
"fixed inset-0 z-50 bg-background",
isPWA
? "mb-[calc(3rem+env(safe-area-inset-bottom))] md:mb-[calc(4rem+env(safe-area-inset-bottom))]"
: "mb-12 md:mb-16",
className,
)}
initial={{ x: "100%" }}
+76 -13
View File
@@ -4,7 +4,14 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import useSWR from "swr";
import { FrigateStats } from "@/types/stats";
import { useEmbeddingsReindexProgress, useFrigateStats } from "@/api/ws";
import { useContext, useEffect, useMemo } from "react";
import {
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import useStats from "@/hooks/use-stats";
import GeneralSettings from "../menu/GeneralSettings";
import useNavigation from "@/hooks/use-navigation";
@@ -14,36 +21,82 @@ import {
} from "@/context/statusbar-provider";
import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
import { isIOS, isMobile } from "react-device-detect";
import { isMobile } from "react-device-detect";
import { isPWA } from "@/utils/isPWA";
import { useTranslation } from "react-i18next";
function Bottombar() {
const navItems = useNavigation("secondary");
// Render 48px touch targets when they fit with even spacing, otherwise fall
// back to the compact size. Measured against the live bar width and icon
// count (which varies with enabled nav items and the status alert).
const containerRef = useRef<HTMLDivElement | null>(null);
const [large, setLarge] = useState(false);
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) {
return;
}
const TARGET = 48; // standard bottom-nav touch target (px)
const MIN_GAP = 8; // minimum spacing between targets (px)
const compute = () => {
const count = el.children.length;
if (count === 0) {
return;
}
const needed = count * TARGET + Math.max(count - 1, 0) * MIN_GAP;
setLarge(needed <= el.clientWidth);
};
compute();
const resize = new ResizeObserver(compute);
resize.observe(el);
// recompute when items are added/removed (e.g. the status alert appears)
const mutation = new MutationObserver(compute);
mutation.observe(el, { childList: true });
return () => {
resize.disconnect();
mutation.disconnect();
};
}, [navItems]);
return (
<div
ref={containerRef}
className={cn(
"absolute inset-x-4 bottom-0 flex h-16 flex-row justify-between",
isPWA && isIOS
? "portrait:items-start portrait:pt-1 landscape:items-center"
: "items-center",
isMobile && !isPWA && "h-12 md:h-16",
"absolute inset-x-4 bottom-0 flex h-16 flex-row items-center justify-between",
isMobile &&
(isPWA
? "h-[calc(3rem+env(safe-area-inset-bottom))] pb-[env(safe-area-inset-bottom)] md:h-[calc(4rem+env(safe-area-inset-bottom))]"
: "h-12 md:h-16 md:pb-2"),
)}
>
{navItems.map((item) => (
<NavItem key={item.id} className="p-2" item={item} Icon={item.icon} />
<NavItem
key={item.id}
large={large}
className="p-2"
item={item}
Icon={item.icon}
/>
))}
<GeneralSettings className="p-2" />
<StatusAlertNav className="p-2" />
<GeneralSettings large={large} className="p-2" />
<StatusAlertNav large={large} className="p-2" />
</div>
);
}
type StatusAlertNavProps = {
className?: string;
large?: boolean;
};
function StatusAlertNav({ className }: StatusAlertNavProps) {
function StatusAlertNav({ className, large }: StatusAlertNavProps) {
const { t } = useTranslation(["views/system"]);
const { data: initialStats } = useSWR<FrigateStats>("stats", {
revalidateOnFocus: false,
@@ -105,8 +158,18 @@ function StatusAlertNav({ className }: StatusAlertNavProps) {
return (
<Drawer>
<DrawerTrigger asChild>
<div className="p-2">
<IoIosWarning className="size-5 text-danger md:m-[6px]" />
<div
className={cn(
"flex flex-col items-center justify-center p-2",
large && "size-12",
)}
>
<IoIosWarning
className={cn(
"text-danger md:m-[6px]",
large ? "size-6" : "size-5",
)}
/>
</div>
</DrawerTrigger>
<DrawerContent
+4 -1
View File
@@ -27,6 +27,7 @@ type NavItemProps = {
item: NavData;
Icon: IconType;
onClick?: () => void;
large?: boolean;
};
export default function NavItem({
@@ -34,6 +35,7 @@ export default function NavItem({
item,
Icon,
onClick,
large,
}: NavItemProps) {
const { t } = useTranslation(["common"]);
if (item.enabled == false) {
@@ -48,11 +50,12 @@ export default function NavItem({
cn(
"flex flex-col items-center justify-center rounded-lg p-[6px]",
className,
large && "size-12",
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"],
)
}
>
<Icon className="size-5" />
<Icon className={large ? "size-6" : "size-5"} />
</NavLink>
);
+19 -5
View File
@@ -9,17 +9,24 @@ import { useTranslation } from "react-i18next";
import { FaFilm } from "react-icons/fa6";
type ActionsDropdownProps = {
onDebugReplayClick: () => void;
onDebugReplayClick?: () => void;
onExportClick: () => void;
onShareTimestampClick: () => void;
onMotionSearchClick?: () => void;
};
export default function ActionsDropdown({
onDebugReplayClick,
onExportClick,
onShareTimestampClick,
onMotionSearchClick,
}: Readonly<ActionsDropdownProps>) {
const { t } = useTranslation(["components/dialog", "views/replay", "common"]);
const { t } = useTranslation([
"components/dialog",
"views/replay",
"views/events",
"common",
]);
return (
<DropdownMenu>
@@ -42,9 +49,16 @@ export default function ActionsDropdown({
<DropdownMenuItem onClick={onShareTimestampClick}>
{t("recording.shareTimestamp.label", { ns: "components/dialog" })}
</DropdownMenuItem>
<DropdownMenuItem onClick={onDebugReplayClick}>
{t("title", { ns: "views/replay" })}
</DropdownMenuItem>
{onMotionSearchClick && (
<DropdownMenuItem onClick={onMotionSearchClick}>
{t("motionSearch.menuItem", { ns: "views/events" })}
</DropdownMenuItem>
)}
{onDebugReplayClick && (
<DropdownMenuItem onClick={onDebugReplayClick}>
{t("title", { ns: "views/replay" })}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
+28 -31
View File
@@ -26,6 +26,7 @@ import {
import { useTranslation } from "react-i18next";
import { FrigateConfig } from "@/types/frigateConfig";
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
import { isReplayCamera } from "@/utils/cameraUtil";
import { isDesktop, isMobile } from "react-device-detect";
import { cn } from "@/lib/utils";
import {
@@ -52,7 +53,9 @@ export default function CreateRoleDialog({
const { t } = useTranslation(["views/settings"]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const cameras = Object.keys(config.cameras || {});
const cameras = Object.keys(config.cameras || {}).filter(
(name) => !isReplayCamera(name),
);
const existingRoles = Object.keys(config.auth?.roles || {});
@@ -210,36 +213,30 @@ export default function CreateRoleDialog({
<FormMessage />
</div>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter className="pt-2">
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>
@@ -433,36 +433,30 @@ export default function CreateTriggerDialog({
)}
/>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter className="pt-2">
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>
+24 -30
View File
@@ -411,36 +411,30 @@ export default function CreateUserDialog({
)}
/>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter className="pt-2">
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>
@@ -144,7 +144,7 @@ export function CustomTimeSelector({
/>
<SelectSeparator className="bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={startClock}
@@ -210,7 +210,7 @@ export function CustomTimeSelector({
/>
<SelectSeparator className="bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="endTime"
type="time"
value={endClock}
@@ -0,0 +1,120 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LuSettings } from "react-icons/lu";
import useSWR from "swr";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate";
import { Button } from "@/components/ui/button";
import { PlatformAwareSheet } from "@/components/overlay/dialog/PlatformAwareDialog";
import { useConfigSchema } from "@/hooks/use-config-schema";
import type { FrigateConfig } from "@/types/frigateConfig";
type DebugReplayConfigSheetProps = {
replayCamera: string | undefined;
};
export function DebugReplayConfigSheet({
replayCamera,
}: DebugReplayConfigSheetProps) {
const { t } = useTranslation(["views/replay"]);
const configSchema = useConfigSchema();
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const [open, setOpen] = useState(false);
return (
<PlatformAwareSheet
trigger={
<Button variant="outline" size="sm" className="flex items-center gap-2">
<LuSettings className="size-4" />
<span className="hidden md:inline">{t("page.configuration")}</span>
</Button>
}
title={t("page.configuration")}
titleClassName="text-lg font-semibold"
contentClassName="scrollbar-container flex flex-col gap-0 overflow-y-auto px-6 pb-6 sm:max-w-xl md:max-w-2xl xl:max-w-3xl"
content={
<>
<p className="mb-5 text-sm text-muted-foreground">
{t("page.configurationDesc")}
</p>
{configSchema == null ? (
<div className="flex h-40 items-center justify-center">
<ActivityIndicator />
</div>
) : (
<div className="space-y-6">
<ConfigSectionTemplate
sectionKey="detect"
level="replay"
cameraName={replayCamera}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
<ConfigSectionTemplate
sectionKey="motion"
level="replay"
cameraName={replayCamera}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
<ConfigSectionTemplate
sectionKey="objects"
level="replay"
cameraName={replayCamera}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
{config?.face_recognition?.enabled && (
<ConfigSectionTemplate
sectionKey="face_recognition"
level="replay"
cameraName={replayCamera}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
)}
{config?.lpr?.enabled && (
<ConfigSectionTemplate
sectionKey="lpr"
level="replay"
cameraName={replayCamera}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
)}
</div>
)}
</>
}
open={open}
onOpenChange={setOpen}
/>
);
}
@@ -1,4 +1,5 @@
import { useCallback, useState } from "react";
import { baseUrl } from "@/api/baseUrl";
import {
Dialog,
DialogContent,
@@ -112,19 +113,14 @@ export function DebugReplayContent({
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
<DialogFooter
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-2"}
>
<DialogFooter className="mt-3 sm:mt-0">
<Button
className={isDesktop ? "" : "w-full"}
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
className={isDesktop ? "" : "w-full"}
variant="select"
disabled={isStarting}
onClick={() => {
@@ -227,7 +223,11 @@ export default function DebugReplayDialog({
closeButton: true,
dismissible: false,
action: (
<a href="/replay" target="_blank" rel="noopener noreferrer">
<a
href={`${baseUrl}replay`}
target="_blank"
rel="noopener noreferrer"
>
<Button>{t("dialog.toast.goToReplay")}</Button>
</a>
),
+25 -32
View File
@@ -70,38 +70,31 @@ export default function DeleteRoleDialog({
</div>
</div>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
disabled={isLoading}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
className="flex flex-1"
aria-label={t("button.delete", { ns: "common" })}
variant="destructive"
disabled={isLoading}
onClick={handleDelete}
type="button"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("roles.dialog.deleteRole.deleting")}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
aria-label={t("button.delete", { ns: "common" })}
variant="destructive"
disabled={isLoading}
onClick={handleDelete}
type="button"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("roles.dialog.deleteRole.deleting")}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -43,36 +43,30 @@ export default function DeleteTriggerDialog({
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-3 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
disabled={isLoading}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
className="flex flex-1 text-white"
onClick={onDelete}
disabled={isLoading}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.delete", { ns: "common" })}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
disabled={isLoading}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
onClick={onDelete}
disabled={isLoading}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.delete", { ns: "common" })}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+15 -21
View File
@@ -46,27 +46,21 @@ export default function DeleteUserDialog({
</p>
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
className="flex flex-1 text-white"
onClick={onDelete}
>
{t("button.delete", { ns: "common" })}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
onClick={onDelete}
>
{t("button.delete", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -25,6 +25,7 @@ import {
import { Trans, useTranslation } from "react-i18next";
import { FrigateConfig } from "@/types/frigateConfig";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { isReplayCamera } from "@/utils/cameraUtil";
type EditRoleCamerasOverlayProps = {
show: boolean;
@@ -46,7 +47,9 @@ export default function EditRoleCamerasDialog({
const { t } = useTranslation(["views/settings"]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const cameras = Object.keys(config.cameras || {});
const cameras = Object.keys(config.cameras || {}).filter(
(name) => !isReplayCamera(name),
);
const formSchema = z.object({
cameras: z
@@ -102,13 +105,15 @@ export default function EditRoleCamerasDialog({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 pt-4"
className="space-y-5 pt-2"
>
<div className="space-y-2">
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
<FormDescription className="text-xs text-muted-foreground">
{t("roles.dialog.form.cameras.desc")}
</FormDescription>
<div className="space-y-3">
<div>
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
<FormDescription className="text-xs text-muted-foreground">
{t("roles.dialog.form.cameras.desc")}
</FormDescription>
</div>
<div className="scrollbar-container max-h-[40dvh] space-y-2 overflow-y-auto">
{cameras.map((camera) => (
<FormField
@@ -156,36 +161,30 @@ export default function EditRoleCamerasDialog({
<FormMessage />
</div>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>
+11 -17
View File
@@ -54,6 +54,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { Textarea } from "../ui/textarea";
import { useNavigate } from "react-router-dom";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { isReplayCamera } from "@/utils/cameraUtil";
const EXPORT_OPTIONS = [
"1",
@@ -163,7 +164,11 @@ export default function ExportDialog({
toast.success(t("export.toast.queued"), {
position: "top-center",
action: (
<a href="/export" target="_blank" rel="noopener noreferrer">
<a
href={`${baseUrl}export`}
target="_blank"
rel="noopener noreferrer"
>
<Button>{t("export.toast.view")}</Button>
</a>
),
@@ -282,7 +287,7 @@ export default function ExportDialog({
<Content
className={
isDesktop
? "sm:rounded-lg md:rounded-2xl"
? "scrollbar-container max-h-[90dvh] overflow-y-auto sm:rounded-lg md:rounded-2xl"
: "mx-4 rounded-lg px-4 pb-4 md:rounded-2xl"
}
>
@@ -444,7 +449,9 @@ export function ExportContent({
);
const cameraActivities = useMemo<CameraActivity[]>(() => {
const allCameraIds = Object.keys(config?.cameras ?? {});
const allCameraIds = Object.keys(config?.cameras ?? {}).filter(
(name) => !isReplayCamera(name),
);
const byCamera = new Map<string, Event[]>();
events?.forEach((event) => {
@@ -787,7 +794,6 @@ export function ExportContent({
)}
<Input
className="text-md"
type="search"
placeholder={t("export.name.placeholder")}
value={name}
@@ -828,13 +834,11 @@ export function ExportContent({
{selectedCaseId === "new" && (
<div className="space-y-2 pt-1">
<Input
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")}
value={singleNewCaseName}
onChange={(e) => setSingleNewCaseName(e.target.value)}
/>
<Textarea
className="text-md"
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={singleNewCaseDescription}
onChange={(e) =>
@@ -981,7 +985,6 @@ export function ExportContent({
{t("export.multiCamera.nameLabel")}
</Label>
<Input
className="text-md"
type="search"
placeholder={t("export.multiCamera.namePlaceholder")}
value={name}
@@ -1021,13 +1024,11 @@ export function ExportContent({
{batchCaseSelection === "new" && (
<div className="space-y-2 pt-1">
<Input
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")}
value={newCaseName}
onChange={(event) => setNewCaseName(event.target.value)}
/>
<Textarea
className="text-md"
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={newCaseDescription}
onChange={(event) =>
@@ -1042,20 +1043,15 @@ export function ExportContent({
</Tabs>
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
<DialogFooter
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-2"}
>
<DialogFooter className="mt-3 sm:mt-0">
<Button
className={isDesktop ? "" : "w-full"}
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel}
>
{t("button.cancel", { ns: "common" })}
</Button>
{activeTab === "export" ? (
<Button
className={isDesktop ? "" : "w-full"}
aria-label={t("export.selectOrExport")}
variant="select"
disabled={isStartingExport}
@@ -1079,12 +1075,10 @@ export function ExportContent({
</Button>
) : (
<Button
className={isDesktop ? "" : "w-full"}
aria-label={t("export.multiCamera.exportButton", {
count: selectedCameraCount,
})}
variant="select"
size="sm"
disabled={!canStartBatchExport}
onClick={() => void startBatchExport()}
>
+1 -1
View File
@@ -85,7 +85,7 @@ export default function ImagePicker({
<Input
type="text"
placeholder={t("imagePicker.search.placeholder")}
className="text-md mb-3 md:text-sm"
className="mb-3 md:text-sm"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
+2 -2
View File
@@ -124,7 +124,7 @@ function useHelpfulLinks(content: string | undefined) {
if (/Did not detect hwaccel/.exec(content)) {
links.push({
link: getLocaleDocUrl("configuration/hardware_acceleration"),
link: getLocaleDocUrl("configuration/hardware_acceleration_video"),
text: "Setup Hardware Acceleration",
});
}
@@ -142,7 +142,7 @@ function useHelpfulLinks(content: string | undefined) {
content.includes("No VA display found for device /dev/dri/renderD128")
) {
links.push({
link: getLocaleDocUrl("configuration/hardware_acceleration"),
link: getLocaleDocUrl("configuration/hardware_acceleration_video"),
text: "Verify Hardware Acceleration Setup",
});
}
@@ -1,8 +1,9 @@
import { useCallback, useState } from "react";
import { baseUrl } from "@/api/baseUrl";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Button } from "../ui/button";
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
import { LuBug, LuShare2 } from "react-icons/lu";
import { LuBug, LuSearch, LuShare2 } from "react-icons/lu";
import { TimeRange } from "@/types/timeline";
import { ExportContent, ExportPreviewDialog, ExportTab } from "./ExportDialog";
import {
@@ -28,6 +29,7 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { StartExportResponse } from "@/types/export";
import { ShareTimestampContent } from "./ShareTimestampDialog";
import { useIsAdmin } from "@/hooks/use-is-admin";
type DrawerMode =
| "none"
@@ -44,6 +46,7 @@ const DRAWER_FEATURES = [
"filter",
"debug-replay",
"share-timestamp",
"motion-search",
] as const;
export type DrawerFeatures = (typeof DRAWER_FEATURES)[number];
const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
@@ -52,6 +55,7 @@ const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
"filter",
"debug-replay",
"share-timestamp",
"motion-search",
];
type MobileReviewSettingsDrawerProps = {
@@ -73,6 +77,7 @@ type MobileReviewSettingsDrawerProps = {
setDebugReplayMode?: (mode: ExportMode) => void;
setDebugReplayRange?: (range: TimeRange | undefined) => void;
onShareTimestamp?: (timestamp: number) => void;
onMotionSearch?: () => void;
onUpdateFilter: (filter: ReviewFilter) => void;
setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void;
@@ -97,6 +102,7 @@ export default function MobileReviewSettingsDrawer({
setDebugReplayMode = () => {},
setDebugReplayRange = () => {},
onShareTimestamp = () => {},
onMotionSearch,
onUpdateFilter,
setRange,
setMode,
@@ -106,8 +112,10 @@ export default function MobileReviewSettingsDrawer({
"views/recording",
"components/dialog",
"views/replay",
"views/events",
"common",
]);
const isAdmin = useIsAdmin();
const navigate = useNavigate();
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
const [exportTab, setExportTab] = useState<ExportTab>("export");
@@ -190,7 +198,11 @@ export default function MobileReviewSettingsDrawer({
toast.success(t("export.toast.queued", { ns: "components/dialog" }), {
position: "top-center",
action: (
<a href="/export" target="_blank" rel="noopener noreferrer">
<a
href={`${baseUrl}export`}
target="_blank"
rel="noopener noreferrer"
>
<Button>
{t("export.toast.view", { ns: "components/dialog" })}
</Button>
@@ -336,27 +348,6 @@ export default function MobileReviewSettingsDrawer({
{t("export")}
</Button>
)}
{features.includes("share-timestamp") && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("recording.shareTimestamp.label", {
ns: "components/dialog",
})}
onClick={() => {
const initialTimestamp = Math.floor(currentTime);
setShareTimestampAtOpen(initialTimestamp);
setCustomShareTimestamp(initialTimestamp);
setSelectedShareOption("current");
setDrawerMode("share-timestamp");
}}
>
<LuShare2 className="size-5 rounded-md bg-secondary-foreground stroke-secondary p-1" />
{t("recording.shareTimestamp.label", {
ns: "components/dialog",
})}
</Button>
)}
{features.includes("calendar") && (
<Button
className="flex w-full items-center justify-center gap-2"
@@ -383,7 +374,41 @@ export default function MobileReviewSettingsDrawer({
{t("filter")}
</Button>
)}
{features.includes("debug-replay") && (
{features.includes("share-timestamp") && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("recording.shareTimestamp.label", {
ns: "components/dialog",
})}
onClick={() => {
const initialTimestamp = Math.floor(currentTime);
setShareTimestampAtOpen(initialTimestamp);
setCustomShareTimestamp(initialTimestamp);
setSelectedShareOption("current");
setDrawerMode("share-timestamp");
}}
>
<LuShare2 className="size-5 rounded-md bg-secondary-foreground stroke-secondary p-1" />
{t("recording.shareTimestamp.label", {
ns: "components/dialog",
})}
</Button>
)}
{features.includes("motion-search") && onMotionSearch && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("motionSearch.menuItem", { ns: "views/events" })}
onClick={() => {
onMotionSearch();
setDrawerMode("none");
}}
>
<LuSearch className="size-5 rounded-md bg-secondary-foreground stroke-secondary p-1" />
{t("motionSearch.menuItem", { ns: "views/events" })}
</Button>
)}
{isAdmin && features.includes("debug-replay") && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("title", { ns: "views/replay" })}
@@ -290,7 +290,6 @@ export default function MultiExportDialog({
const newCaseInputs = (
<div className="space-y-2 pt-1">
<Input
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")}
value={newCaseName}
onChange={(event) => setNewCaseName(event.target.value)}
@@ -298,7 +297,6 @@ export default function MultiExportDialog({
autoFocus={isDesktop}
/>
<Textarea
className="text-md"
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={newCaseDescription}
onChange={(event) => setNewCaseDescription(event.target.value)}
@@ -344,11 +342,7 @@ export default function MultiExportDialog({
const footer = (
<>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isExporting}
>
<Button onClick={() => handleOpenChange(false)} disabled={isExporting}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
@@ -380,7 +374,7 @@ export default function MultiExportDialog({
</DialogDescription>
</DialogHeader>
{body}
<DialogFooter className="gap-2">{footer}</DialogFooter>
<DialogFooter>{footer}</DialogFooter>
</DialogContent>
</Dialog>
);
@@ -399,7 +393,7 @@ export default function MultiExportDialog({
</DrawerDescription>
</DrawerHeader>
{body}
<div className="mt-4 flex flex-col-reverse gap-2">{footer}</div>
<DialogFooter className="mt-4">{footer}</DialogFooter>
</DrawerContent>
</Drawer>
);
@@ -19,6 +19,13 @@ import { useTimezone } from "@/hooks/use-date-utils";
type WeekStartsOnType = 0 | 1 | 2 | 3 | 4 | 5 | 6;
function formatCalendarDay(day: Date): string {
const y = day.getFullYear();
const m = String(day.getMonth() + 1).padStart(2, "0");
const d = String(day.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
type ReviewActivityCalendarProps = {
reviewSummary?: ReviewSummary;
recordingsSummary?: RecordingsSummary;
@@ -68,17 +75,10 @@ export default function ReviewActivityCalendar({
}
}
const formatDay = (day: Date) => {
const y = day.getFullYear();
const m = String(day.getMonth() + 1).padStart(2, "0");
const d = String(day.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
};
return {
recordings: (day: Date) => recordingsSet.has(formatDay(day)),
alerts: (day: Date) => alertsSet.has(formatDay(day)),
detections: (day: Date) => detectionsSet.has(formatDay(day)),
recordings: (day: Date) => recordingsSet.has(formatCalendarDay(day)),
alerts: (day: Date) => alertsSet.has(formatCalendarDay(day)),
detections: (day: Date) => detectionsSet.has(formatCalendarDay(day)),
};
}, [reviewSummary, recordingsSummary]);
@@ -163,15 +163,33 @@ type TimezoneAwareCalendarProps = {
selectedDay?: Date;
onSelect: (day?: Date) => void;
disabled?: ComponentProps<typeof Calendar>["disabled"];
recordingsSummary?: RecordingsSummary;
};
export function TimezoneAwareCalendar({
timezone,
selectedDay,
onSelect,
disabled,
recordingsSummary,
}: TimezoneAwareCalendarProps) {
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
// When a recordings summary is supplied, underline days that have footage
const recordingsModifier = useMemo(() => {
if (!recordingsSummary) {
return undefined;
}
const recordingsSet = new Set<string>();
for (const date of Object.keys(recordingsSummary)) {
if (date !== LAST_24_HOURS_KEY) {
recordingsSet.add(date);
}
}
return {
recordings: (day: Date) => recordingsSet.has(formatCalendarDay(day)),
};
}, [recordingsSummary]);
const timezoneOffset = useMemo(
() =>
timezone ? Math.round(getUTCOffset(new Date(), timezone)) : undefined,
@@ -226,6 +244,10 @@ export function TimezoneAwareCalendar({
onSelect={onSelect}
defaultMonth={selectedDay ?? new Date()}
weekStartsOn={(weekStartsOn ?? 0) as WeekStartsOnType}
modifiers={recordingsModifier}
components={
recordingsModifier ? { DayButton: ReviewActivityDay } : undefined
}
/>
);
}
+17 -24
View File
@@ -117,30 +117,23 @@ export default function RoleChangeDialog({
</Select>
</div>
<DialogFooter className="flex gap-3 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
className="flex flex-1"
onClick={() => onSave(selectedRole)}
type="button"
disabled={selectedRole === currentRole}
>
{t("button.save", { ns: "common" })}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
onClick={() => onSave(selectedRole)}
type="button"
disabled={selectedRole === currentRole}
>
{t("button.save", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -450,36 +450,30 @@ export default function SetPasswordDialog({
</div>
)}
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
disabled={isLoading}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
className="flex flex-1"
type="submit"
disabled={isLoading || !form.formState.isValid}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
disabled={isLoading}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
type="submit"
disabled={isLoading || !form.formState.isValid}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>
@@ -196,21 +196,16 @@ export function ShareTimestampContent({
{isDesktop && <Separator className="my-4 bg-secondary" />}
<DialogFooter
className={cn("mt-4", !isDesktop && "flex flex-col-reverse gap-2")}
>
<DialogFooter className="mt-3 sm:mt-0">
{onCancel && (
<Button
className={cn(!isDesktop && "w-full")}
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel}
>
{t("button.cancel", { ns: "common" })}
</Button>
)}
<Button
className={cn(!isDesktop && "w-full")}
variant="select"
onClick={() => onShareTimestamp(Math.floor(selectedTimestamp))}
>
@@ -338,7 +333,7 @@ function CustomTimestampSelector({
/>
<div className="my-3 h-px w-full bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="shareTimestamp"
type="time"
value={clock}

Some files were not shown because too many files have changed in this diff Show More