mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-03 18:41:14 +03:00
Compare commits
4 Commits
19b13bb088
...
1d38d5a244
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d38d5a244 | ||
|
|
6fdd65ddb5 | ||
|
|
4b6fa49449 | ||
|
|
bc65713ae4 |
@ -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(
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
181
web/e2e/specs/clone-camera.spec.ts
Normal file
181
web/e2e/specs/clone-camera.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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": {
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -485,7 +485,7 @@ export default function ClassificationModelEditDialog({
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
className="h-8"
|
||||
placeholder={t(
|
||||
"wizard.step1.classPlaceholder",
|
||||
)}
|
||||
|
||||
@ -214,7 +214,7 @@ export default function Step1NameAndDefine({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
className="h-8"
|
||||
placeholder={t("wizard.step1.namePlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
@ -457,7 +457,7 @@ export default function Step1NameAndDefine({
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
className="h-8"
|
||||
placeholder={t("wizard.step1.classPlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
@ -489,7 +489,7 @@ export default function Step1NameAndDefine({
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<Button type="button" onClick={onCancel} className="sm:flex-1">
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -79,7 +79,7 @@ export function ArrayAsTextWidget(props: WidgetProps) {
|
||||
return (
|
||||
<Textarea
|
||||
id={id}
|
||||
className={cn("text-md", fieldClassName)}
|
||||
className={cn(fieldClassName)}
|
||||
value={text}
|
||||
disabled={disabled || readonly}
|
||||
rows={(options.rows as number) || 3}
|
||||
|
||||
@ -124,7 +124,7 @@ export function CameraPathWidget(props: WidgetProps) {
|
||||
<div className={cn("relative", fieldClassName)}>
|
||||
<Input
|
||||
id={id}
|
||||
className={cn("text-md", canToggle ? "pr-10" : undefined)}
|
||||
className={cn(canToggle ? "pr-10" : undefined)}
|
||||
type="text"
|
||||
value={displayValue}
|
||||
disabled={disabled || readonly}
|
||||
|
||||
@ -26,7 +26,7 @@ export function TextWidget(props: WidgetProps) {
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
className={cn("text-md", fieldClassName)}
|
||||
className={cn(fieldClassName)}
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
|
||||
@ -26,7 +26,7 @@ export function TextareaWidget(props: WidgetProps) {
|
||||
return (
|
||||
<Textarea
|
||||
id={id}
|
||||
className={cn("text-md", fieldClassName)}
|
||||
className={cn(fieldClassName)}
|
||||
value={value ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={placeholder || (options.placeholder as string) || ""}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -56,18 +56,25 @@ export function CameraLineGraph({
|
||||
});
|
||||
}, [t, timeFormat]);
|
||||
|
||||
const updateTimesRef = useRef(updateTimes);
|
||||
useEffect(() => {
|
||||
updateTimesRef.current = updateTimes;
|
||||
}, [updateTimes]);
|
||||
|
||||
const formatTime = useCallback(
|
||||
(val: unknown) => {
|
||||
return formatUnixTimestampToDateTime(
|
||||
updateTimes[Math.round(val as number)],
|
||||
{
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
},
|
||||
);
|
||||
const times = updateTimesRef.current;
|
||||
const ts = times[Math.round(val as number)];
|
||||
if (isNaN(ts)) {
|
||||
return "";
|
||||
}
|
||||
return formatUnixTimestampToDateTime(ts, {
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
});
|
||||
},
|
||||
[config?.ui.timezone, format, locale, updateTimes],
|
||||
[config?.ui.timezone, format, locale],
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
@ -211,18 +218,25 @@ export function EventsPerSecondsLineGraph({
|
||||
});
|
||||
}, [t, timeFormat]);
|
||||
|
||||
const updateTimesRef = useRef(updateTimes);
|
||||
useEffect(() => {
|
||||
updateTimesRef.current = updateTimes;
|
||||
}, [updateTimes]);
|
||||
|
||||
const formatTime = useCallback(
|
||||
(val: unknown) => {
|
||||
return formatUnixTimestampToDateTime(
|
||||
updateTimes[Math.round(val as number) - 1],
|
||||
{
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
},
|
||||
);
|
||||
const times = updateTimesRef.current;
|
||||
const ts = times[Math.round(val as number) - 1];
|
||||
if (isNaN(ts)) {
|
||||
return "";
|
||||
}
|
||||
return formatUnixTimestampToDateTime(ts, {
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
});
|
||||
},
|
||||
[config?.ui.timezone, format, locale, updateTimes],
|
||||
[config?.ui.timezone, format, locale],
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
|
||||
@ -61,6 +61,11 @@ export function ThresholdBarGraph({
|
||||
});
|
||||
}, [t, timeFormat]);
|
||||
|
||||
const updateTimesRef = useRef(updateTimes);
|
||||
useEffect(() => {
|
||||
updateTimesRef.current = updateTimes;
|
||||
}, [updateTimes]);
|
||||
|
||||
const formatTime = useCallback(
|
||||
(val: unknown) => {
|
||||
const dateIndex = Math.round(val as number);
|
||||
@ -69,16 +74,18 @@ export function ThresholdBarGraph({
|
||||
if (dateIndex < 0) {
|
||||
timeOffset = 5 * Math.abs(dateIndex);
|
||||
}
|
||||
return formatUnixTimestampToDateTime(
|
||||
updateTimes[Math.max(1, dateIndex) - 1] - timeOffset,
|
||||
{
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
},
|
||||
);
|
||||
const times = updateTimesRef.current;
|
||||
const ts = times[Math.max(1, dateIndex) - 1] - timeOffset;
|
||||
if (isNaN(ts)) {
|
||||
return "";
|
||||
}
|
||||
return formatUnixTimestampToDateTime(ts, {
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
});
|
||||
},
|
||||
[config?.ui.timezone, format, locale, updateTimes],
|
||||
[config?.ui.timezone, format, locale],
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
|
||||
@ -119,7 +119,7 @@ export default function IconPicker({
|
||||
placeholder={t("iconPicker.search.placeholder", {
|
||||
ns: "components/icons",
|
||||
})}
|
||||
className="text-md mb-3 md:text-sm"
|
||||
className="mb-3 md:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -69,7 +69,6 @@ export function SaveSearchDialog({
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={searchName}
|
||||
className="text-md"
|
||||
onChange={(e) => setSearchName(e.target.value)}
|
||||
placeholder={t("search.saveSearch.placeholder")}
|
||||
/>
|
||||
@ -88,7 +87,6 @@ export function SaveSearchDialog({
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="select"
|
||||
className="mb-2 md:mb-0"
|
||||
aria-label={t("search.saveSearch.button.save.label")}
|
||||
>
|
||||
{t("button.save", { ns: "common" })}
|
||||
|
||||
@ -77,7 +77,7 @@ export default function TextEntry({
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="text-md w-full"
|
||||
className="w-full"
|
||||
placeholder={placeholder}
|
||||
type="text"
|
||||
/>
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -433,36 +433,30 @@ export default function CreateTriggerDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -411,36 +411,30 @@ export default function CreateUserDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -144,7 +144,7 @@ export function CustomTimeSelector({
|
||||
/>
|
||||
<SelectSeparator className="bg-secondary" />
|
||||
<input
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="startTime"
|
||||
type="time"
|
||||
value={startClock}
|
||||
@ -210,7 +210,7 @@ export function CustomTimeSelector({
|
||||
/>
|
||||
<SelectSeparator className="bg-secondary" />
|
||||
<input
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="endTime"
|
||||
type="time"
|
||||
value={endClock}
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -70,38 +70,31 @@ export default function DeleteRoleDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
variant="destructive"
|
||||
disabled={isLoading}
|
||||
onClick={handleDelete}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("roles.dialog.deleteRole.deleting")}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.delete", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
variant="destructive"
|
||||
disabled={isLoading}
|
||||
onClick={handleDelete}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("roles.dialog.deleteRole.deleting")}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.delete", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -43,36 +43,30 @@ export default function DeleteTriggerDialog({
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
className="flex flex-1 text-white"
|
||||
onClick={onDelete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("button.delete", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.delete", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
onClick={onDelete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("button.delete", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.delete", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -46,27 +46,21 @@ export default function DeleteUserDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
className="flex flex-1 text-white"
|
||||
onClick={onDelete}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()}
|
||||
>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -117,30 +117,23 @@ export default function RoleChangeDialog({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
className="flex flex-1"
|
||||
onClick={() => onSave(selectedRole)}
|
||||
type="button"
|
||||
disabled={selectedRole === currentRole}
|
||||
>
|
||||
{t("button.save", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
onClick={() => onSave(selectedRole)}
|
||||
type="button"
|
||||
disabled={selectedRole === currentRole}
|
||||
>
|
||||
{t("button.save", { ns: "common" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -450,36 +450,30 @@ export default function SetPasswordDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
type="submit"
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -196,21 +196,16 @@ export function ShareTimestampContent({
|
||||
|
||||
{isDesktop && <Separator className="my-4 bg-secondary" />}
|
||||
|
||||
<DialogFooter
|
||||
className={cn("mt-4", !isDesktop && "flex flex-col-reverse gap-2")}
|
||||
>
|
||||
<DialogFooter className="mt-3 sm:mt-0">
|
||||
{onCancel && (
|
||||
<Button
|
||||
className={cn(!isDesktop && "w-full")}
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className={cn(!isDesktop && "w-full")}
|
||||
variant="select"
|
||||
onClick={() => onShareTimestamp(Math.floor(selectedTimestamp))}
|
||||
>
|
||||
@ -338,7 +333,7 @@ function CustomTimestampSelector({
|
||||
/>
|
||||
<div className="my-3 h-px w-full bg-secondary" />
|
||||
<input
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="shareTimestamp"
|
||||
type="time"
|
||||
value={clock}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
1046
web/src/components/settings/CloneCameraDialog.tsx
Normal file
1046
web/src/components/settings/CloneCameraDialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" })}
|
||||
|
||||
@ -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" })}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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()}
|
||||
>
|
||||
|
||||
@ -567,7 +567,6 @@ function LibrarySelector({
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="text-white"
|
||||
onClick={() => {
|
||||
if (confirmDelete) {
|
||||
handleDeleteFace(confirmDelete);
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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">
|
||||
|
||||
856
web/src/utils/cameraClone.ts
Normal file
856
web/src/utils/cameraClone.ts
Normal 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,
|
||||
}));
|
||||
});
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -668,7 +668,6 @@ function LibrarySelector({
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="text-white"
|
||||
onClick={() => {
|
||||
if (confirmDelete) {
|
||||
handleDeleteCategory(confirmDelete);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -1052,7 +1052,6 @@ export default function MotionSearchView({
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="text-white"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void cancelMotionSearchJob(jobId, jobCamera);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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")}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user