diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 65a3430269..5830b20555 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -484,11 +484,15 @@ "reorderHandle": "Drag to reorder", "saving": "Saving…", "saved": "Saved", - "friendlyName": { - "edit": "Edit camera display name", - "title": "Edit Display Name", - "description": "Set the friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.", - "rename": "Rename" + "details": { + "edit": "Edit camera details", + "title": "Edit Camera Details", + "description": "Update the display name and external URL used for this camera throughout the Frigate UI.", + "friendlyNameLabel": "Display Name", + "friendlyNameHelp": "Friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.", + "webuiUrlLabel": "Camera Web UI URL", + "webuiUrlHelp": "URL to visit the camera's web UI directly from the Debug view. Leave blank to disable the link.", + "webuiUrlInvalid": "Must be a valid URL (e.g., https://example.com)." } }, "cameraConfig": { diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index b43baf170f..212b32389a 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -36,7 +36,15 @@ import axios from "axios"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator"; -import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, @@ -53,6 +61,17 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; const REORDER_SAVED_INDICATOR_MS = 1500; @@ -482,7 +501,7 @@ function EnabledCameraRow({ - @@ -519,25 +538,91 @@ function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) { ); } -type CameraFriendlyNameEditorProps = { +type CameraDetailsEditorProps = { cameraName: string; onConfigChanged: () => Promise; }; -function CameraFriendlyNameEditor({ +type CameraDetailsFormValues = { + friendlyName: string; + webuiUrl: string; +}; + +function CameraDetailsEditor({ cameraName, onConfigChanged, -}: CameraFriendlyNameEditorProps) { +}: CameraDetailsEditorProps) { const { t } = useTranslation(["views/settings", "common"]); const { data: config } = useSWR("config"); const [open, setOpen] = useState(false); const [isSaving, setIsSaving] = useState(false); const currentFriendlyName = config?.cameras?.[cameraName]?.friendly_name; + const currentWebuiUrl = config?.cameras?.[cameraName]?.webui_url; - const onSave = useCallback( - async (text: string) => { + const formSchema = useMemo( + () => + z.object({ + friendlyName: z.string(), + webuiUrl: z.string().refine( + (val) => { + const trimmed = val.trim(); + if (!trimmed) return true; + try { + new URL(trimmed); + return true; + } catch { + return false; + } + }, + { + message: t("cameraManagement.streams.details.webuiUrlInvalid", { + ns: "views/settings", + }), + }, + ), + }), + [t], + ); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + friendlyName: currentFriendlyName ?? "", + webuiUrl: currentWebuiUrl ?? "", + }, + }); + + // Reset form values from config whenever the dialog is opened. + useEffect(() => { + if (open) { + form.reset({ + friendlyName: currentFriendlyName ?? "", + webuiUrl: currentWebuiUrl ?? "", + }); + } + }, [open, currentFriendlyName, currentWebuiUrl, form]); + + const onSubmit = useCallback( + async (values: CameraDetailsFormValues) => { if (isSaving) return; + + // only send fields the user actually changed + const newFriendly = values.friendlyName.trim() || null; + const newWebui = values.webuiUrl.trim() || null; + const cameraUpdate: Record = {}; + if (newFriendly !== (currentFriendlyName ?? null)) { + cameraUpdate.friendly_name = newFriendly; + } + if (newWebui !== (currentWebuiUrl ?? null)) { + cameraUpdate.webui_url = newWebui; + } + + if (Object.keys(cameraUpdate).length === 0) { + setOpen(false); + return; + } + setIsSaving(true); try { @@ -545,9 +630,7 @@ function CameraFriendlyNameEditor({ requires_restart: 0, config_data: { cameras: { - [cameraName]: { - friendly_name: text.trim() || null, - }, + [cameraName]: cameraUpdate, }, }, }); @@ -573,10 +656,17 @@ function CameraFriendlyNameEditor({ setIsSaving(false); } }, - [cameraName, isSaving, onConfigChanged, t], + [ + cameraName, + currentFriendlyName, + currentWebuiUrl, + isSaving, + onConfigChanged, + t, + ], ); - const renameLabel = t("cameraManagement.streams.friendlyName.rename", { + const editLabel = t("cameraManagement.streams.details.edit", { ns: "views/settings", }); @@ -588,30 +678,107 @@ function CameraFriendlyNameEditor({ variant="ghost" size="icon" className="size-7" - aria-label={renameLabel} + aria-label={editLabel} onClick={() => setOpen(true)} disabled={isSaving} > - {renameLabel} + {editLabel} - + + + + + {t("cameraManagement.streams.details.title", { + ns: "views/settings", + })} + + + {t("cameraManagement.streams.details.description", { + ns: "views/settings", + })} + + + + + ( + + + {t("cameraManagement.streams.details.friendlyNameLabel", { + ns: "views/settings", + })} + + + + + + {t("cameraManagement.streams.details.friendlyNameHelp", { + ns: "views/settings", + })} + + + + )} + /> + ( + + + {t("cameraManagement.streams.details.webuiUrlLabel", { + ns: "views/settings", + })} + + + + + + {t("cameraManagement.streams.details.webuiUrlHelp", { + ns: "views/settings", + })} + + + + )} + /> + + setOpen(false)} + > + {t("button.cancel", { ns: "common" })} + + + {isSaving ? ( + + + {t("button.saving", { ns: "common" })} + + ) : ( + t("button.save", { ns: "common" }) + )} + + + + + + > ); }
+ {t("cameraManagement.streams.details.friendlyNameHelp", { + ns: "views/settings", + })} +
+ {t("cameraManagement.streams.details.webuiUrlHelp", { + ns: "views/settings", + })} +