mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-06 13:43:04 +03:00
Merge branch 'dev' into dev
This commit is contained in:
+3
-1
@@ -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]",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) || ""}
|
||||
|
||||
@@ -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 || {}),
|
||||
}),
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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"}`}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" })}
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function TextEntry({
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="text-md w-full"
|
||||
className="w-full"
|
||||
placeholder={placeholder}
|
||||
type="text"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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%" }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user