diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx
index 58600ed3fd..1534f93a99 100644
--- a/web/src/components/card/ExportCard.tsx
+++ b/web/src/components/card/ExportCard.tsx
@@ -275,7 +275,6 @@ export function ExportCard({
);
diff --git a/web/src/components/input/SaveSearchDialog.tsx b/web/src/components/input/SaveSearchDialog.tsx
index 5eebdda746..e4b7b0103e 100644
--- a/web/src/components/input/SaveSearchDialog.tsx
+++ b/web/src/components/input/SaveSearchDialog.tsx
@@ -88,7 +88,6 @@ export function SaveSearchDialog({
{t("button.save", { ns: "common" })}
diff --git a/web/src/components/overlay/CreateRoleDialog.tsx b/web/src/components/overlay/CreateRoleDialog.tsx
index 2a1b79b761..fef205ea48 100644
--- a/web/src/components/overlay/CreateRoleDialog.tsx
+++ b/web/src/components/overlay/CreateRoleDialog.tsx
@@ -213,36 +213,30 @@ export default function CreateRoleDialog({
-
-
-
-
- {t("button.cancel", { ns: "common" })}
-
-
- {isLoading ? (
-
-
-
{t("button.saving", { ns: "common" })}
-
- ) : (
- t("button.save", { ns: "common" })
- )}
-
-
-
+
+
+ {t("button.cancel", { ns: "common" })}
+
+
+ {isLoading ? (
+
+
+
{t("button.saving", { ns: "common" })}
+
+ ) : (
+ t("button.save", { ns: "common" })
+ )}
+
diff --git a/web/src/components/overlay/CreateTriggerDialog.tsx b/web/src/components/overlay/CreateTriggerDialog.tsx
index 1db934e3c3..ecd993f7e0 100644
--- a/web/src/components/overlay/CreateTriggerDialog.tsx
+++ b/web/src/components/overlay/CreateTriggerDialog.tsx
@@ -433,36 +433,30 @@ export default function CreateTriggerDialog({
)}
/>
-
-
-
-
- {t("button.cancel", { ns: "common" })}
-
-
- {isLoading ? (
-
-
-
{t("button.saving", { ns: "common" })}
-
- ) : (
- t("button.save", { ns: "common" })
- )}
-
-
-
+
+
+ {t("button.cancel", { ns: "common" })}
+
+
+ {isLoading ? (
+
+
+
{t("button.saving", { ns: "common" })}
+
+ ) : (
+ t("button.save", { ns: "common" })
+ )}
+
diff --git a/web/src/components/overlay/CreateUserDialog.tsx b/web/src/components/overlay/CreateUserDialog.tsx
index c0e17bb374..3b003f2611 100644
--- a/web/src/components/overlay/CreateUserDialog.tsx
+++ b/web/src/components/overlay/CreateUserDialog.tsx
@@ -411,36 +411,30 @@ export default function CreateUserDialog({
)}
/>
-
-
-
-
- {t("button.cancel", { ns: "common" })}
-
-
- {isLoading ? (
-
-
-
{t("button.saving", { ns: "common" })}
-
- ) : (
- t("button.save", { ns: "common" })
- )}
-
-
-
+
+
+ {t("button.cancel", { ns: "common" })}
+
+
+ {isLoading ? (
+
+
+
{t("button.saving", { ns: "common" })}
+
+ ) : (
+ t("button.save", { ns: "common" })
+ )}
+
diff --git a/web/src/components/overlay/DebugReplayDialog.tsx b/web/src/components/overlay/DebugReplayDialog.tsx
index 665040dc5c..3aa4b87e1c 100644
--- a/web/src/components/overlay/DebugReplayDialog.tsx
+++ b/web/src/components/overlay/DebugReplayDialog.tsx
@@ -113,19 +113,14 @@ export function DebugReplayContent({
{isDesktop && }
-
+
{t("button.cancel", { ns: "common" })}
{
diff --git a/web/src/components/overlay/DeleteRoleDialog.tsx b/web/src/components/overlay/DeleteRoleDialog.tsx
index ad5fc8d064..7c8cbe119b 100644
--- a/web/src/components/overlay/DeleteRoleDialog.tsx
+++ b/web/src/components/overlay/DeleteRoleDialog.tsx
@@ -70,38 +70,31 @@ export default function DeleteRoleDialog({
-
-
-
-
- {t("button.cancel", { ns: "common" })}
-
-
- {isLoading ? (
-
-
-
{t("roles.dialog.deleteRole.deleting")}
-
- ) : (
- t("button.delete", { ns: "common" })
- )}
-
-
-
+
+
+ {t("button.cancel", { ns: "common" })}
+
+
+ {isLoading ? (
+
+
+
{t("roles.dialog.deleteRole.deleting")}
+
+ ) : (
+ t("button.delete", { ns: "common" })
+ )}
+
diff --git a/web/src/components/overlay/DeleteTriggerDialog.tsx b/web/src/components/overlay/DeleteTriggerDialog.tsx
index 0a7c93d9c0..4c34f46dcf 100644
--- a/web/src/components/overlay/DeleteTriggerDialog.tsx
+++ b/web/src/components/overlay/DeleteTriggerDialog.tsx
@@ -43,36 +43,30 @@ export default function DeleteTriggerDialog({
-
-
-
-
- {t("button.cancel", { ns: "common" })}
-
-
- {isLoading ? (
-
-
-
{t("button.delete", { ns: "common" })}
-
- ) : (
- t("button.delete", { ns: "common" })
- )}
-
-
-
+
+
+ {t("button.cancel", { ns: "common" })}
+
+
+ {isLoading ? (
+
+
+
{t("button.delete", { ns: "common" })}
+
+ ) : (
+ t("button.delete", { ns: "common" })
+ )}
+
diff --git a/web/src/components/overlay/DeleteUserDialog.tsx b/web/src/components/overlay/DeleteUserDialog.tsx
index 162ef22415..e9c73cb944 100644
--- a/web/src/components/overlay/DeleteUserDialog.tsx
+++ b/web/src/components/overlay/DeleteUserDialog.tsx
@@ -46,27 +46,21 @@ export default function DeleteUserDialog({
-
-
-
-
- {t("button.cancel", { ns: "common" })}
-
-
- {t("button.delete", { ns: "common" })}
-
-
-
+
+
+ {t("button.cancel", { ns: "common" })}
+
+
+ {t("button.delete", { ns: "common" })}
+
diff --git a/web/src/components/overlay/EditRoleCamerasDialog.tsx b/web/src/components/overlay/EditRoleCamerasDialog.tsx
index f23dc0931a..cfa84db81f 100644
--- a/web/src/components/overlay/EditRoleCamerasDialog.tsx
+++ b/web/src/components/overlay/EditRoleCamerasDialog.tsx
@@ -105,13 +105,15 @@ export default function EditRoleCamerasDialog({
diff --git a/web/src/components/overlay/ShareTimestampDialog.tsx b/web/src/components/overlay/ShareTimestampDialog.tsx
index 2345798194..cbc07f7df3 100644
--- a/web/src/components/overlay/ShareTimestampDialog.tsx
+++ b/web/src/components/overlay/ShareTimestampDialog.tsx
@@ -196,21 +196,16 @@ export function ShareTimestampContent({
{isDesktop && }
-
+
{onCancel && (
{t("button.cancel", { ns: "common" })}
)}
onShareTimestamp(Math.floor(selectedTimestamp))}
>
diff --git a/web/src/components/overlay/dialog/DeleteCameraDialog.tsx b/web/src/components/overlay/dialog/DeleteCameraDialog.tsx
index 472e6bfa74..be5ebda1d6 100644
--- a/web/src/components/overlay/dialog/DeleteCameraDialog.tsx
+++ b/web/src/components/overlay/dialog/DeleteCameraDialog.tsx
@@ -121,28 +121,22 @@ export default function DeleteCameraDialog({
))}
-
-
-
-
- {t("button.cancel", { ns: "common" })}
-
-
- {t("button.delete", { ns: "common" })}
-
-
-
+
+
+ {t("button.cancel", { ns: "common" })}
+
+
+ {t("button.delete", { ns: "common" })}
+
>
) : (
@@ -173,39 +167,31 @@ export default function DeleteCameraDialog({
{t("cameraManagement.deleteCameraDialog.deleteExports")}
-
-
-
-
- {t("button.back", { ns: "common" })}
-
-
- {isDeleting ? (
-
-
-
- {t(
- "cameraManagement.deleteCameraDialog.confirmButton",
- )}
-
-
- ) : (
- t("cameraManagement.deleteCameraDialog.confirmButton")
- )}
-
-
-
+
+
+ {t("button.back", { ns: "common" })}
+
+
+ {isDeleting ? (
+
+
+
+ {t("cameraManagement.deleteCameraDialog.confirmButton")}
+
+
+ ) : (
+ t("cameraManagement.deleteCameraDialog.confirmButton")
+ )}
+
>
)}
diff --git a/web/src/components/overlay/dialog/MultiSelectDialog.tsx b/web/src/components/overlay/dialog/MultiSelectDialog.tsx
index b30ac9cf53..f347d6c775 100644
--- a/web/src/components/overlay/dialog/MultiSelectDialog.tsx
+++ b/web/src/components/overlay/dialog/MultiSelectDialog.tsx
@@ -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({
/>
))}
-
+
setOpen(false)}>
{t("button.cancel")}
diff --git a/web/src/components/overlay/dialog/OptionAndInputDialog.tsx b/web/src/components/overlay/dialog/OptionAndInputDialog.tsx
index 0225d18267..89f100eaa3 100644
--- a/web/src/components/overlay/dialog/OptionAndInputDialog.tsx
+++ b/web/src/components/overlay/dialog/OptionAndInputDialog.tsx
@@ -164,10 +164,9 @@ export default function OptionAndInputDialog({
)}
-
+
{
setOpen(false);
diff --git a/web/src/components/overlay/dialog/TextEntryDialog.tsx b/web/src/components/overlay/dialog/TextEntryDialog.tsx
index 38fcee6573..27372352eb 100644
--- a/web/src/components/overlay/dialog/TextEntryDialog.tsx
+++ b/web/src/components/overlay/dialog/TextEntryDialog.tsx
@@ -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}
>
-
+
-
+
{changeCount > 0 && (
<>
@@ -1005,7 +1005,7 @@ export default function CloneCameraDialog({
>
)}
-
+
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) => (
+}: React.HTMLAttributes &
+ VariantProps) => (
);
diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx
index f65adbff01..3ad0795e67 100644
--- a/web/src/components/ui/dialog.tsx
+++ b/web/src/components/ui/dialog.tsx
@@ -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) => (
+}: React.HTMLAttributes &
+ VariantProps) => (
);
diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx
index 244fcf7635..455ed2db6c 100644
--- a/web/src/pages/Exports.tsx
+++ b/web/src/pages/Exports.tsx
@@ -1277,8 +1277,8 @@ function CaseEditorDialog({
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
-
-
+
+
{t("button.cancel", { ns: "common" })}
-
+
@@ -1427,13 +1427,12 @@ function CaseAddExportDialog({
)}
-
-
+
+
{t("button.cancel", { ns: "common" })}
void handleAdd()}
>
diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx
index 86ab910374..63a1217a2b 100644
--- a/web/src/views/events/EventView.tsx
+++ b/web/src/views/events/EventView.tsx
@@ -1389,9 +1389,8 @@ function MotionReview({
selectedCells={pendingFilterCells}
onCellsChange={setPendingFilterCells}
/>
-
+
{
setPendingFilterCells(new Set());
diff --git a/web/src/views/settings/Go2RtcStreamsSettingsView.tsx b/web/src/views/settings/Go2RtcStreamsSettingsView.tsx
index 73fac7e9b0..6ed43286ee 100644
--- a/web/src/views/settings/Go2RtcStreamsSettingsView.tsx
+++ b/web/src/views/settings/Go2RtcStreamsSettingsView.tsx
@@ -547,7 +547,7 @@ function RenameStreamDialog({
{nameError}
)}
-
+
{t("button.cancel", { ns: "common" })}
@@ -628,7 +628,7 @@ function AddStreamDialog({
{nameError}
)}
-
+
{t("button.cancel", { ns: "common" })}
diff --git a/web/src/views/settings/ProfilesView.tsx b/web/src/views/settings/ProfilesView.tsx
index 8991e38f65..4c97b1bb07 100644
--- a/web/src/views/settings/ProfilesView.tsx
+++ b/web/src/views/settings/ProfilesView.tsx
@@ -654,10 +654,9 @@ export default function ProfilesView({
ns: "views/settings",
})}
/>
-
+
setAddDialogOpen(false)}
disabled={addingProfile}
>
@@ -746,7 +745,6 @@ export default function ProfilesView({
/>
setRenameProfile(null)}
disabled={renaming}
>