Compare commits

...

4 Commits

Author SHA1 Message Date
dependabot[bot]
1d38d5a244
Merge 96846f7bf6 into 6fdd65ddb5 2026-05-30 22:54:19 +07:00
Josh Hawkins
6fdd65ddb5
UI tweaks (#23346)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* remove redundant per-view toasters in settings

* add variants to standardize dialog footer button layouts

* remove text-md

this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html

* make wizard footers consistent with dialog footers

* consistent destructive button style

remove text-white from individual buttons and add it to the variant
2026-05-29 16:00:30 -06:00
Josh Hawkins
4b6fa49449
Miscellaneous fixes (#23335)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* stabilize chart options to stop ApexCharts updateOptions running on every stats tick

* constrain height of export dialog

* stop audio maintainer when deleting a camera

* run face register and recognize API handlers in threadpool
2026-05-29 06:53:17 -06:00
Josh Hawkins
bc65713ae4
Clone camera settings (#23339)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* add clone dialog

* i18n

* tweaks

* add to camera management pane

* add e2e test

* optional disable portal prop

* radio and checkbox tweaks

* tweak i18n

* add select all/select none

* fixes

* reset form only on open transition

* unselect all targets for existing camera

* fix test

* reorder sections for save and collapse to single put for new camera

* change source and allow cloning to multiple cameras

* tweak language

* fix overflowing text in save all popover

* tweaks

* fix per label object masks

* use grid for source and target

* language tweak
2026-05-28 17:44:06 -06:00
97 changed files with 2756 additions and 644 deletions

View File

@ -280,7 +280,7 @@ async def create_face(request: Request, name: str):
success response with details about the registration, or an error if face recognition
is not enabled or the image cannot be processed.""",
)
async def register_face(request: Request, name: str, file: UploadFile):
def register_face(request: Request, name: str, file: UploadFile):
if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse(
status_code=400,
@ -288,7 +288,7 @@ async def register_face(request: Request, name: str, file: UploadFile):
)
context: EmbeddingsContext = request.app.embeddings
result = None if context is None else context.register_face(name, await file.read())
result = None if context is None else context.register_face(name, file.file.read())
if not isinstance(result, dict):
return JSONResponse(
@ -313,7 +313,7 @@ async def register_face(request: Request, name: str, file: UploadFile):
registered faces in the system. Returns the recognized face name and confidence score,
or an error if face recognition is not enabled or the image cannot be processed.""",
)
async def recognize_face(request: Request, file: UploadFile):
def recognize_face(request: Request, file: UploadFile):
if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse(
status_code=400,
@ -321,7 +321,7 @@ async def recognize_face(request: Request, file: UploadFile):
)
context: EmbeddingsContext = request.app.embeddings
result = context.recognize_face(await file.read())
result = context.recognize_face(file.file.read())
if not isinstance(result, dict):
return JSONResponse(

View File

@ -94,9 +94,21 @@ class AudioProcessor(FrigateProcess):
self.camera_metrics = camera_metrics
self.config = config
def __stop_audio_thread(self, camera: str) -> None:
thread = self.audio_threads.pop(camera, None)
if thread is None:
return
thread.stop()
thread.join(10)
if thread.is_alive():
self.logger.warning(f"Audio maintainer thread for {camera} is still alive")
else:
self.logger.info(f"Audio maintainer stopped for {camera}")
def run(self) -> None:
self.pre_run_setup(self.config.logger)
audio_threads: dict[str, AudioEventMaintainer] = {}
self.audio_threads: dict[str, AudioEventMaintainer] = {}
threading.current_thread().name = "process:audio_manager"
@ -120,12 +132,13 @@ class AudioProcessor(FrigateProcess):
CameraConfigUpdateEnum.add,
CameraConfigUpdateEnum.audio,
CameraConfigUpdateEnum.ffmpeg,
CameraConfigUpdateEnum.remove,
],
)
def spawn_if_needed(camera: CameraConfig) -> None:
name = camera.name
if name is None or name in audio_threads:
if name is None or name in self.audio_threads:
return
if not camera.enabled or not camera.audio.enabled:
return
@ -139,7 +152,7 @@ class AudioProcessor(FrigateProcess):
self.transcription_model_runner,
self.stop_event, # type: ignore[arg-type]
)
audio_threads[name] = thread
self.audio_threads[name] = thread
thread.start()
self.logger.info(f"Audio maintainer started for {name}")
@ -148,21 +161,31 @@ class AudioProcessor(FrigateProcess):
self.logger.info(f"Audio processor started (pid: {self.pid})")
# poll for newly added cameras or cameras flipped to audio.enabled at runtime
# poll for newly added/removed cameras or cameras flipped to
# audio.enabled at runtime
while not self.stop_event.wait(timeout=1.0):
config_subscriber.check_for_updates()
updated_topics = config_subscriber.check_for_updates()
# stop maintainers for removed cameras so their ffmpeg process is
# torn down and they stop touching camera_metrics (which the camera
# maintainer has already popped for the removed camera)
for removed_camera in updated_topics.get(
CameraConfigUpdateEnum.remove.name, []
):
self.__stop_audio_thread(removed_camera)
for camera in self.config.cameras.values():
spawn_if_needed(camera)
config_subscriber.stop()
for thread in audio_threads.values():
for thread in self.audio_threads.values():
thread.join(1)
if thread.is_alive():
self.logger.info(f"Waiting for thread {thread.name:s} to exit")
thread.join(10)
for thread in audio_threads.values():
for thread in self.audio_threads.values():
if thread.is_alive():
self.logger.warning(f"Thread {thread.name} is still alive")
@ -184,6 +207,9 @@ class AudioEventMaintainer(threading.Thread):
self.camera_config = camera
self.camera_metrics = camera_metrics
self.stop_event = stop_event
# per-camera stop signal so a single maintainer can be torn down at
# runtime (e.g. on camera removal) without stopping the whole process
self.camera_stop_event = threading.Event()
self.detector = AudioTfl(stop_event, self.camera_config.audio.num_threads)
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2))
@ -233,7 +259,11 @@ class AudioEventMaintainer(threading.Thread):
self.was_audio_enabled = camera.audio.enabled
def detect_audio(self, audio: np.ndarray) -> None:
if not self.camera_config.audio.enabled or self.stop_event.is_set():
if (
not self.camera_config.audio.enabled
or self.stop_event.is_set()
or self.camera_stop_event.is_set()
):
return
audio_as_float: np.ndarray = audio.astype(np.float32)
@ -352,11 +382,15 @@ class AudioEventMaintainer(threading.Thread):
self.logger.error(f"Error reading audio data from ffmpeg process: {e}")
log_and_restart()
def stop(self) -> None:
"""Signal this maintainer to exit its run loop and clean up."""
self.camera_stop_event.set()
def run(self) -> None:
if self.camera_config.enabled:
self.start_or_restart_ffmpeg()
while not self.stop_event.is_set():
while not self.stop_event.is_set() and not self.camera_stop_event.is_set():
# check if there is an updated config
self.config_subscriber.check_for_updates()

View File

@ -0,0 +1,181 @@
/**
* Camera clone dialog E2E tests.
*
* Covers the design invariants that don't depend on per-camera resolution
* differences in the mock fixture:
* 1. Dialog opens from the "Clone settings" button below Add/Delete.
* 2. A source camera must be chosen inside the dialog before cloning.
* 3. "Stream URLs and roles" is forced on and disabled for new-camera target.
* 4. Cloning to a new camera issues a single add PUT and shows a restart prompt.
* 5. The existing-camera target selects multiple destinations via a switch
* popover (with an "All cameras" toggle and source exclusion); the closed
* trigger summarizes the selection by name or as "All cameras".
*
* The spatial-mismatch warning path is exercised in unit-level review and via
* manual QA the shared mock fixture ships every camera at 1280×720. The
* existing-camera PUT fan-out is likewise not asserted here: the mock cameras
* are identical apart from stream URLs (which existing-camera clones never
* copy) and the schema mock is empty, so a clone onto them produces no diff
* and no PUT. That path is covered by unit-level review and manual QA.
*/
import { test, expect } from "../fixtures/frigate-test";
async function openCloneDialog(frigateApp: {
page: import("@playwright/test").Page;
}) {
await frigateApp.page
.getByRole("button", { name: /^Clone settings$/i })
.click();
await expect(frigateApp.page.getByRole("dialog")).toBeVisible();
}
async function selectSource(
frigateApp: { page: import("@playwright/test").Page },
source: string,
) {
await frigateApp.page.getByRole("dialog").getByRole("combobox").click();
await frigateApp.page
.getByRole("option", { name: source, exact: true })
.click();
}
test.describe("Camera clone dialog @medium @mobile", () => {
test.beforeEach(async ({ frigateApp }) => {
await frigateApp.goto("/settings?page=cameraManagement");
await expect(
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
).toBeVisible();
});
test("opens the dialog from the Clone settings button", async ({
frigateApp,
}) => {
await openCloneDialog(frigateApp);
await expect(
frigateApp.page.getByRole("dialog").getByText(/Clone camera settings/i),
).toBeVisible();
// The Clone button is disabled until a source (and target) is chosen.
await expect(
frigateApp.page.getByRole("button", { name: /^Clone$/i }),
).toBeDisabled();
});
test("forces Stream URLs and roles on for new-camera target", async ({
frigateApp,
}) => {
await openCloneDialog(frigateApp);
await selectSource(frigateApp, "Front Door");
// The "New camera" radio is selected by default; the Streams group renders
// the ffmpeg_live checkbox as forced-checked and disabled.
const streamsLabel = frigateApp.page
.locator("label")
.filter({ hasText: /Stream URLs and roles/i });
await expect(streamsLabel).toBeVisible();
const streamsCheckbox = streamsLabel.getByRole("checkbox");
await expect(streamsCheckbox).toBeChecked();
await expect(streamsCheckbox).toBeDisabled();
});
test("issues a single add PUT and shows restart toast for new-camera target", async ({
frigateApp,
}) => {
const requests: { body: unknown }[] = [];
await frigateApp.page.route("**/api/config/set", async (route) => {
const body = route.request().postDataJSON();
requests.push({ body });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true, require_restart: false }),
});
});
await frigateApp.goto("/settings?page=cameraManagement");
await expect(
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
).toBeVisible();
await openCloneDialog(frigateApp);
await selectSource(frigateApp, "Front Door");
const nameInput = frigateApp.page.getByPlaceholder(
/e\.g\., back_door or Back Door/i,
);
await nameInput.fill("clone_target_one");
// With a source picked and a valid name, changeCount > 0 enables Clone.
await expect(
frigateApp.page.getByRole("button", { name: /^Clone$/i }),
).toBeEnabled({ timeout: 5_000 });
await frigateApp.page.getByRole("button", { name: /^Clone$/i }).click();
// New-camera clones bundle into a single atomic add PUT (avoids
// per-section validation ordering issues).
await expect.poll(() => requests.length, { timeout: 10_000 }).toBe(1);
const firstBody = requests[0].body as {
requires_restart?: number;
update_topic?: string;
};
expect(firstBody.update_topic).toMatch(
/config\/cameras\/clone_target_one\/add/,
);
expect(firstBody.requires_restart).toBe(1);
// The toast offers a Restart action because new-camera always needs restart.
// .first() avoids strict-mode rejection when both the toast action and the
// RestartDialog trigger render concurrently.
await expect(
frigateApp.page.getByRole("button", { name: /Restart/i }).first(),
).toBeVisible({ timeout: 8_000 });
});
test("selects multiple existing destination cameras via a switch popover", async ({
frigateApp,
}) => {
await openCloneDialog(frigateApp);
await selectSource(frigateApp, "Front Door");
await frigateApp.page
.getByRole("radio", { name: /Existing cameras/i })
.click();
const dialog = frigateApp.page.getByRole("dialog");
// The destination trigger starts with the empty-selection placeholder.
await dialog
.getByRole("button", { name: /Select at least one camera/i })
.click();
// The chosen source is excluded from the destination switch list.
await expect(
dialog.getByRole("switch", { name: /Backyard/i }),
).toBeVisible();
await expect(dialog.getByRole("switch", { name: /Garage/i })).toBeVisible();
await expect(
dialog.getByRole("switch", { name: /^Front Door$/i }),
).toHaveCount(0);
// Selecting a single camera summarizes by name once the popover closes.
await dialog.getByRole("switch", { name: /Backyard/i }).click();
await frigateApp.page.keyboard.press("Escape");
await expect(
dialog.getByRole("button", { name: /^Backyard$/i }),
).toBeVisible();
// Reopen and select everything; the trigger collapses to "All cameras".
await dialog.getByRole("button", { name: /^Backyard$/i }).click();
await dialog.getByRole("switch", { name: /^All cameras$/i }).click();
await frigateApp.page.keyboard.press("Escape");
await expect(
dialog.getByRole("button", { name: /^All cameras$/i }),
).toBeVisible();
});
});

View File

@ -544,6 +544,92 @@
"normal": "Normal",
"dedicatedLpr": "Dedicated LPR",
"saveSuccess": "Updated camera type for {{cameraName}}. Restart Frigate to apply the changes."
},
"clone": {
"sectionTitle": "Clone settings",
"sectionDescription": "Copy configuration from one camera to another camera or a new one.",
"button": "Clone settings",
"title": "Clone camera settings",
"description": "Copy a camera's configuration to one or more other cameras or a new camera. Identity (name, friendly name, web UI URL, display order) is never copied.",
"source": {
"label": "Source camera",
"placeholder": "Select a source camera",
"required": "Select a source camera"
},
"target": {
"legend": "Target",
"newRadio": "New camera",
"newNameLabel": "Camera name",
"newNamePlaceholder": "e.g., back_door or Back Door",
"newNameRequired": "Camera name is required",
"newNameInvalid": "Invalid camera name",
"newNameCollision": "A camera with this name already exists",
"newStreamsForced": "Streams are always copied for a new camera.",
"existingCamerasRadio": "Existing cameras",
"allCameras": "All cameras",
"existingPlaceholder": "Select at least one camera",
"existingDisabled": "No other cameras to copy to"
},
"categories": {
"legend": "Settings to clone",
"description": "Choose which settings to copy from the source camera.",
"selectAll": "Select all",
"selectNone": "Select none",
"resetDefaults": "Reset to defaults",
"general": "General",
"spatial": "Spatial settings",
"streams": "Streams",
"spatialWarningTitle": "Resolution mismatch",
"spatialWarning": "Source camera {{srcCamera}} detect resolution ({{srcWidth}}×{{srcHeight}}) differs from: {{cameras}}. Polygons may not align on those cameras. These defaults are off; enable to copy as-is.",
"restartHint": "Restart required",
"items": {
"record": "Recording",
"snapshots": "Snapshots",
"review": "Review",
"motion": "Motion detection",
"objects": "Objects",
"audio": "Audio detection",
"audio_transcription": "Audio transcription",
"notifications": "Notifications",
"birdseye": "Birdseye",
"mqtt": "MQTT",
"timestamp_style": "Timestamp style",
"onvif": "ONVIF",
"lpr": "License plate recognition",
"face_recognition": "Face recognition",
"semantic_search": "Semantic search",
"genai": "Generative AI",
"type": "Camera type (normal / dedicated LPR)",
"profiles": "Profiles",
"detect": "Detect dimensions",
"zones": "Zones",
"motion_mask": "Motion masks",
"object_masks": "Object masks",
"ffmpeg_live": "Stream URLs and roles"
}
},
"footer": {
"changeCount_zero": "No changes selected",
"changeCount_one": "{{count}} change will be applied",
"changeCount_other": "{{count}} changes will be applied",
"restartNeeded": "Restart will be required for some changes.",
"liveOnly": "All changes will apply live without a restart.",
"submit": "Clone",
"submitting": "Cloning…"
},
"toast": {
"success": "Settings copied to {{cameraName}}",
"successWithRestart": "Settings copied to {{cameraName}}. Restart Frigate to apply all changes.",
"successMulti_one": "Settings copied to {{count}} camera",
"successMulti_other": "Settings copied to {{count}} cameras",
"successMultiWithRestart_one": "Settings copied to {{count}} camera. Restart Frigate to apply all changes.",
"successMultiWithRestart_other": "Settings copied to {{count}} cameras. Restart Frigate to apply all changes.",
"partialFailure": "{{successCount}} sections applied; '{{failedSection}}' failed: {{errorMessage}}",
"partialFailureMulti": "Copied to {{successCount}} camera(s); failed for {{failed}}: {{errorMessage}}",
"newCameraPartialFailure": "Camera {{cameraName}} was created but some settings failed to copy: {{errorMessage}}",
"sourceMissing": "Source camera no longer exists",
"submitError": "Failed to clone camera: {{errorMessage}}"
}
}
},
"cameraReview": {

View File

@ -107,7 +107,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
<FormLabel>{t("form.user")}</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
autoFocus
autoCapitalize="off"
autoCorrect="off"
@ -125,7 +125,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
<FormLabel>{t("form.password")}</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
type="password"
{...field}
/>

View File

@ -257,7 +257,7 @@ export function ExportCard({
{editName && (
<>
<Input
className="text-md mt-3"
className="mt-3"
type="search"
placeholder={editName?.original}
value={
@ -275,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()}

View File

@ -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}

View File

@ -48,7 +48,7 @@ export default function ChatSettings({
<div className="my-3 space-y-5 py-3 md:mt-0 md:py-0">
<div className="space-y-3">
<div className="space-y-0.5">
<div className="text-md">{t("settings.show_stats.title")}</div>
<div>{t("settings.show_stats.title")}</div>
<div className="text-xs text-muted-foreground">
{t("settings.show_stats.desc")}
</div>
@ -77,7 +77,7 @@ export default function ChatSettings({
<DropdownMenuSeparator />
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<Label htmlFor="auto-scroll" className="text-md cursor-pointer">
<Label htmlFor="auto-scroll" className="cursor-pointer">
{t("settings.auto_scroll.title")}
</Label>
<div className="text-xs text-muted-foreground">

View File

@ -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",
)}

View File

@ -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>

View File

@ -458,7 +458,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>

View File

@ -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>

View File

@ -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";
@ -491,7 +490,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 && (
@ -521,7 +519,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",
)}
@ -788,7 +786,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}
/>

View File

@ -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";
@ -1236,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);

View File

@ -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">

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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) || ""}

View File

@ -15,6 +15,7 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
@ -847,7 +848,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 +974,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 +985,6 @@ export function CameraGroupEdit({
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label={t("button.save", { ns: "common" })}
type="submit"
>
@ -998,7 +997,7 @@ export function CameraGroupEdit({
t("button.save", { ns: "common" })
)}
</Button>
</div>
</DialogFooter>
</form>
</Form>
);

View File

@ -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")}

View File

@ -56,18 +56,25 @@ export function CameraLineGraph({
});
}, [t, timeFormat]);
const updateTimesRef = useRef(updateTimes);
useEffect(() => {
updateTimesRef.current = updateTimes;
}, [updateTimes]);
const formatTime = useCallback(
(val: unknown) => {
return formatUnixTimestampToDateTime(
updateTimes[Math.round(val as number)],
{
timezone: config?.ui.timezone,
date_format: format,
locale,
},
);
const times = updateTimesRef.current;
const ts = times[Math.round(val as number)];
if (isNaN(ts)) {
return "";
}
return formatUnixTimestampToDateTime(ts, {
timezone: config?.ui.timezone,
date_format: format,
locale,
});
},
[config?.ui.timezone, format, locale, updateTimes],
[config?.ui.timezone, format, locale],
);
const options = useMemo(() => {
@ -211,18 +218,25 @@ export function EventsPerSecondsLineGraph({
});
}, [t, timeFormat]);
const updateTimesRef = useRef(updateTimes);
useEffect(() => {
updateTimesRef.current = updateTimes;
}, [updateTimes]);
const formatTime = useCallback(
(val: unknown) => {
return formatUnixTimestampToDateTime(
updateTimes[Math.round(val as number) - 1],
{
timezone: config?.ui.timezone,
date_format: format,
locale,
},
);
const times = updateTimesRef.current;
const ts = times[Math.round(val as number) - 1];
if (isNaN(ts)) {
return "";
}
return formatUnixTimestampToDateTime(ts, {
timezone: config?.ui.timezone,
date_format: format,
locale,
});
},
[config?.ui.timezone, format, locale, updateTimes],
[config?.ui.timezone, format, locale],
);
const options = useMemo(() => {

View File

@ -61,6 +61,11 @@ export function ThresholdBarGraph({
});
}, [t, timeFormat]);
const updateTimesRef = useRef(updateTimes);
useEffect(() => {
updateTimesRef.current = updateTimes;
}, [updateTimes]);
const formatTime = useCallback(
(val: unknown) => {
const dateIndex = Math.round(val as number);
@ -69,16 +74,18 @@ export function ThresholdBarGraph({
if (dateIndex < 0) {
timeOffset = 5 * Math.abs(dateIndex);
}
return formatUnixTimestampToDateTime(
updateTimes[Math.max(1, dateIndex) - 1] - timeOffset,
{
timezone: config?.ui.timezone,
date_format: format,
locale,
},
);
const times = updateTimesRef.current;
const ts = times[Math.max(1, dateIndex) - 1] - timeOffset;
if (isNaN(ts)) {
return "";
}
return formatUnixTimestampToDateTime(ts, {
timezone: config?.ui.timezone,
date_format: format,
locale,
});
},
[config?.ui.timezone, format, locale, updateTimes],
[config?.ui.timezone, format, locale],
);
const options = useMemo(() => {

View File

@ -119,7 +119,7 @@ export default function IconPicker({
placeholder={t("iconPicker.search.placeholder", {
ns: "components/icons",
})}
className="text-md mb-3 md:text-sm"
className="mb-3 md:text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>

View File

@ -696,7 +696,7 @@ export default function InputWithTags({
onFocus={handleInputFocus}
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
className="text-md h-9 pr-32"
className="h-9 pr-32"
placeholder={t("placeholder.search")}
/>
<div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5">

View File

@ -112,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>
@ -134,7 +130,6 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
<FormLabel>{idLabel ?? t("label.ID")}</FormLabel>
<FormControl>
<Input
className="text-md"
placeholder={placeholderId}
disabled={idDisabled}
{...field}

View File

@ -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" })}

View File

@ -77,7 +77,7 @@ export default function TextEntry({
<FormControl>
<Input
{...field}
className="text-md w-full"
className="w-full"
placeholder={placeholder}
type="text"
/>

View File

@ -276,7 +276,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 && (

View File

@ -213,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>

View File

@ -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>

View File

@ -411,36 +411,30 @@ export default function CreateUserDialog({
)}
/>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter className="pt-2">
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>

View File

@ -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}

View File

@ -113,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={() => {

View File

@ -70,38 +70,31 @@ export default function DeleteRoleDialog({
</div>
</div>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
disabled={isLoading}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
className="flex flex-1"
aria-label={t("button.delete", { ns: "common" })}
variant="destructive"
disabled={isLoading}
onClick={handleDelete}
type="button"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("roles.dialog.deleteRole.deleting")}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
aria-label={t("button.delete", { ns: "common" })}
variant="destructive"
disabled={isLoading}
onClick={handleDelete}
type="button"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("roles.dialog.deleteRole.deleting")}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -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>

View File

@ -46,27 +46,21 @@ export default function DeleteUserDialog({
</p>
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
className="flex flex-1 text-white"
onClick={onDelete}
>
{t("button.delete", { ns: "common" })}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
onClick={onDelete}
>
{t("button.delete", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -105,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
@ -159,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>

View File

@ -287,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"
}
>
@ -794,7 +794,6 @@ export function ExportContent({
)}
<Input
className="text-md"
type="search"
placeholder={t("export.name.placeholder")}
value={name}
@ -835,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) =>
@ -988,7 +985,6 @@ export function ExportContent({
{t("export.multiCamera.nameLabel")}
</Label>
<Input
className="text-md"
type="search"
placeholder={t("export.multiCamera.namePlaceholder")}
value={name}
@ -1028,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) =>
@ -1049,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}
@ -1086,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()}
>

View File

@ -85,7 +85,7 @@ export default function ImagePicker({
<Input
type="text"
placeholder={t("imagePicker.search.placeholder")}
className="text-md mb-3 md:text-sm"
className="mb-3 md:text-sm"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);

View File

@ -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>
);

View File

@ -117,30 +117,23 @@ export default function RoleChangeDialog({
</Select>
</div>
<DialogFooter className="flex gap-3 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
className="flex flex-1"
onClick={() => onSave(selectedRole)}
type="button"
disabled={selectedRole === currentRole}
>
{t("button.save", { ns: "common" })}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
onClick={() => onSave(selectedRole)}
type="button"
disabled={selectedRole === currentRole}
>
{t("button.save", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -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>

View File

@ -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}

View File

@ -145,7 +145,7 @@ export function AnnotationSettingsPane({
return (
<div className="p-4">
<div className="text-md mb-2">
<div className="mb-2">
{t("trackingDetails.annotationSettings.title")}
</div>

View File

@ -131,7 +131,7 @@ export default function CreateFaceWizardDialog({
forbiddenPattern={/#/}
forbiddenErrorMessage={t("description.nameCannotContainHash")}
>
<div className="flex justify-end py-2">
<div className="flex flex-col-reverse gap-2 py-2 sm:flex-row sm:justify-end">
<Button variant="select" type="submit">
{t("button.next", { ns: "common" })}
</Button>
@ -144,7 +144,7 @@ export default function CreateFaceWizardDialog({
{t("steps.description.uploadFace", { name })}
</div>
<ImageEntry onSave={onUploadImage}>
<div className="flex justify-end py-2">
<div className="flex flex-col-reverse gap-2 py-2 sm:flex-row sm:justify-end">
<Button variant="select" type="submit">
{t("button.next", { ns: "common" })}
</Button>
@ -173,7 +173,7 @@ export default function CreateFaceWizardDialog({
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
<div className="flex justify-end">
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
variant="select"
onClick={() => {

View File

@ -22,6 +22,7 @@ type SaveAllPreviewPopoverProps = {
className?: string;
align?: "start" | "center" | "end";
side?: "top" | "bottom" | "left" | "right";
disablePortal?: boolean;
};
export default function SaveAllPreviewPopover({
@ -29,6 +30,7 @@ export default function SaveAllPreviewPopover({
className,
align = "end",
side = "bottom",
disablePortal = false,
}: SaveAllPreviewPopoverProps) {
const { t } = useTranslation(["views/settings", "common"]);
const [open, setOpen] = useState(false);
@ -67,6 +69,7 @@ export default function SaveAllPreviewPopover({
<PopoverContent
align={align}
side={side}
disablePortal={disablePortal}
className="w-[90vw] max-w-sm border bg-background p-4 shadow-lg"
onOpenAutoFocus={(event) => event.preventDefault()}
>
@ -108,13 +111,13 @@ export default function SaveAllPreviewPopover({
}`}
className="rounded-md border border-secondary bg-background_alt p-2"
>
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs">
<span className="text-muted-foreground">
{t("saveAllPreview.scope.label", {
ns: "views/settings",
})}
</span>
<span className="truncate">{scopeLabel}</span>
<span className="min-w-0 truncate">{scopeLabel}</span>
{item.profileName && (
<>
<span className="text-muted-foreground">
@ -122,7 +125,7 @@ export default function SaveAllPreviewPopover({
ns: "views/settings",
})}
</span>
<span className="truncate font-medium">
<span className="min-w-0 truncate font-medium">
{item.profileName}
</span>
</>
@ -132,7 +135,7 @@ export default function SaveAllPreviewPopover({
ns: "views/settings",
})}
</span>
<span className="break-all font-mono">
<span className="min-w-0 break-all font-mono">
{item.fieldPath}
</span>
<span className="text-muted-foreground">
@ -140,7 +143,7 @@ export default function SaveAllPreviewPopover({
ns: "views/settings",
})}
</span>
<span className="whitespace-pre-wrap break-words font-mono">
<span className="min-w-0 whitespace-pre-wrap break-all font-mono">
{formatValue(item.value)}
</span>
</div>

View File

@ -1569,7 +1569,7 @@ function ObjectDetailsTab({
{t("button.yes", { ns: "common" })}
</Button>
<Button
className="flex-1 text-white"
className="flex-1"
aria-label={t("button.no", { ns: "common" })}
variant="destructive"
onClick={() => {
@ -1706,7 +1706,7 @@ function ObjectDetailsTab({
) : (
<div className="flex flex-col gap-2">
<Textarea
className="text-md h-32 md:text-sm"
className="h-32 md:text-sm"
placeholder={t("details.description.placeholder")}
value={desc}
onChange={(e) => setDesc(e.target.value)}

View File

@ -821,7 +821,7 @@ export function TrackingDetails({
</div>
<div className="flex items-center gap-2">
<span className="capitalize">{label}</span>
<div className="md:text-md flex items-center text-xs text-secondary-foreground">
<div className="flex items-center text-xs text-secondary-foreground">
{formattedStart ?? ""}
{event.end_time != null ? (
<> - {formattedEnd}</>
@ -1072,7 +1072,7 @@ function LifecycleIconRow({
<div className="ml-2 flex w-full min-w-0 flex-1">
<div className="flex flex-col">
<div className="text-md flex items-start break-words text-left">
<div className="flex items-start break-words text-left">
{getLifecycleItemDescription(item)}
</div>
{/* Only show Score/Ratio/Area for object events, not for audio (heard) or manual API (external) events */}

View File

@ -121,28 +121,22 @@ export default function DeleteCameraDialog({
))}
</SelectContent>
</Select>
<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={handleClose}
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={handleDelete}
disabled={!selectedCamera}
>
{t("button.delete", { ns: "common" })}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={handleClose}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
onClick={handleDelete}
disabled={!selectedCamera}
>
{t("button.delete", { ns: "common" })}
</Button>
</DialogFooter>
</>
) : (
@ -173,39 +167,31 @@ export default function DeleteCameraDialog({
{t("cameraManagement.deleteCameraDialog.deleteExports")}
</Label>
</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.back", { ns: "common" })}
onClick={handleBack}
type="button"
disabled={isDeleting}
>
{t("button.back", { ns: "common" })}
</Button>
<Button
variant="destructive"
className="flex flex-1 text-white"
onClick={handleConfirmDelete}
disabled={isDeleting}
>
{isDeleting ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>
{t(
"cameraManagement.deleteCameraDialog.confirmButton",
)}
</span>
</div>
) : (
t("cameraManagement.deleteCameraDialog.confirmButton")
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.back", { ns: "common" })}
onClick={handleBack}
type="button"
disabled={isDeleting}
>
{t("button.back", { ns: "common" })}
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={isDeleting}
>
{isDeleting ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>
{t("cameraManagement.deleteCameraDialog.confirmButton")}
</span>
</div>
) : (
t("cameraManagement.deleteCameraDialog.confirmButton")
)}
</Button>
</DialogFooter>
</>
)}

View File

@ -173,7 +173,7 @@ export function FrigatePlusDialog({
{t("button.yes", { ns: "common" })}
</Button>
<Button
className="flex-1 text-white"
className="flex-1"
aria-label={t("button.no", { ns: "common" })}
variant="destructive"
onClick={() => {

View File

@ -7,9 +7,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
import FilterSwitch from "@/components/filter/FilterSwitch";
@ -77,7 +75,7 @@ export default function MultiSelectDialog({
/>
))}
</div>
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
<DialogFooter className="pt-4">
<Button type="button" onClick={() => setOpen(false)}>
{t("button.cancel")}
</Button>

View File

@ -144,18 +144,13 @@ export default function OptionAndInputDialog({
<label className="text-sm font-medium text-secondary-foreground">
{nameLabel}
</label>
<Input
className="text-md"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-secondary-foreground">
{descriptionLabel}
</label>
<Textarea
className="text-md"
value={descriptionValue}
onChange={(e) => setDescriptionValue(e.target.value)}
rows={2}
@ -164,10 +159,9 @@ export default function OptionAndInputDialog({
</div>
)}
<DialogFooter className={cn("pt-2", isMobile && "gap-2")}>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={() => {
setOpen(false);

View File

@ -349,7 +349,7 @@ function TimeRangeFilterContent({
</PopoverTrigger>
<PopoverContent className="flex flex-row items-center justify-center">
<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={selectedAfterHour}
@ -389,7 +389,7 @@ function TimeRangeFilterContent({
</PopoverTrigger>
<PopoverContent className="flex flex-col items-center">
<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={

View File

@ -9,8 +9,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
type TextEntryDialogProps = {
@ -63,7 +61,7 @@ export default function TextEntryDialog({
forbiddenPattern={forbiddenPattern}
forbiddenErrorMessage={forbiddenErrorMessage}
>
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
<DialogFooter>
<Button
type="button"
disabled={isSaving}

View File

@ -443,7 +443,7 @@ export default function LivePlayer({
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" />
<div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 rounded-lg bg-background/50 p-3 text-center">
<div className="text-md">{t("streamOffline.title")}</div>
<div>{t("streamOffline.title")}</div>
<TbExclamationCircle className="size-6" />
{!isCompact && (
<p className="text-center text-sm">

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,6 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
import axios from "axios";
import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
@ -327,7 +326,6 @@ export default function MotionMaskEditPane({
return (
<>
<Toaster position="top-center" closeButton={true} />
<Heading as="h3" className="my-2">
{polygon.name.length
? t("masksAndZones.motionMasks.edit")

View File

@ -31,7 +31,6 @@ import { FaCheckCircle } from "react-icons/fa";
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
import axios from "axios";
import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
@ -335,7 +334,6 @@ export default function ObjectMaskEditPane({
return (
<>
<Toaster position="top-center" closeButton={true} />
<Heading as="h3" className="my-2">
{polygon.name.length
? t("masksAndZones.objectMasks.edit")

View File

@ -24,12 +24,12 @@ import { toRGBColorString } from "@/utils/canvasUtil";
import { Polygon, PolygonType } from "@/types/canvas";
import { useCallback, useMemo, useState } from "react";
import axios from "axios";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { reviewQueries } from "@/utils/zoneEdutUtil";
import IconWrapper from "../ui/icon-wrapper";
import { buttonVariants } from "@/components/ui/button";
import { Trans, useTranslation } from "react-i18next";
import ActivityIndicator from "../indicators/activity-indicator";
import { cn } from "@/lib/utils";
@ -368,8 +368,6 @@ export default function PolygonItem({
return (
<>
<Toaster position="top-center" closeButton={true} />
<div
key={index}
className="transition-background relative my-1.5 flex flex-row items-center justify-between rounded-lg p-1 duration-100"
@ -511,7 +509,7 @@ export default function PolygonItem({
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={handleDelete}
>
{polygon.polygonSource === "override"

View File

@ -59,9 +59,7 @@ export default function ExploreSettings({
<div className={cn(className, "my-3 space-y-5 py-3 md:mt-0 md:py-0")}>
<div className="space-y-4">
<div className="space-y-0.5">
<div className="text-md">
{t("explore.settings.defaultView.title")}
</div>
<div>{t("explore.settings.defaultView.title")}</div>
<div className="space-y-1 text-xs text-muted-foreground">
{t("explore.settings.defaultView.desc")}
</div>
@ -97,9 +95,7 @@ export default function ExploreSettings({
<DropdownMenuSeparator />
<div className="flex w-full flex-col space-y-4">
<div className="space-y-0.5">
<div className="text-md">
{t("explore.settings.gridColumns.title")}
</div>
<div>{t("explore.settings.gridColumns.title")}</div>
<div className="space-y-1 text-xs text-muted-foreground">
{t("explore.settings.gridColumns.desc")}
</div>
@ -162,9 +158,7 @@ export function SearchTypeContent({
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<div className="space-y-0.5">
<div className="text-md">
{t("explore.settings.searchSource.label")}
</div>
<div>{t("explore.settings.searchSource.label")}</div>
<div className="space-y-1 text-xs text-muted-foreground">
{t("explore.settings.searchSource.desc")}
</div>

View File

@ -24,7 +24,6 @@ import { Label } from "../ui/label";
import PolygonEditControls from "./PolygonEditControls";
import { FaCheckCircle } from "react-icons/fa";
import axios from "axios";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
import ActivityIndicator from "../indicators/activity-indicator";
@ -628,7 +627,6 @@ export default function ZoneEditPane({
return (
<>
<Toaster position="top-center" closeButton={true} />
<Heading as="h3" className="my-2">
{polygon.name.length
? t("masksAndZones.zones.edit")
@ -709,7 +707,7 @@ export default function ZoneEditPane({
<FormLabel>{t("masksAndZones.zones.inertia.title")}</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="3"
{...field}
/>
@ -734,7 +732,7 @@ export default function ZoneEditPane({
</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="0"
{...field}
/>
@ -864,7 +862,7 @@ export default function ZoneEditPane({
</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]"
{...field}
onFocus={() => setActiveLine(1)}
onBlur={() => setActiveLine(undefined)}
@ -891,7 +889,7 @@ export default function ZoneEditPane({
</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]"
{...field}
onFocus={() => setActiveLine(2)}
onBlur={() => setActiveLine(undefined)}
@ -918,7 +916,7 @@ export default function ZoneEditPane({
</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]"
{...field}
onFocus={() => setActiveLine(3)}
onBlur={() => setActiveLine(undefined)}
@ -945,7 +943,7 @@ export default function ZoneEditPane({
</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]"
{...field}
onFocus={() => setActiveLine(4)}
onBlur={() => setActiveLine(undefined)}
@ -972,7 +970,7 @@ export default function ZoneEditPane({
</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]"
{...field}
/>
</FormControl>

View File

@ -171,7 +171,7 @@ export default function Step1NameCamera({
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
className="h-8"
placeholder={t("cameraWizard.step1.cameraNamePlaceholder")}
{...field}
/>
@ -192,7 +192,7 @@ export default function Step1NameCamera({
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
className="h-8"
placeholder="192.168.1.100"
{...field}
/>
@ -212,7 +212,7 @@ export default function Step1NameCamera({
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
className="h-8"
placeholder={t("cameraWizard.step1.usernamePlaceholder")}
{...field}
/>
@ -233,7 +233,7 @@ export default function Step1NameCamera({
<FormControl>
<div className="relative">
<Input
className="text-md h-8 pr-10"
className="h-8 pr-10"
type={showPassword ? "text" : "password"}
placeholder={t(
"cameraWizard.step1.passwordPlaceholder",
@ -316,7 +316,7 @@ export default function Step1NameCamera({
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
className="h-8"
type="text"
{...field}
placeholder="80"
@ -440,7 +440,7 @@ export default function Step1NameCamera({
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
className="h-8"
placeholder="rtsp://username:password@host:port/path"
{...field}
/>
@ -455,7 +455,7 @@ export default function Step1NameCamera({
</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>

View File

@ -626,7 +626,7 @@ function ProbeFooterButtons({
<ActivityIndicator className="size-4" />
{t("cameraWizard.step2.probing")}
</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} disabled className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
@ -649,7 +649,7 @@ function ProbeFooterButtons({
return (
<div className="space-y-4">
<div className="text-sm text-destructive">{probeError}</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>
@ -670,7 +670,7 @@ function ProbeFooterButtons({
// If manual mode, show Continue when test succeeded, otherwise show Test (calls onManualTest)
if (mode === "manual") {
return (
<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>
@ -707,7 +707,7 @@ function ProbeFooterButtons({
// Default probe footer
return (
<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>

View File

@ -731,7 +731,7 @@ export default function Step3StreamConfig({
</div>
)}
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-6 sm:flex-row sm:justify-end">
{onBack && (
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}

View File

@ -490,7 +490,7 @@ export default function Step4Validation({
</div>
</div>
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-6 sm:flex-row sm:justify-end">
{onBack && (
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}

View File

@ -176,20 +176,15 @@ export default function Step1NameAndType({
)}
/>
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1"
>
<div className="flex flex-col-reverse gap-2 pt-4 sm:flex-row sm:justify-end">
<Button type="button" onClick={onCancel} className="sm:flex-1">
{t("button.cancel", { ns: "common" })}
</Button>
<Button
type="submit"
variant="select"
disabled={!form.formState.isValid}
className="flex-1"
className="sm:flex-1"
>
{t("button.next", { ns: "common" })}
</Button>

View File

@ -109,20 +109,15 @@ export default function Step2ConfigureData({
)}
/>
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onBack}
className="flex-1"
>
<div className="flex flex-col-reverse gap-2 pt-4 sm:flex-row sm:justify-end">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="submit"
variant="select"
disabled={!form.formState.isValid}
className="flex-1"
className="sm:flex-1"
>
{t("button.next", { ns: "common" })}
</Button>

View File

@ -181,20 +181,15 @@ export default function Step3ThresholdAndActions({
)}
/>
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onBack}
className="flex-1"
>
<div className="flex flex-col-reverse gap-2 pt-4 sm:flex-row sm:justify-end">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={isLoading}
className="flex-1"
className="sm:flex-1"
variant="select"
>
{isLoading && <ActivityIndicator className="mr-2 size-5" />}

View File

@ -1,6 +1,8 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
@ -59,15 +61,35 @@ const AlertDialogHeader = ({
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const alertDialogFooterVariants = cva(
"flex flex-col-reverse gap-2 sm:flex-row",
{
variants: {
variant: {
// 1-2 action buttons: full-width stacked on mobile, right-aligned auto on desktop.
// [&>button] only targets real button children, so non-button siblings are untouched.
actions: "sm:justify-end [&>button]:w-full sm:[&>button]:w-auto",
// context content (text/popover) alongside actions: space-between on desktop.
// flex-col (not -reverse) keeps the context above the buttons when stacked on mobile.
split: "flex-col sm:items-center sm:justify-between",
// alignment only; never touches children. Escape hatch for unusual content.
plain: "sm:justify-end",
},
},
defaultVariants: {
variant: "actions",
},
},
);
const AlertDialogFooter = ({
className,
variant,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
}: React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof alertDialogFooterVariants>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
className={cn(alertDialogFooterVariants({ variant }), className)}
{...props}
/>
);

View File

@ -11,8 +11,7 @@ const buttonVariants = cva(
variant: {
default: "bg-secondary text-primary hover:bg-secondary/80",
select: "bg-selected text-selected-foreground hover:bg-opacity-90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
destructive: "bg-destructive text-white hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-primary text-primary-foreground hover:bg-primary/90",

View File

@ -1,5 +1,6 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useHistoryBack } from "@/hooks/use-history-back";
@ -107,15 +108,32 @@ const DialogHeader = ({
);
DialogHeader.displayName = "DialogHeader";
const dialogFooterVariants = cva("flex flex-col-reverse gap-2 sm:flex-row", {
variants: {
variant: {
// 1-2 action buttons: full-width stacked on mobile, right-aligned auto on desktop.
// [&>button] only targets real button children, so non-button siblings are untouched.
actions: "sm:justify-end [&>button]:w-full sm:[&>button]:w-auto",
// context content (text/popover) alongside actions: space-between on desktop.
// flex-col (not -reverse) keeps the context above the buttons when stacked on mobile.
split: "flex-col sm:items-center sm:justify-between",
// alignment only; never touches children. Escape hatch for unusual content.
plain: "sm:justify-end",
},
},
defaultVariants: {
variant: "actions",
},
});
const DialogFooter = ({
className,
variant,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
}: React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof dialogFooterVariants>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
className={cn(dialogFooterVariants({ variant }), className)}
{...props}
/>
);

View File

@ -608,7 +608,6 @@ function Exports() {
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<Button
className="text-white"
aria-label="Delete Export"
variant="destructive"
onClick={() => onHandleDelete()}
@ -658,7 +657,6 @@ function Exports() {
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<Button
className="text-white"
variant="destructive"
onClick={() => void handleDeleteCase()}
>
@ -744,7 +742,7 @@ function Exports() {
</Button>
)}
<Input
className="text-md w-full bg-muted md:w-1/2"
className="w-full bg-muted md:w-1/2"
placeholder={t("search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
@ -1277,8 +1275,8 @@ function CaseEditorDialog({
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
<DialogFooter>
<Button onClick={onClose}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
@ -1295,7 +1293,7 @@ function CaseEditorDialog({
? t("button.save", { ns: "common" })
: t("toolbar.newCase")}
</Button>
</div>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
@ -1427,13 +1425,12 @@ function CaseAddExportDialog({
)}
</div>
</div>
<DialogFooter className="flex-row justify-end gap-2">
<Button variant="outline" size="sm" onClick={onClose}>
<DialogFooter>
<Button onClick={onClose}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
size="sm"
disabled={selectedIds.length === 0 || isAdding}
onClick={() => void handleAdd()}
>

View File

@ -567,7 +567,6 @@ function LibrarySelector({
</Button>
<Button
variant="destructive"
className="text-white"
onClick={() => {
if (confirmDelete) {
handleDeleteFace(confirmDelete);

View File

@ -332,7 +332,7 @@ export default function Replay() {
<Button
variant="destructive"
size="sm"
className="flex items-center gap-2 text-white"
className="flex items-center gap-2"
disabled={isStopping}
>
{isStopping && <ActivityIndicator className="size-4" />}
@ -355,10 +355,7 @@ export default function Replay() {
</AlertDialogCancel>
<AlertDialogAction
onClick={handleStop}
className={cn(
buttonVariants({ variant: "destructive" }),
"text-white",
)}
className={cn(buttonVariants({ variant: "destructive" }))}
>
{t("page.confirmStop.confirm")}
</AlertDialogAction>
@ -687,7 +684,7 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
</div>
</div>
<div className="flex w-8/12 flex-row items-center justify-end">
<div className="text-md mr-2 w-1/3">
<div className="mr-2 w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="mb-1.5 text-sm text-primary-variant">
{t("debug.objectShapeFilterDrawing.score", {
@ -697,7 +694,7 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
{obj.score ? (obj.score * 100).toFixed(1).toString() : "-"}%
</div>
</div>
<div className="text-md mr-2 w-1/3">
<div className="mr-2 w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="mb-1.5 text-sm text-primary-variant">
{t("debug.objectShapeFilterDrawing.ratio", {
@ -707,7 +704,7 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
{obj.ratio ? obj.ratio.toFixed(2).toString() : "-"}
</div>
</div>
<div className="text-md mr-2 w-1/3">
<div className="mr-2 w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="mb-1.5 text-sm text-primary-variant">
{t("debug.objectShapeFilterDrawing.area", {

View File

@ -1616,7 +1616,7 @@ export default function Settings() {
if (isMobile) {
return (
<>
<Toaster position="top-center" />
<Toaster position="top-center" closeButton={true} />
{!contentMobileOpen && (
<div
key={`mobile-menu-${selectedCamera}`}
@ -1872,7 +1872,7 @@ export default function Settings() {
return (
<div className="flex h-full flex-col">
<Toaster position="top-center" />
<Toaster position="top-center" closeButton={true} />
<div className="flex min-h-16 items-center justify-between border-b border-secondary p-3">
<div className="mr-2 flex w-full items-center justify-between gap-3">
<Heading as="h3" className="mb-0">

View File

@ -0,0 +1,856 @@
import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual";
import merge from "lodash/merge";
import type { RJSFSchema } from "@rjsf/utils";
import {
buildOverrides,
cameraUpdateTopicMap,
flattenOverrides,
getEffectiveAttributeLabels,
getSectionConfig,
prepareSectionSavePayload,
resolveHiddenFieldEntries,
sanitizeSectionData,
type SectionSavePayload,
} from "@/utils/configUtil";
import { applySchemaDefaults } from "@/lib/config-schema";
import type { SaveAllPreviewItem } from "@/components/overlay/detail/SaveAllPreviewPopover";
import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import type {
ConfigSectionData,
JsonObject,
JsonValue,
} from "@/types/configForm";
import { processCameraName } from "@/utils/cameraUtil";
/**
* Sections whose `filters` dict is auto-populated by the backend at parse
* time. `attributeBump` reflects the global-level `min_score=0.7` override
* the backend applies to attribute labels (face, license_plate, Frigate+
* couriers) see `frigate/config/config.py`.
*/
const FILTER_SECTION_DEFS: Record<
string,
{
listField: string;
filterDef: string;
attributeBump?: { min_score: number };
}
> = {
objects: {
listField: "track",
filterDef: "FilterConfig",
attributeBump: { min_score: 0.7 },
},
audio: { listField: "listen", filterDef: "AudioFilterConfig" },
};
function resolveDef(schema: RJSFSchema, name: string): RJSFSchema | undefined {
const defs =
(schema as { $defs?: Record<string, RJSFSchema> }).$defs ??
(schema as { definitions?: Record<string, RJSFSchema> }).definitions;
return defs ? defs[name] : undefined;
}
/**
* Reduce each filter entry to the fields that differ from the backend's
* auto-default. An entry that is entirely auto-populated drops out; a
* partially-customized entry keeps only its customized fields, so cloning
* doesn't copy the auto-populated default for every other field.
*/
function stripAutoDefaultFilters(
section: string,
sourceSection: JsonObject,
fullSchema: RJSFSchema,
fullConfig: FrigateConfig,
fullCameraConfig: CameraConfig,
): JsonObject {
const def = FILTER_SECTION_DEFS[section];
if (!def) return sourceSection;
const filters = sourceSection.filters;
if (!filters || typeof filters !== "object" || Array.isArray(filters)) {
return sourceSection;
}
const filterDef = resolveDef(fullSchema, def.filterDef);
if (!filterDef) return sourceSection;
const baseDefaults = applySchemaDefaults(filterDef, {}) as JsonObject;
const attributeDefaults = def.attributeBump
? ({ ...baseDefaults, ...def.attributeBump } as JsonObject)
: baseDefaults;
const attributeSet =
section === "objects"
? new Set(
getEffectiveAttributeLabels(fullConfig, fullCameraConfig, "camera"),
)
: new Set<string>();
// Ignore runtime-only `mask`/`raw_mask`: the API ships them as `{}` while the
// schema default omits them, which would otherwise break the equality check.
const withoutRuntimeFields = (entry: JsonValue): JsonValue => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return entry;
}
const copy = { ...(entry as JsonObject) };
delete copy.mask;
delete copy.raw_mask;
return copy;
};
const cleaned: JsonObject = {};
for (const [label, value] of Object.entries(filters as JsonObject)) {
const expected = attributeSet.has(label) ? attributeDefaults : baseDefaults;
const valNorm = withoutRuntimeFields(value as JsonValue);
const expNorm = withoutRuntimeFields(expected as JsonValue);
// Non-object filter value: keep only if it differs from the default.
if (
!valNorm ||
typeof valNorm !== "object" ||
Array.isArray(valNorm) ||
!expNorm ||
typeof expNorm !== "object" ||
Array.isArray(expNorm)
) {
if (!isEqual(valNorm, expNorm)) {
cleaned[label] = value as JsonValue;
}
continue;
}
const diff: JsonObject = {};
for (const [field, fieldValue] of Object.entries(valNorm as JsonObject)) {
if (!isEqual(fieldValue, (expNorm as JsonObject)[field])) {
diff[field] = fieldValue as JsonValue;
}
}
if (Object.keys(diff).length > 0) {
cleaned[label] = diff;
}
}
return { ...sourceSection, filters: cleaned };
}
/**
* Strip runtime-only fields from each entry of a dict-of-objects (mask
* `enabled_in_config`/`raw_coordinates`, zone `color`) that clone re-injects
* from the API.
*/
function stripDictEntryFields(
dict: unknown,
fieldsToStrip: readonly string[],
): unknown {
if (!dict || typeof dict !== "object" || Array.isArray(dict)) return dict;
const result: JsonObject = {};
for (const [key, value] of Object.entries(dict as JsonObject)) {
if (value && typeof value === "object" && !Array.isArray(value)) {
const cleaned = { ...(value as JsonObject) };
for (const field of fieldsToStrip) {
delete cleaned[field];
}
result[key] = cleaned as JsonValue;
} else {
result[key] = value as JsonValue;
}
}
return result;
}
/**
* Per-object masks (`objects.filters.<label>.mask`) for the labels that define
* one, stripped of runtime fields. The objects form hides `filters.*.mask`, so
* clone re-injects these like the camera-wide `objects.mask`.
*/
function extractFilterMasks(objectsSection: unknown): JsonObject | undefined {
if (!objectsSection || typeof objectsSection !== "object") return undefined;
const filters = (objectsSection as JsonObject).filters;
if (!filters || typeof filters !== "object" || Array.isArray(filters)) {
return undefined;
}
const result: JsonObject = {};
for (const [label, filter] of Object.entries(filters as JsonObject)) {
if (!filter || typeof filter !== "object" || Array.isArray(filter))
continue;
const mask = (filter as JsonObject).mask;
if (
mask &&
typeof mask === "object" &&
!Array.isArray(mask) &&
Object.keys(mask as JsonObject).length > 0
) {
result[label] = {
mask: stripDictEntryFields(mask, [
"enabled_in_config",
"raw_coordinates",
]) as JsonValue,
};
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
/**
* Drop `""` (Reset) markers meaningless for a new camera and unsafe
* (backend `update_yaml` raises KeyError trying to `del` a missing key).
*/
function stripResetMarkers(
value: JsonValue | undefined,
): JsonValue | undefined {
if (value === undefined || value === "") return undefined;
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const result: JsonObject = {};
for (const [key, child] of Object.entries(value as JsonObject)) {
const cleaned = stripResetMarkers(child);
if (cleaned !== undefined) result[key] = cleaned;
}
return Object.keys(result).length > 0 ? result : undefined;
}
/**
* Collapse per-section payloads into one camera-level `…/add` payload. The
* backend's `add` handler validates atomically, avoiding the per-section
* ordering problem (e.g. `review.required_zones` referencing unwritten zones).
*/
function bundleNewCameraPayload(
payloads: SectionSavePayload[],
target: string,
): SectionSavePayload {
const prefix = `cameras.${target}`;
const camera: JsonObject = {};
for (const p of payloads) {
if (p.basePath === prefix) {
merge(camera, p.sanitizedOverrides);
} else if (p.basePath.startsWith(`${prefix}.`)) {
merge(camera, {
[p.basePath.slice(prefix.length + 1)]: p.sanitizedOverrides,
});
}
}
return {
basePath: prefix,
sanitizedOverrides: camera,
updateTopic: `config/cameras/${target}/add`,
needsRestart: true,
pendingDataKey: `${target}::add`,
};
}
/**
* Drop empty `*_args` arrays from ffmpeg inputs the establishing payload
* uses `buildOverrides` directly, bypassing `sanitizeOverridesForSection`.
*/
function cleanupFfmpegInputArgs(
ffmpeg: JsonValue | undefined,
): JsonValue | undefined {
if (!ffmpeg || typeof ffmpeg !== "object" || Array.isArray(ffmpeg)) {
return ffmpeg;
}
const obj = ffmpeg as JsonObject;
const inputs = obj.inputs;
if (!Array.isArray(inputs)) return ffmpeg;
const cleanedInputs = inputs.map((input) => {
if (!input || typeof input !== "object" || Array.isArray(input))
return input;
const cleaned = { ...(input as JsonObject) };
for (const argsKey of ["global_args", "hwaccel_args", "input_args"]) {
const v = cleaned[argsKey];
if (Array.isArray(v) && v.length === 0) delete cleaned[argsKey];
}
return cleaned as JsonValue;
});
return { ...obj, inputs: cleanedInputs as JsonValue };
}
/** Subset of `/api/config/raw_paths` used to unmask source credentials. */
export type RawCameraPaths = {
cameras?: Record<
string,
{ ffmpeg?: { inputs?: Array<{ path?: string; roles?: string[] }> } }
>;
};
/**
* Replace each ffmpeg input's `path` with the unmasked value from
* `rawInputs` at the same index. Mirrors `_restore_masked_camera_paths`.
*/
function restoreFfmpegPaths(
ffmpeg: unknown,
rawInputs: Array<{ path?: string }> | undefined,
): unknown {
if (!ffmpeg || typeof ffmpeg !== "object" || Array.isArray(ffmpeg)) {
return ffmpeg;
}
const obj = cloneDeep(ffmpeg) as JsonObject;
const inputs = obj.inputs;
if (!Array.isArray(inputs) || !rawInputs) return obj;
inputs.forEach((input, i) => {
if (!input || typeof input !== "object" || Array.isArray(input)) return;
const rawPath = rawInputs[i]?.path;
if (typeof rawPath !== "string") return;
(input as JsonObject).path = rawPath;
});
return obj;
}
/**
* Replay the backend's per-camera detect-field formulas on the synthetic
* baseline so the source's computed values cancel out of the diff (the global
* config has no per-camera derivation).
*/
function applyDetectComputedDefaults(
detect: JsonObject,
fpsOverride?: number,
): JsonObject {
const result = { ...detect };
const fps =
typeof fpsOverride === "number"
? fpsOverride
: typeof result.fps === "number"
? result.fps
: 5;
if (result.min_initialized == null) {
result.min_initialized = Math.max(Math.floor(fps / 2), 2);
}
if (result.max_disappeared == null) {
result.max_disappeared = fps * 5;
}
const threshold = fps * 10;
const stationary = result.stationary;
const stat: JsonObject =
stationary && typeof stationary === "object" && !Array.isArray(stationary)
? { ...(stationary as JsonObject) }
: {};
if (stat.threshold == null) stat.threshold = threshold;
if (stat.interval == null) stat.interval = threshold;
result.stationary = stat as JsonValue;
return result;
}
/**
* Categories the dialog exposes. Most map 1:1 to a section config and flow
* through `prepareSectionSavePayload`. Special cases:
* - `motion_mask`/`object_masks`: carve-outs merged into the parent
* section's payload, or emitted standalone if the parent is unselected.
* - `ffmpeg_live`: new-camera target only.
* - `type`/`profiles`: not schema-driven; built directly below.
*/
export type CloneCategoryKey =
| "record"
| "snapshots"
| "review"
| "motion"
| "objects"
| "audio"
| "audio_transcription"
| "notifications"
| "birdseye"
| "mqtt"
| "timestamp_style"
| "onvif"
| "lpr"
| "face_recognition"
| "semantic_search"
| "genai"
| "type"
| "profiles"
| "detect"
| "zones"
| "motion_mask"
| "object_masks"
| "ffmpeg_live";
export type CloneCategoryGroup = "general" | "spatial" | "streams";
export type CloneCategory = {
key: CloneCategoryKey;
group: CloneCategoryGroup;
/** True when this category is only valid for "new camera" targets. */
newCameraOnly?: boolean;
/** True when this category is forced selected for new-camera targets. */
forcedForNewCamera?: boolean;
/** Default selection state for "existing camera" targets when resolutions match. */
defaultOnExisting: boolean;
};
export const CLONE_CATEGORIES: readonly CloneCategory[] = [
// General
{ key: "record", group: "general", defaultOnExisting: true },
{ key: "snapshots", group: "general", defaultOnExisting: true },
{ key: "review", group: "general", defaultOnExisting: true },
{ key: "motion", group: "general", defaultOnExisting: true },
{ key: "objects", group: "general", defaultOnExisting: true },
{ key: "audio", group: "general", defaultOnExisting: true },
{ key: "audio_transcription", group: "general", defaultOnExisting: true },
{ key: "notifications", group: "general", defaultOnExisting: true },
{ key: "birdseye", group: "general", defaultOnExisting: true },
{ key: "mqtt", group: "general", defaultOnExisting: true },
{ key: "timestamp_style", group: "general", defaultOnExisting: true },
{ key: "onvif", group: "general", defaultOnExisting: false },
{ key: "lpr", group: "general", defaultOnExisting: true },
{ key: "face_recognition", group: "general", defaultOnExisting: true },
{ key: "semantic_search", group: "general", defaultOnExisting: true },
{ key: "genai", group: "general", defaultOnExisting: true },
{ key: "type", group: "general", defaultOnExisting: false },
{ key: "profiles", group: "general", defaultOnExisting: true },
// Spatial — defaults computed via resolutionsMatch()
{ key: "detect", group: "spatial", defaultOnExisting: true },
{ key: "zones", group: "spatial", defaultOnExisting: true },
{ key: "motion_mask", group: "spatial", defaultOnExisting: true },
{ key: "object_masks", group: "spatial", defaultOnExisting: true },
// Streams — only for new-camera target, forced on
{
key: "ffmpeg_live",
group: "streams",
newCameraOnly: true,
forcedForNewCamera: true,
defaultOnExisting: false,
},
] as const;
/**
* Exact-match detect dimensions. Aspect-ratio tolerance isn't safe because
* zone/mask coords may be stored as explicit pixels, not just 0-1 relative.
*/
export function resolutionsMatch(
srcDetect: CameraConfig["detect"] | undefined,
dstDetect: CameraConfig["detect"] | undefined,
): boolean {
if (!srcDetect || !dstDetect) return false;
if (
typeof srcDetect.width !== "number" ||
typeof srcDetect.height !== "number"
) {
return false;
}
if (
typeof dstDetect.width !== "number" ||
typeof dstDetect.height !== "number"
) {
return false;
}
return (
srcDetect.width === dstDetect.width && srcDetect.height === dstDetect.height
);
}
/**
* Initial selection set. Existing-camera targets start empty copying onto
* a configured camera is destructive, so the user opts in explicitly.
* New-camera targets pre-select `defaultOnExisting` categories plus
* `forcedForNewCamera`.
*/
export function getCategoryDefaults(
targetIsNew: boolean,
): Set<CloneCategoryKey> {
const selected = new Set<CloneCategoryKey>();
if (!targetIsNew) return selected;
for (const cat of CLONE_CATEGORIES) {
if (cat.forcedForNewCamera || cat.defaultOnExisting) selected.add(cat.key);
}
return selected;
}
type BuildClonedPayloadsArgs = {
sourceCfg: CameraConfig;
sourceName: string;
/** Raw user input for new camera, or the existing-camera key. */
targetInput: string;
targetIsNew: boolean;
selectedKeys: Set<CloneCategoryKey>;
fullConfig: FrigateConfig;
fullSchema: RJSFSchema;
rawPaths?: RawCameraPaths;
};
/**
* Build the ordered payloads to PUT. Order: new-camera `…/add`, then
* `type` (LPR vs normal affects attribute resolution for later payloads),
* then per-section, then `profiles` (no hot-reload topic).
*/
export function buildClonedCameraPayloads({
sourceCfg,
sourceName,
targetInput,
targetIsNew,
selectedKeys,
fullConfig,
fullSchema,
rawPaths,
}: BuildClonedPayloadsArgs): SectionSavePayload[] {
const payloads: SectionSavePayload[] = [];
const { finalCameraName: target, friendlyName } = targetIsNew
? processCameraName(targetInput)
: { finalCameraName: targetInput, friendlyName: undefined };
// New-camera establishing payload (carries the `…/add` topic).
if (targetIsNew) {
const addOverrides: Record<string, unknown> = {
enabled: true,
};
if (friendlyName) {
addOverrides.friendly_name = friendlyName;
}
// Diff ffmpeg/live against the global config so fields matching
// inherited defaults drop out. Required fields (ffmpeg.inputs) come
// along because the source differs from global there.
if (selectedKeys.has("ffmpeg_live") && sourceCfg.ffmpeg) {
// /api/config masks `user:pass` as `*:*`; backend's restoration
// only handles existing cameras, so we unmask here for new ones.
const ffmpegWithRealPaths = restoreFfmpegPaths(
sourceCfg.ffmpeg,
rawPaths?.cameras?.[sourceName]?.ffmpeg?.inputs,
);
const diff = buildOverrides(
ffmpegWithRealPaths,
undefined,
fullConfig.ffmpeg,
);
const cleaned = cleanupFfmpegInputArgs(diff as JsonValue | undefined);
if (cleaned !== undefined) addOverrides.ffmpeg = cleaned;
}
if (selectedKeys.has("ffmpeg_live") && sourceCfg.live) {
const diff = buildOverrides(
sourceCfg.live,
undefined,
(fullConfig as unknown as JsonObject).live,
);
if (diff !== undefined) addOverrides.live = diff;
}
payloads.push({
basePath: `cameras.${target}`,
sanitizedOverrides: addOverrides as JsonObject,
updateTopic: `config/cameras/${target}/add`,
needsRestart: true,
pendingDataKey: `${target}::__add__`,
});
}
// Camera type — top-level scalar, no schema-driven section.
if (selectedKeys.has("type")) {
const srcType = (sourceCfg as { type?: string | null }).type;
if (srcType !== undefined && srcType !== null) {
payloads.push({
basePath: `cameras.${target}`,
sanitizedOverrides: { type: srcType },
updateTopic: undefined,
needsRestart: true,
pendingDataKey: `${target}::type`,
});
}
}
// Order matters for the existing-camera multi-PUT path (each PUT re-validates
// the whole config): `detect` then `zones` must precede sections that
// reference zones via `required_zones` (review, objects, snapshots, mqtt).
const SECTION_KEYS: Array<{ key: CloneCategoryKey; section: string }> = [
{ key: "detect", section: "detect" },
{ key: "zones", section: "zones" },
{ key: "motion", section: "motion" },
{ key: "objects", section: "objects" },
{ key: "record", section: "record" },
{ key: "snapshots", section: "snapshots" },
{ key: "review", section: "review" },
{ key: "audio", section: "audio" },
{ key: "audio_transcription", section: "audio_transcription" },
{ key: "notifications", section: "notifications" },
{ key: "birdseye", section: "birdseye" },
{ key: "mqtt", section: "mqtt" },
{ key: "timestamp_style", section: "timestamp_style" },
{ key: "onvif", section: "onvif" },
{ key: "lpr", section: "lpr" },
{ key: "face_recognition", section: "face_recognition" },
{ key: "semantic_search", section: "semantic_search" },
{ key: "genai", section: "genai" },
];
// Synthetic target reused as the diff baseline. New-camera: seed sections
// whose camera schema accepts all global fields (correct inheritance
// baseline), but leave divergent per-camera sections (mqtt, birdseye, lpr,
// face_recognition, semantic_search, audio_transcription, genai) unset —
// seeding from global would surface its extra fields as Reset markers.
const GLOBAL_INHERITED_SECTIONS = [
"detect",
"objects",
"motion",
"record",
"snapshots",
"review",
"audio",
"notifications",
"ffmpeg",
"live",
"timestamp_style",
];
const syntheticTargetCamera = targetIsNew
? ({
enabled: true,
...Object.fromEntries(
GLOBAL_INHERITED_SECTIONS.map((s) => [
s,
cloneDeep((fullConfig as unknown as JsonObject)[s]),
]).filter(([, value]) => value !== undefined && value !== null),
),
} as unknown as FrigateConfig["cameras"][string])
: ((fullConfig.cameras?.[target]
? cloneDeep(fullConfig.cameras[target])
: { enabled: true }) as unknown as FrigateConfig["cameras"][string]);
// Strip auto-default filters from the baseline (matching the per-section
// source strip) so default-only entries cancel. Includes `base_config` (the
// pre-profile parse getBaseCameraSectionValue reads) — otherwise its
// auto-populated entries become `""` resets and the backend KeyErrors
// deleting a key not in the YAML. Cloned above so this won't mutate the cache.
const syntheticCameraObj = syntheticTargetCamera as unknown as JsonObject;
const baseConfigObj = syntheticCameraObj.base_config as
| Record<string, JsonObject>
| undefined;
for (const section of Object.keys(FILTER_SECTION_DEFS)) {
const syntheticSection = syntheticCameraObj[section];
if (syntheticSection && typeof syntheticSection === "object") {
syntheticCameraObj[section] = stripAutoDefaultFilters(
section,
syntheticSection as JsonObject,
fullSchema,
fullConfig,
syntheticTargetCamera as CameraConfig,
);
}
const baseSection = baseConfigObj?.[section];
if (baseConfigObj && baseSection && typeof baseSection === "object") {
baseConfigObj[section] = stripAutoDefaultFilters(
section,
baseSection,
fullSchema,
fullConfig,
syntheticTargetCamera as CameraConfig,
);
}
}
// New-camera: synthetic's detect is from global (no per-camera derive),
// so apply the formulas using source's fps to keep both sides aligned.
// Existing-camera target already has the values from its own parse.
if (targetIsNew && sourceCfg.detect) {
const syntheticDetect = syntheticCameraObj.detect;
if (syntheticDetect && typeof syntheticDetect === "object") {
syntheticCameraObj.detect = applyDetectComputedDefaults(
syntheticDetect as JsonObject,
typeof sourceCfg.detect.fps === "number"
? sourceCfg.detect.fps
: undefined,
) as JsonValue;
}
}
const syntheticConfig: FrigateConfig = {
...fullConfig,
cameras: {
...fullConfig.cameras,
[target]: syntheticTargetCamera,
},
};
for (const { key, section } of SECTION_KEYS) {
if (!selectedKeys.has(key)) continue;
const sourceSectionValue = (
sourceCfg as unknown as Record<string, unknown>
)[section];
if (sourceSectionValue == null) continue;
// Sanitize the source like BaseSection's form does: strip runtime/derived
// and hidden-path fields (e.g. `hideAttributeFilters` drops untracked
// attributes based on the source's track list).
const sectionConfig = getSectionConfig(section, "camera");
const resolvedHiddenFields = resolveHiddenFieldEntries(
sectionConfig.hiddenFields,
{
fullConfig,
fullCameraConfig: sourceCfg,
level: "camera",
formData: sourceSectionValue as ConfigSectionData,
},
);
let pendingSectionValue: unknown = sanitizeSectionData(
cloneDeep(sourceSectionValue) as ConfigSectionData,
resolvedHiddenFields,
);
if (FILTER_SECTION_DEFS[section]) {
pendingSectionValue = stripAutoDefaultFilters(
section,
pendingSectionValue as JsonObject,
fullSchema,
fullConfig,
syntheticTargetCamera as CameraConfig,
);
}
// Re-inject masks the parent section's hiddenFields just stripped,
// when the mask category is also selected. `raw_mask` is never in
// the API response; `enabled_in_config` is runtime-only.
if (key === "motion" && selectedKeys.has("motion_mask")) {
const srcMask = (sourceSectionValue as { mask?: unknown }).mask;
if (srcMask !== undefined) {
pendingSectionValue = {
...(pendingSectionValue as object),
mask: stripDictEntryFields(srcMask, [
"enabled_in_config",
"raw_coordinates",
]),
};
}
}
if (key === "objects" && selectedKeys.has("object_masks")) {
const next = { ...(pendingSectionValue as JsonObject) };
// Camera-wide object mask (applies to all objects).
const srcMask = (sourceSectionValue as { mask?: unknown }).mask;
if (srcMask !== undefined) {
next.mask = stripDictEntryFields(srcMask, [
"enabled_in_config",
"raw_coordinates",
]) as JsonValue;
}
// Per-object masks (objects.filters.<label>.mask), stripped by the
// section's hiddenFields above. Merge them onto the reduced filters
// (creating the entry when the filter was otherwise all-default).
const filterMasks = extractFilterMasks(sourceSectionValue);
if (filterMasks) {
const mergedFilters: JsonObject = {
...((next.filters as JsonObject) ?? {}),
};
for (const [label, overlay] of Object.entries(filterMasks)) {
mergedFilters[label] = {
...((mergedFilters[label] as JsonObject) ?? {}),
...(overlay as JsonObject),
};
}
next.filters = mergedFilters;
}
pendingSectionValue = next;
}
// `color` is a Pydantic PrivateAttr (runtime-only).
if (key === "zones") {
pendingSectionValue = stripDictEntryFields(pendingSectionValue, [
"color",
]);
}
const payload = prepareSectionSavePayload({
pendingDataKey: `${target}::${section}`,
pendingData: pendingSectionValue,
config: syntheticConfig,
fullSchema,
});
if (payload) {
payloads.push(payload);
}
}
// Standalone mask payloads — only when the parent section isn't also
// selected (otherwise the masks were merged into its payload above).
if (selectedKeys.has("motion_mask") && !selectedKeys.has("motion")) {
const srcMask = (sourceCfg.motion as { mask?: unknown } | undefined)?.mask;
if (srcMask !== undefined) {
payloads.push({
basePath: `cameras.${target}.motion`,
sanitizedOverrides: {
mask: stripDictEntryFields(srcMask, [
"enabled_in_config",
"raw_coordinates",
]) as JsonValue,
},
updateTopic: `config/cameras/${target}/${cameraUpdateTopicMap.motion}`,
needsRestart: false,
pendingDataKey: `${target}::motion.masks`,
});
}
}
if (selectedKeys.has("object_masks") && !selectedKeys.has("objects")) {
const overrides: JsonObject = {};
const srcMask = (sourceCfg.objects as { mask?: unknown } | undefined)?.mask;
if (srcMask !== undefined) {
overrides.mask = stripDictEntryFields(srcMask, [
"enabled_in_config",
"raw_coordinates",
]) as JsonValue;
}
const filterMasks = extractFilterMasks(sourceCfg.objects);
if (filterMasks) {
overrides.filters = filterMasks;
}
if (Object.keys(overrides).length > 0) {
payloads.push({
basePath: `cameras.${target}.objects`,
sanitizedOverrides: overrides,
updateTopic: `config/cameras/${target}/${cameraUpdateTopicMap.objects}`,
needsRestart: false,
pendingDataKey: `${target}::objects.masks`,
});
}
}
// Profiles — wholesale dict replacement; no hot-reload topic.
if (selectedKeys.has("profiles")) {
const srcProfiles = (sourceCfg as { profiles?: unknown }).profiles;
if (srcProfiles && typeof srcProfiles === "object") {
payloads.push({
basePath: `cameras.${target}.profiles`,
sanitizedOverrides: cloneDeep(srcProfiles) as JsonObject,
updateTopic: undefined,
needsRestart: true,
pendingDataKey: `${target}::profiles`,
});
}
}
// New camera: scrub Reset markers (see stripResetMarkers), then bundle
// into one atomic `…/add` PUT so the backend validates the full camera
// at once (avoids per-section ordering issues).
if (targetIsNew) {
const scrubbed = payloads
.map((p) => {
const cleaned = stripResetMarkers(p.sanitizedOverrides as JsonValue);
return cleaned === undefined
? null
: { ...p, sanitizedOverrides: cleaned as JsonObject };
})
.filter((p): p is SectionSavePayload => p !== null);
return [bundleNewCameraPayload(scrubbed, target)];
}
return payloads;
}
/**
* Flatten payloads to `SaveAllPreviewItem`s with camera-relative
* `fieldPath`s (matches BaseSection's per-section preview).
*/
export function buildClonePreviewItems(
payloads: SectionSavePayload[],
targetCamera: string,
): SaveAllPreviewItem[] {
const cameraBase = `cameras.${targetCamera}`;
return payloads.flatMap((p) => {
const flattened = flattenOverrides(p.sanitizedOverrides as JsonValue);
const sectionRelativeBase =
p.basePath === cameraBase
? ""
: p.basePath.startsWith(`${cameraBase}.`)
? p.basePath.slice(cameraBase.length + 1)
: p.basePath;
return flattened.map(({ path, value }) => ({
scope: "camera" as const,
cameraName: targetCamera,
fieldPath: path
? sectionRelativeBase
? `${sectionRelativeBase}.${path}`
: path
: sectionRelativeBase,
value,
}));
});
}

View File

@ -81,6 +81,7 @@ export const cameraUpdateTopicMap: Record<string, string> = {
mqtt: "mqtt",
onvif: "onvif",
ui: "ui",
zones: "zones",
};
// Sections where global config serves as the default for per-camera config.

View File

@ -668,7 +668,6 @@ function LibrarySelector({
</Button>
<Button
variant="destructive"
className="text-white"
onClick={() => {
if (confirmDelete) {
handleDeleteCategory(confirmDelete);

View File

@ -1389,9 +1389,8 @@ function MotionReview({
selectedCells={pendingFilterCells}
onCellsChange={setPendingFilterCells}
/>
<DialogFooter className="justify-end gap-1">
<DialogFooter>
<Button
variant="outline"
disabled={pendingFilterCells.size === 0}
onClick={() => {
setPendingFilterCells(new Set());
@ -1432,9 +1431,7 @@ function MotionReview({
<div className="space-y-4 py-2">
{!isDesktop && (
<div className="space-y-1">
<div className="text-md">
{t("motionPreviews.mobileSettingsTitle")}
</div>
<div>{t("motionPreviews.mobileSettingsTitle")}</div>
<div className="text-xs text-muted-foreground">
{t("motionPreviews.mobileSettingsDesc")}
</div>
@ -1443,9 +1440,7 @@ function MotionReview({
<div className="space-y-3">
<div className="space-y-0.5">
<div className="text-md">
{t("motionPreviews.speed")}
</div>
<div>{t("motionPreviews.speed")}</div>
<div className="text-xs text-muted-foreground">
{t("motionPreviews.speedDesc")}
</div>
@ -1474,7 +1469,7 @@ function MotionReview({
<div className="space-y-3">
<div className="space-y-0.5">
<div className="text-md">{t("motionPreviews.dim")}</div>
<div>{t("motionPreviews.dim")}</div>
<div className="text-xs text-muted-foreground">
{t("motionPreviews.dimDesc")}
</div>

View File

@ -784,7 +784,7 @@ export default function LiveCameraView({
transcription != null && (
<div
ref={transcriptionRef}
className="text-md scrollbar-container absolute bottom-4 left-1/2 max-h-[15vh] w-[75%] -translate-x-1/2 overflow-y-auto rounded-lg bg-black/70 p-2 text-white md:w-[50%]"
className="scrollbar-container absolute bottom-4 left-1/2 max-h-[15vh] w-[75%] -translate-x-1/2 overflow-y-auto rounded-lg bg-black/70 p-2 text-white md:w-[50%]"
>
{transcription}
</div>

View File

@ -630,7 +630,7 @@ function SearchRangeSelector({
/>
<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}
@ -696,7 +696,7 @@ function SearchRangeSelector({
/>
<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}

View File

@ -1052,7 +1052,6 @@ export default function MotionSearchView({
</div>
<Button
variant="destructive"
className="text-white"
size="sm"
onClick={() => {
void cancelMotionSearchJob(jobId, jobCamera);

View File

@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FrigateConfig } from "@/types/frigateConfig";
import { Toaster } from "@/components/ui/sonner";
import useSWR from "swr";
import Heading from "@/components/ui/heading";
import { User } from "@/types/user";
@ -790,7 +789,6 @@ export default function AuthenticationView({
return (
<div className="flex size-full flex-col">
<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 pb-2 md:order-none md:mr-3 md:mt-0">
{section === "users" && UsersSection}
{section === "roles" && RolesSection}

View File

@ -1,12 +1,18 @@
import Heading from "@/components/ui/heading";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
CONTROL_COLUMN_CLASS_NAME,
SettingsGroupCard,
SPLIT_ROW_CLASS_NAME,
} from "@/components/card/SettingsGroupCard";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import { Button } from "@/components/ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
@ -15,6 +21,7 @@ import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
import {
LuCheck,
LuCopy,
LuExternalLink,
LuGripVertical,
LuPencil,
@ -22,6 +29,7 @@ import {
LuRefreshCcw,
LuTrash2,
} from "react-icons/lu";
import CloneCameraDialog from "@/components/settings/CloneCameraDialog";
import { Reorder, useDragControls } from "framer-motion";
import { Link } from "react-router-dom";
import { useDocDomain } from "@/hooks/use-doc-domain";
@ -50,6 +58,7 @@ import {
import type { ProfileState } from "@/types/profile";
import { getProfileColor } from "@/utils/profileColors";
import { isReplayCamera } from "@/utils/cameraUtil";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { cn } from "@/lib/utils";
import {
Select,
@ -88,6 +97,7 @@ export default function CameraManagementView({
const [showWizard, setShowWizard] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showCloneDialog, setShowCloneDialog] = useState(false);
// State for restart dialog when enabling a disabled camera
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
@ -217,12 +227,6 @@ export default function CameraManagementView({
return (
<>
<Toaster
richColors
className="z-[1000]"
position="top-center"
closeButton
/>
<div className="flex size-full space-y-6">
<div className="scrollbar-container flex-1 overflow-y-auto pb-2">
<Heading as="h4" className="mb-2">
@ -246,7 +250,7 @@ export default function CameraManagementView({
<Button
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
className="mb-2 flex max-w-48 items-center gap-2 text-white"
className="mb-2 flex max-w-48 items-center gap-2"
>
<LuTrash2 className="h-4 w-4" />
{t("cameraManagement.deleteCamera")}
@ -254,6 +258,27 @@ export default function CameraManagementView({
)}
</div>
{enabledCameras.length + disabledCameras.length > 0 && (
<div className="mb-5 space-y-3">
<div className="space-y-0.5">
<div className="font-medium">
{t("cameraManagement.clone.sectionTitle")}
</div>
<p className="text-sm text-muted-foreground">
{t("cameraManagement.clone.sectionDescription")}
</p>
</div>
<Button
variant="select"
onClick={() => setShowCloneDialog(true)}
className="flex max-w-48 items-center gap-2"
>
<LuCopy className="h-4 w-4" />
{t("cameraManagement.clone.button")}
</Button>
</div>
)}
{(enabledCameras.length > 0 || disabledCameras.length > 0) && (
<SettingsGroupCard
title={
@ -364,6 +389,10 @@ export default function CameraManagementView({
onClose={() => setRestartDialogOpen(false)}
onRestart={() => sendRestart("restart")}
/>
<CloneCameraDialog
open={showCloneDialog}
onClose={() => setShowCloneDialog(false)}
/>
</>
);
}
@ -501,6 +530,7 @@ function CameraStatusSelect({
]);
const { payload: enabledState, send: sendEnabled } =
useEnabledState(cameraName);
const statusBar = useContext(StatusBarMessagesContext);
const [isSaving, setIsSaving] = useState(false);
const currentStatus: CameraStatus = isDisabledInConfig
@ -586,6 +616,12 @@ function CameraStatusSelect({
},
});
await onConfigChanged();
statusBar?.addMessage(
"config_restart_required",
t("configForm.restartRequiredFooter", { ns: "views/settings" }),
undefined,
"config_restart_required",
);
toast.success(
t("cameraManagement.streams.disableSuccess", {
ns: "views/settings",
@ -617,6 +653,7 @@ function CameraStatusSelect({
onConfigChanged,
sendEnabled,
setRestartDialogOpen,
statusBar,
t,
],
);

View File

@ -22,7 +22,6 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import Heading from "@/components/ui/heading";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Toaster } from "@/components/ui/sonner";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
@ -598,7 +597,6 @@ export default function DetectorsAndModelSettingsView({
return (
<div className="flex size-full flex-col md:pr-2">
<Toaster position="top-center" closeButton={true} />
<div className="mb-1 flex items-center justify-between gap-4 pt-2">
<div className="flex max-w-5xl flex-col">
<Heading as="h4">{t("detectorsAndModel.title")}</Heading>

View File

@ -306,9 +306,7 @@ export default function EnrichmentsSettingsView({
</div>
<div className="mt-2 flex flex-col space-y-6">
<div className="space-y-0.5">
<div className="text-md">
{t("enrichments.semanticSearch.modelSize.label")}
</div>
<div>{t("enrichments.semanticSearch.modelSize.label")}</div>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
<Trans ns="views/settings">
@ -436,9 +434,7 @@ export default function EnrichmentsSettingsView({
</div>
</div>
<div className="space-y-0.5">
<div className="text-md">
{t("enrichments.faceRecognition.modelSize.label")}
</div>
<div>{t("enrichments.faceRecognition.modelSize.label")}</div>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
<Trans ns="views/settings">

View File

@ -4,7 +4,6 @@ import { Link, useNavigate } from "react-router-dom";
import useSWR from "swr";
import { CheckCircle2, XCircle } from "lucide-react";
import { LuExternalLink } from "react-icons/lu";
import { Toaster } from "@/components/ui/sonner";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
import Heading from "@/components/ui/heading";
@ -35,7 +34,6 @@ export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
return (
<div className="flex size-full flex-col md:pr-2">
<Toaster position="top-center" closeButton={true} />
<div className="w-full max-w-5xl space-y-6 pt-2">
<div className="flex flex-col gap-0">
<Heading as="h4" className="mb-2">

View File

@ -446,10 +446,7 @@ export default function Go2RtcStreamsSettingsView({
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={cn(
buttonVariants({ variant: "destructive" }),
"text-white",
)}
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => deleteDialog && deleteStream(deleteDialog)}
>
{t("go2rtcStreams.deleteStream")}
@ -533,7 +530,6 @@ function RenameStreamDialog({
<div className="space-y-2 py-2">
<Label>{t("go2rtcStreams.newStreamName")}</Label>
<Input
className="text-md"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
@ -547,7 +543,7 @@ function RenameStreamDialog({
<p className="text-xs text-destructive">{nameError}</p>
)}
</div>
<DialogFooter className="gap-2 sm:justify-end md:gap-0">
<DialogFooter>
<DialogClose asChild>
<Button>{t("button.cancel", { ns: "common" })}</Button>
</DialogClose>
@ -614,7 +610,6 @@ function AddStreamDialog({
<Label>{t("go2rtcStreams.streamName")}</Label>
<Input
value={name}
className="text-md"
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && canSubmit) {
@ -628,7 +623,7 @@ function AddStreamDialog({
<p className="text-xs text-destructive">{nameError}</p>
)}
</div>
<DialogFooter className="gap-2 sm:justify-end md:gap-0">
<DialogFooter>
<DialogClose asChild>
<Button>{t("button.cancel", { ns: "common" })}</Button>
</DialogClose>
@ -924,7 +919,7 @@ function StreamUrlEntry({
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
className="text-md h-8 pr-10"
className="h-8 pr-10"
value={baseUrlForDisplay}
onChange={(e) => handleBaseUrlChange(e.target.value)}
onFocus={() => setIsFocused(true)}

View File

@ -15,7 +15,6 @@ import {
} from "@/components/ui/hover-card";
import copy from "copy-to-clipboard";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import { Button } from "@/components/ui/button";
import {
Tooltip,
@ -730,7 +729,6 @@ export default function MasksAndZonesView({
<>
{cameraConfig && editingPolygons && (
<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 rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mr-3 md:mt-0 md:w-3/12 md:min-w-0 md:shrink-0">
{editPane == "zone" && (
<ZoneEditPane
@ -793,7 +791,7 @@ export default function MasksAndZonesView({
<div className="my-3 flex flex-row items-center justify-between">
<HoverCard>
<HoverCardTrigger asChild>
<div className="text-md cursor-default">
<div className="cursor-default">
{t("masksAndZones.zones.label")}
</div>
</HoverCardTrigger>
@ -871,7 +869,7 @@ export default function MasksAndZonesView({
<div className="my-3 flex flex-row items-center justify-between">
<HoverCard>
<HoverCardTrigger asChild>
<div className="text-md cursor-default">
<div className="cursor-default">
{t("masksAndZones.motionMasks.label")}
</div>
</HoverCardTrigger>
@ -953,7 +951,7 @@ export default function MasksAndZonesView({
<div className="my-3 flex flex-row items-center justify-between">
<HoverCard>
<HoverCardTrigger asChild>
<div className="text-md cursor-default">
<div className="cursor-default">
{t("masksAndZones.objectMasks.label")}
</div>
</HoverCardTrigger>

View File

@ -2,7 +2,6 @@ import Heading from "@/components/ui/heading";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Toaster } from "@/components/ui/sonner";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
@ -105,7 +104,6 @@ export default function MediaSyncSettingsView() {
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="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
<div className="col-span-1">

View File

@ -15,7 +15,6 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { Separator } from "@/components/ui/separator";
import { Link } from "react-router-dom";
@ -184,7 +183,6 @@ export default function MotionTunerView({
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 rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mr-3 md:mt-0 md:w-3/12">
<Heading as="h4" className="mb-2">
{t("motionDetectionTuner.title")}
@ -208,7 +206,7 @@ export default function MotionTunerView({
<div className="flex w-full flex-col space-y-6">
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<Label htmlFor="motion-threshold" className="text-md">
<Label htmlFor="motion-threshold">
{t("motionDetectionTuner.Threshold.title")}
</Label>
<div className="my-2 text-sm text-muted-foreground">
@ -237,7 +235,7 @@ export default function MotionTunerView({
</div>
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<Label htmlFor="motion-threshold" className="text-md">
<Label htmlFor="motion-threshold">
{t("motionDetectionTuner.contourArea.title")}
</Label>
<div className="my-2 text-sm text-muted-foreground">

View File

@ -415,7 +415,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
</div>
</div>
<div className="flex w-8/12 flex-row items-center justify-end">
<div className="text-md mr-2 w-1/3">
<div className="mr-2 w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="mb-1.5 text-sm text-primary-variant">
{t("debug.objectShapeFilterDrawing.score")}
@ -426,7 +426,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
%
</div>
</div>
<div className="text-md mr-2 w-1/3">
<div className="mr-2 w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="mb-1.5 text-sm text-primary-variant">
{t("debug.objectShapeFilterDrawing.ratio")}
@ -434,7 +434,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
{obj.ratio ? obj.ratio.toFixed(2).toString() : "-"}
</div>
</div>
<div className="text-md mr-2 w-1/3">
<div className="mr-2 w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="mb-1.5 text-sm text-primary-variant">
{t("debug.objectShapeFilterDrawing.area")}
@ -505,7 +505,7 @@ function AudioList({ cameraConfig, audioDetections }: AudioListProps) {
<div className="ml-3 text-lg">{getTranslatedLabel(key)}</div>
</div>
<div className="flex w-8/12 flex-row items-center justify-end">
<div className="text-md mr-2 w-1/3">
<div className="mr-2 w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="mb-1.5 text-sm text-primary-variant">
{t("debug.audio.score")}

View File

@ -24,7 +24,7 @@ import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { cn } from "@/lib/utils";
import Heading from "@/components/ui/heading";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import NameAndIdFields from "@/components/input/NameAndIdFields";
@ -654,10 +654,9 @@ export default function ProfilesView({
ns: "views/settings",
})}
/>
<DialogFooter className="gap-2 md:gap-0">
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setAddDialogOpen(false)}
disabled={addingProfile}
>
@ -709,7 +708,7 @@ export default function ProfilesView({
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={(e) => {
e.preventDefault();
handleDeleteProfile();
@ -746,7 +745,6 @@ export default function ProfilesView({
/>
<DialogFooter>
<Button
variant="outline"
onClick={() => setRenameProfile(null)}
disabled={renaming}
>

View File

@ -10,7 +10,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Toaster } from "@/components/ui/sonner";
import { useCallback, useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import axios from "axios";
@ -59,7 +58,6 @@ export default function RegionGridSettingsView({
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">
<Heading as="h4" className="mb-2 hidden md:block">
{t("maintenance.regionGrid.title")}
@ -85,7 +83,7 @@ export default function RegionGridSettingsView({
onClick={() => setIsConfirmOpen(true)}
disabled={isClearing}
variant="destructive"
className="flex flex-1 text-white md:max-w-sm"
className="flex flex-1 md:max-w-sm"
>
{t("maintenance.regionGrid.clear")}
</Button>
@ -108,10 +106,7 @@ export default function RegionGridSettingsView({
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={cn(
buttonVariants({ variant: "destructive" }),
"text-white",
)}
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={handleClear}
>
{t("maintenance.regionGrid.clear")}

View File

@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import useSWR from "swr";
import axios from "axios";
import { Button } from "@/components/ui/button";
@ -443,7 +442,6 @@ export default function TriggerView({
return (
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div
className={cn(
"scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2",

View File

@ -1,7 +1,7 @@
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { ReactNode, useCallback, useContext, useEffect } from "react";
import { Toaster, toast } from "sonner";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
@ -211,7 +211,6 @@ export default function UiSettingsView() {
return (
<div className="flex size-full flex-col">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2">
<Heading as="h4" className="mb-3">
{t("general.title")}