From 0d05f0feaa2fae753725cac320cc67646f020257 Mon Sep 17 00:00:00 2001 From: Dmitry Marchuk Date: Sun, 17 May 2026 08:50:19 +0300 Subject: [PATCH 1/6] Add custom notification suspension dialog --- web/public/locales/en/common.json | 1 + web/public/locales/en/views/settings.json | 13 + .../NotificationsSettingsExtras.tsx | 22 ++ web/src/components/menu/LiveContextMenu.tsx | 24 ++ .../overlay/dialog/CustomSuspensionDialog.tsx | 273 ++++++++++++++++++ 5 files changed, 333 insertions(+) create mode 100644 web/src/components/overlay/dialog/CustomSuspensionDialog.tsx diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index de17f444b5..90fd7d53b4 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -21,6 +21,7 @@ "1hour": "1 hour", "12hours": "12 hours", "24hours": "24 hours", + "custom": "Custom\u2026", "pm": "pm", "am": "am", "yr": "{{time}}yr", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index a1e14452e5..d1ddbc1500 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1050,8 +1050,21 @@ "1hour": "Suspend for 1 hour", "12hours": "Suspend for 12 hours", "24hours": "Suspend for 24 hours", + "custom": "Suspend for custom time\u2026", "untilRestart": "Suspend until restart" }, + "customSuspension": { + "title": "Custom suspension time", + "description": "Choose how long notifications should stay suspended for this camera.", + "tabDuration": "Duration", + "tabUntilTime": "Until time", + "hours": "Hours", + "minutes": "Minutes", + "untilLabel": "Suspend until", + "invalidTime": "Pick a time in the future.", + "apply": "Apply", + "cancel": "Cancel" + }, "cancelSuspension": "Cancel Suspension", "toast": { "success": { diff --git a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx index b97c90448d..f84c5d377c 100644 --- a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx +++ b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx @@ -50,6 +50,7 @@ import { Trans, useTranslation } from "react-i18next"; import { useDateLocale } from "@/hooks/use-date-locale"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; +import CustomSuspensionDialog from "@/components/overlay/dialog/CustomSuspensionDialog"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { cn } from "@/lib/utils"; import cloneDeep from "lodash/cloneDeep"; @@ -738,7 +739,13 @@ export function CameraNotificationSwitch({ } }, [notificationSuspendUntil, notificationState]); + const [customDialogOpen, setCustomDialogOpen] = useState(false); + const handleSuspend = (duration: string) => { + if (duration === "custom") { + setCustomDialogOpen(true); + return; + } setIsSuspended(true); if (duration == "off") { sendNotification("OFF"); @@ -747,6 +754,11 @@ export function CameraNotificationSwitch({ } }; + const handleCustomSuspend = (totalMinutes: number) => { + setIsSuspended(true); + sendNotificationSuspend(totalMinutes); + }; + const handleCancelSuspension = () => { sendNotification("ON"); sendNotificationSuspend(0); @@ -829,6 +841,9 @@ export function CameraNotificationSwitch({ {t("notification.suspendTime.24hours")} + + {t("notification.suspendTime.custom")} + {t("notification.suspendTime.untilRestart")} @@ -843,6 +858,13 @@ export function CameraNotificationSwitch({ {t("notification.cancelSuspension")} )} + + ); } diff --git a/web/src/components/menu/LiveContextMenu.tsx b/web/src/components/menu/LiveContextMenu.tsx index 982895200d..ddf0b7e7f8 100644 --- a/web/src/components/menu/LiveContextMenu.tsx +++ b/web/src/components/menu/LiveContextMenu.tsx @@ -50,6 +50,7 @@ import { use24HourTime } from "@/hooks/use-date-utils"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { CameraNameLabel } from "../camera/FriendlyNameLabel"; import { LiveStreamMetadata } from "@/types/live"; +import CustomSuspensionDialog from "@/components/overlay/dialog/CustomSuspensionDialog"; type LiveContextMenuProps = { className?: string; @@ -238,7 +239,13 @@ export default function LiveContextMenu({ } }, [notificationSuspendUntil, notificationState]); + const [customDialogOpen, setCustomDialogOpen] = useState(false); + const handleSuspend = (duration: string) => { + if (duration === "custom") { + setCustomDialogOpen(true); + return; + } if (duration === "off") { sendNotification("OFF"); } else { @@ -534,6 +541,16 @@ export default function LiveContextMenu({ > {t("time.24hours", { ns: "common" })} + handleSuspend("custom") + : undefined + } + > + {t("time.custom", { ns: "common" })} + + + sendNotificationSuspend(minutes)} + config={config} + /> ); } diff --git a/web/src/components/overlay/dialog/CustomSuspensionDialog.tsx b/web/src/components/overlay/dialog/CustomSuspensionDialog.tsx new file mode 100644 index 0000000000..7a77f8d95c --- /dev/null +++ b/web/src/components/overlay/dialog/CustomSuspensionDialog.tsx @@ -0,0 +1,273 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { isDesktop } from "react-device-detect"; +import { FaCalendarAlt } from "react-icons/fa"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Label } from "@/components/ui/label"; +import { Calendar } from "@/components/ui/calendar"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { getUTCOffset } from "@/utils/dateUtil"; + +type CustomSuspensionDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (minutes: number) => void; + config?: FrigateConfig; +}; + +export default function CustomSuspensionDialog({ + open, + onOpenChange, + onConfirm, + config, +}: CustomSuspensionDialogProps) { + const { t } = useTranslation(["views/settings"]); + + const [tab, setTab] = useState<"duration" | "untilTime">("duration"); + + // duration tab state + const [hours, setHours] = useState(1); + const [minutes, setMinutes] = useState(0); + + // until-time tab state — epoch seconds in UI-timezone-adjusted frame, + // matching the pattern used by CustomTimeSelector. + const timezoneOffset = useMemo( + () => + config?.ui.timezone + ? Math.round(getUTCOffset(new Date(), config.ui.timezone)) + : undefined, + [config?.ui.timezone], + ); + const localTimeOffset = useMemo( + () => + Math.round( + getUTCOffset( + new Date(), + Intl.DateTimeFormat().resolvedOptions().timeZone, + ), + ), + [], + ); + + const initialUntilEpoch = () => { + let epoch = Math.floor(Date.now() / 1000) + 3600; + if (timezoneOffset !== undefined) { + epoch = epoch + (timezoneOffset - localTimeOffset) * 60; + } + return epoch; + }; + + const [untilEpoch, setUntilEpoch] = useState(initialUntilEpoch); + const [calendarOpen, setCalendarOpen] = useState(false); + + useEffect(() => { + if (open) { + setTab("duration"); + setHours(1); + setMinutes(0); + setUntilEpoch(initialUntilEpoch()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const clockText = useMemo(() => { + const date = new Date(untilEpoch * 1000); + return `${date.getHours().toString().padStart(2, "0")}:${date + .getMinutes() + .toString() + .padStart(2, "0")}`; + }, [untilEpoch]); + + const dateText = useMemo(() => { + const date = new Date(untilEpoch * 1000); + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + }, [untilEpoch]); + + const totalMinutes = useMemo(() => { + if (tab === "duration") { + return Math.max(0, Math.floor(hours) * 60 + Math.floor(minutes)); + } + // until-time: undo the TZ shift to get the real target epoch, then diff now. + let realEpoch = untilEpoch; + if (timezoneOffset !== undefined) { + realEpoch = untilEpoch - (timezoneOffset - localTimeOffset) * 60; + } + const nowEpoch = Math.floor(Date.now() / 1000); + return Math.ceil((realEpoch - nowEpoch) / 60); + }, [tab, hours, minutes, untilEpoch, timezoneOffset, localTimeOffset]); + + const canApply = totalMinutes > 0; + + const handleApply = () => { + if (!canApply) return; + onConfirm(totalMinutes); + onOpenChange(false); + }; + + return ( + + + + {t("notification.customSuspension.title")} + + {t("notification.customSuspension.description")} + + + + setTab(v as "duration" | "untilTime")} + > + + + {t("notification.customSuspension.tabDuration")} + + + {t("notification.customSuspension.tabUntilTime")} + + + + +
+
+ + { + const n = Number.parseInt(e.target.value, 10); + setHours(Number.isNaN(n) ? 0 : Math.max(0, n)); + }} + /> +
+
+ + { + const n = Number.parseInt(e.target.value, 10); + setMinutes( + Number.isNaN(n) ? 0 : Math.min(59, Math.max(0, n)), + ); + }} + /> +
+
+
+ + +
+ +
+ + + + + + + { + if (!day) return; + const current = new Date(untilEpoch * 1000); + const next = new Date(day); + next.setHours( + current.getHours(), + current.getMinutes(), + current.getSeconds(), + 0, + ); + setUntilEpoch(Math.floor(next.getTime() / 1000)); + setCalendarOpen(false); + }} + /> + + + { + const [h, m] = e.target.value.split(":"); + const next = new Date(untilEpoch * 1000); + next.setHours( + Number.parseInt(h ?? "0", 10), + Number.parseInt(m ?? "0", 10), + 0, + 0, + ); + setUntilEpoch(Math.floor(next.getTime() / 1000)); + }} + /> +
+ {!canApply && ( +

+ {t("notification.customSuspension.invalidTime")} +

+ )} +
+
+
+ + + + + +
+
+ ); +} From 263554a5f630146593545ad0d321eb1a8b221a78 Mon Sep 17 00:00:00 2001 From: Dmitry Marchuk Date: Fri, 22 May 2026 11:29:43 +0300 Subject: [PATCH 2/6] Improve on the dialog, fix some bugs --- .../NotificationsSettingsExtras.tsx | 16 +- web/src/components/menu/LiveContextMenu.tsx | 1 - .../overlay/dialog/CustomSuspensionDialog.tsx | 169 +++++++----------- 3 files changed, 79 insertions(+), 107 deletions(-) diff --git a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx index f84c5d377c..55b520b08b 100644 --- a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx +++ b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx @@ -740,9 +740,13 @@ export function CameraNotificationSwitch({ }, [notificationSuspendUntil, notificationState]); const [customDialogOpen, setCustomDialogOpen] = useState(false); + // Doesn't actually represent the state of the Select + // Workaround for CustomSuspensionDialog (explained below at setSelectValue call site). + const [selectValue, setSelectValue] = useState(""); const handleSuspend = (duration: string) => { if (duration === "custom") { + setSelectValue("custom"); setCustomDialogOpen(true); return; } @@ -818,7 +822,7 @@ export function CameraNotificationSwitch({ {!isSuspended ? ( - @@ -861,9 +865,15 @@ export function CameraNotificationSwitch({ { + setCustomDialogOpen(open); + // Radix treats `undefined` as "uncontrolled", which keeps the last + // internal selection. This results in an option "Suspend for custom time..." still + // being selected if the CustomSuspensionDialog is closed without applying the suspension + // So we explicitly set "" on CustomSuspensionDialog closure + if (!open) setSelectValue(""); + }} onConfirm={handleCustomSuspend} - config={config} /> ); diff --git a/web/src/components/menu/LiveContextMenu.tsx b/web/src/components/menu/LiveContextMenu.tsx index ad71619cf6..c240e34dd0 100644 --- a/web/src/components/menu/LiveContextMenu.tsx +++ b/web/src/components/menu/LiveContextMenu.tsx @@ -588,7 +588,6 @@ export default function LiveContextMenu({ open={customDialogOpen} onOpenChange={setCustomDialogOpen} onConfirm={(minutes) => sendNotificationSuspend(minutes)} - config={config} /> ); diff --git a/web/src/components/overlay/dialog/CustomSuspensionDialog.tsx b/web/src/components/overlay/dialog/CustomSuspensionDialog.tsx index 7a77f8d95c..c5efebb190 100644 --- a/web/src/components/overlay/dialog/CustomSuspensionDialog.tsx +++ b/web/src/components/overlay/dialog/CustomSuspensionDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { isDesktop } from "react-device-detect"; import { FaCalendarAlt } from "react-icons/fa"; @@ -20,108 +20,70 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Label } from "@/components/ui/label"; import { Calendar } from "@/components/ui/calendar"; -import { FrigateConfig } from "@/types/frigateConfig"; -import { getUTCOffset } from "@/utils/dateUtil"; type CustomSuspensionDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; onConfirm: (minutes: number) => void; - config?: FrigateConfig; }; +const ONE_HOUR_MS = 60 * 60 * 1000; + +function pad(n: number): string { + return n.toString().padStart(2, "0"); +} + +function isValidDate(d: Date): boolean { + return !Number.isNaN(d.getTime()); +} + +function parsePositive(value: string): number { + const n = Number.parseInt(value, 10); + if (Number.isNaN(n)) return 0; + return Math.max(0, n); +} + +type Tabs = "duration" | "untilTime"; + export default function CustomSuspensionDialog({ open, onOpenChange, onConfirm, - config, }: CustomSuspensionDialogProps) { const { t } = useTranslation(["views/settings"]); - const [tab, setTab] = useState<"duration" | "untilTime">("duration"); - - // duration tab state + const [tab, setTab] = useState("duration"); const [hours, setHours] = useState(1); const [minutes, setMinutes] = useState(0); - - // until-time tab state — epoch seconds in UI-timezone-adjusted frame, - // matching the pattern used by CustomTimeSelector. - const timezoneOffset = useMemo( - () => - config?.ui.timezone - ? Math.round(getUTCOffset(new Date(), config.ui.timezone)) - : undefined, - [config?.ui.timezone], + const [until, setUntil] = useState( + () => new Date(Date.now() + ONE_HOUR_MS), ); - const localTimeOffset = useMemo( - () => - Math.round( - getUTCOffset( - new Date(), - Intl.DateTimeFormat().resolvedOptions().timeZone, - ), - ), - [], - ); - - const initialUntilEpoch = () => { - let epoch = Math.floor(Date.now() / 1000) + 3600; - if (timezoneOffset !== undefined) { - epoch = epoch + (timezoneOffset - localTimeOffset) * 60; - } - return epoch; - }; - - const [untilEpoch, setUntilEpoch] = useState(initialUntilEpoch); const [calendarOpen, setCalendarOpen] = useState(false); + // Reset to defaults whenever the dialog re-opens. useEffect(() => { - if (open) { - setTab("duration"); - setHours(1); - setMinutes(0); - setUntilEpoch(initialUntilEpoch()); - } - // eslint-disable-next-line react-hooks/exhaustive-deps + if (!open) return; + setTab("duration"); + setHours(1); + setMinutes(0); + setUntil(new Date(Date.now() + ONE_HOUR_MS)); }, [open]); - const clockText = useMemo(() => { - const date = new Date(untilEpoch * 1000); - return `${date.getHours().toString().padStart(2, "0")}:${date - .getMinutes() - .toString() - .padStart(2, "0")}`; - }, [untilEpoch]); - - const dateText = useMemo(() => { - const date = new Date(untilEpoch * 1000); - return date.toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }); - }, [untilEpoch]); - const totalMinutes = useMemo(() => { if (tab === "duration") { return Math.max(0, Math.floor(hours) * 60 + Math.floor(minutes)); } - // until-time: undo the TZ shift to get the real target epoch, then diff now. - let realEpoch = untilEpoch; - if (timezoneOffset !== undefined) { - realEpoch = untilEpoch - (timezoneOffset - localTimeOffset) * 60; - } - const nowEpoch = Math.floor(Date.now() / 1000); - return Math.ceil((realEpoch - nowEpoch) / 60); - }, [tab, hours, minutes, untilEpoch, timezoneOffset, localTimeOffset]); + if (!isValidDate(until)) return 0; + return Math.ceil((until.getTime() - Date.now()) / 60_000); + }, [hours, minutes, tab, until]); - const canApply = totalMinutes > 0; + const canApply = useMemo(() => totalMinutes > 0, [totalMinutes]); - const handleApply = () => { + const handleApply = useCallback(() => { if (!canApply) return; onConfirm(totalMinutes); onOpenChange(false); - }; + }, [canApply, onConfirm, onOpenChange, totalMinutes]); return ( @@ -133,10 +95,7 @@ export default function CustomSuspensionDialog({ - setTab(v as "duration" | "untilTime")} - > + setTab(v as Tabs)}> {t("notification.customSuspension.tabDuration")} @@ -156,12 +115,9 @@ export default function CustomSuspensionDialog({ id="suspend-hours" type="number" min={0} - max={168} + max={999} value={hours} - onChange={(e) => { - const n = Number.parseInt(e.target.value, 10); - setHours(Number.isNaN(n) ? 0 : Math.max(0, n)); - }} + onChange={(e) => setHours(parsePositive(e.target.value))} />
@@ -174,12 +130,7 @@ export default function CustomSuspensionDialog({ min={0} max={59} value={minutes} - onChange={(e) => { - const n = Number.parseInt(e.target.value, 10); - setMinutes( - Number.isNaN(n) ? 0 : Math.min(59, Math.max(0, n)), - ); - }} + onChange={(e) => setMinutes(parsePositive(e.target.value))} />
@@ -197,7 +148,13 @@ export default function CustomSuspensionDialog({ variant={calendarOpen ? "select" : "default"} size="sm" > - {dateText} + {isValidDate(until) + ? until.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }) + : "—"} { if (!day) return; - const current = new Date(untilEpoch * 1000); const next = new Date(day); + // If `until` is invalid, don't propagate + // NaN hours/minutes into the new date - fall back to now. + const carry = isValidDate(until) ? until : new Date(); next.setHours( - current.getHours(), - current.getMinutes(), - current.getSeconds(), + carry.getHours(), + carry.getMinutes(), + carry.getSeconds(), 0, ); - setUntilEpoch(Math.floor(next.getTime() / 1000)); + setUntil(next); setCalendarOpen(false); }} /> @@ -230,18 +189,22 @@ export default function CustomSuspensionDialog({ className="text-md border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" aria-label={t("notification.customSuspension.untilLabel")} type="time" - value={clockText} + value={ + isValidDate(until) + ? `${pad(until.getHours())}:${pad(until.getMinutes())}` + : "" + } step="60" onChange={(e) => { + // Ignore anything that doesn't parse to a real HH:MM pair. const [h, m] = e.target.value.split(":"); - const next = new Date(untilEpoch * 1000); - next.setHours( - Number.parseInt(h ?? "0", 10), - Number.parseInt(m ?? "0", 10), - 0, - 0, - ); - setUntilEpoch(Math.floor(next.getTime() / 1000)); + const hh = Number.parseInt(h ?? "", 10); + const mm = Number.parseInt(m ?? "", 10); + if (Number.isNaN(hh) || Number.isNaN(mm)) return; + const base = isValidDate(until) ? until : new Date(); + const next = new Date(base); + next.setHours(hh, mm, 0, 0); + setUntil(next); }} /> From df40d9e2b5621915f77cb0a75c7ff53a41f3bee1 Mon Sep 17 00:00:00 2001 From: Dmitry Marchuk Date: Sat, 23 May 2026 21:32:02 +0300 Subject: [PATCH 3/6] Review changes --- web/public/locales/en/common.json | 2 +- web/public/locales/en/views/settings.json | 12 +- .../NotificationsSettingsExtras.tsx | 83 +++--- web/src/components/menu/LiveContextMenu.tsx | 6 +- .../overlay/ReviewActivityCalendar.tsx | 13 +- .../overlay/dialog/CustomSuspensionDialog.tsx | 239 +++++++----------- 6 files changed, 137 insertions(+), 218 deletions(-) diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 2e16f84f1c..c5d9c4aca3 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -21,7 +21,7 @@ "1hour": "1 hour", "12hours": "12 hours", "24hours": "24 hours", - "custom": "Custom\u2026", + "custom": "Custom...", "pm": "pm", "am": "am", "yr": "{{time}}yr", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 8363cd13c3..3b2d6561f6 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1083,20 +1083,14 @@ "1hour": "Suspend for 1 hour", "12hours": "Suspend for 12 hours", "24hours": "Suspend for 24 hours", - "custom": "Suspend for custom time\u2026", + "custom": "Suspend until...", "untilRestart": "Suspend until restart" }, "customSuspension": { "title": "Custom suspension time", - "description": "Choose how long notifications should stay suspended for this camera.", - "tabDuration": "Duration", - "tabUntilTime": "Until time", - "hours": "Hours", - "minutes": "Minutes", + "description": "Suspend notifications for this camera until the selected time.", "untilLabel": "Suspend until", - "invalidTime": "Pick a time in the future.", - "apply": "Apply", - "cancel": "Cancel" + "invalidTime": "Pick a time in the future." }, "cancelSuspension": "Cancel Suspension", "toast": { diff --git a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx index 55b520b08b..a18667f326 100644 --- a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx +++ b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx @@ -740,16 +740,8 @@ export function CameraNotificationSwitch({ }, [notificationSuspendUntil, notificationState]); const [customDialogOpen, setCustomDialogOpen] = useState(false); - // Doesn't actually represent the state of the Select - // Workaround for CustomSuspensionDialog (explained below at setSelectValue call site). - const [selectValue, setSelectValue] = useState(""); const handleSuspend = (duration: string) => { - if (duration === "custom") { - setSelectValue("custom"); - setCustomDialogOpen(true); - return; - } setIsSuspended(true); if (duration == "off") { sendNotification("OFF"); @@ -822,37 +814,41 @@ export function CameraNotificationSwitch({ {!isSuspended ? ( - +
+ + +
) : ( - - - { - if (!day) return; - const next = new Date(day); - // If `until` is invalid, don't propagate - // NaN hours/minutes into the new date - fall back to now. - const carry = isValidDate(until) ? until : new Date(); - next.setHours( - carry.getHours(), - carry.getMinutes(), - carry.getSeconds(), - 0, - ); - setUntil(next); - setCalendarOpen(false); - }} - /> - - - { - // Ignore anything that doesn't parse to a real HH:MM pair. - const [h, m] = e.target.value.split(":"); - const hh = Number.parseInt(h ?? "", 10); - const mm = Number.parseInt(m ?? "", 10); - if (Number.isNaN(hh) || Number.isNaN(mm)) return; - const base = isValidDate(until) ? until : new Date(); - const next = new Date(base); - next.setHours(hh, mm, 0, 0); +
+ +
+ + + + + + + { + if (!day) return; + const next = new Date(day); + const carry = isValidDate(until) ? until : new Date(); + next.setHours( + carry.getHours(), + carry.getMinutes(), + carry.getSeconds(), + 0, + ); setUntil(next); + setCalendarOpen(false); }} /> -
- {!canApply && ( -

- {t("notification.customSuspension.invalidTime")} -

- )} -
- -
+ + + { + const [h, m] = e.target.value.split(":"); + const hh = Number.parseInt(h ?? "", 10); + const mm = Number.parseInt(m ?? "", 10); + if (Number.isNaN(hh) || Number.isNaN(mm)) return; + const base = isValidDate(until) ? until : new Date(); + const next = new Date(base); + next.setHours(hh, mm, 0, 0); + setUntil(next); + }} + /> + + {!isFuture && ( +

+ {t("notification.customSuspension.invalidTime")} +

+ )} + From bf255600678b32ffd82105d4c8b7b785375aafa2 Mon Sep 17 00:00:00 2001 From: Dmitry Marchuk Date: Sat, 23 May 2026 22:18:09 +0300 Subject: [PATCH 4/6] Make Dropdown look like Select --- .../NotificationsSettingsExtras.tsx | 85 ++++++++++--------- web/src/components/menu/LiveContextMenu.tsx | 2 +- web/src/components/ui/select.tsx | 8 +- 3 files changed, 48 insertions(+), 47 deletions(-) diff --git a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx index a18667f326..caec913f5e 100644 --- a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx +++ b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx @@ -24,7 +24,7 @@ import { useState, } from "react"; import { useForm } from "react-hook-form"; -import { LuCheck, LuExternalLink, LuX } from "react-icons/lu"; +import { LuCheck, LuChevronDown, LuExternalLink, LuX } from "react-icons/lu"; import { CiCircleAlert } from "react-icons/ci"; import { Link } from "react-router-dom"; import { toast } from "sonner"; @@ -36,12 +36,13 @@ import { useNotificationTest, } from "@/api/ws"; import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem, -} from "@/components/ui/select"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { selectTriggerClassName } from "@/components/ui/select"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { use24HourTime } from "@/hooks/use-date-utils"; import FilterSwitch from "@/components/filter/FilterSwitch"; @@ -814,41 +815,41 @@ export function CameraNotificationSwitch({ {!isSuspended ? ( -
- - -
+ + + {t("notification.suspendTime.suspend")} + + + + handleSuspend("5")}> + {t("notification.suspendTime.5minutes")} + + handleSuspend("10")}> + {t("notification.suspendTime.10minutes")} + + handleSuspend("30")}> + {t("notification.suspendTime.30minutes")} + + handleSuspend("60")}> + {t("notification.suspendTime.1hour")} + + handleSuspend("840")}> + {t("notification.suspendTime.12hours")} + + handleSuspend("1440")}> + {t("notification.suspendTime.24hours")} + + handleSuspend("off")}> + {t("notification.suspendTime.untilRestart")} + + + setCustomDialogOpen(true)}> + {t("notification.suspendTime.custom")} + + + ) : ( handleSuspend("5")}> diff --git a/web/src/components/menu/LiveContextMenu.tsx b/web/src/components/menu/LiveContextMenu.tsx index 00ac0e21a4..8694408d53 100644 --- a/web/src/components/menu/LiveContextMenu.tsx +++ b/web/src/components/menu/LiveContextMenu.tsx @@ -275,7 +275,7 @@ export default function LiveContextMenu({ return (
- + {children}
diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx index b6959d38b6..8eabc46518 100644 --- a/web/src/components/ui/select.tsx +++ b/web/src/components/ui/select.tsx @@ -10,16 +10,16 @@ const SelectGroup = SelectPrimitive.Group; const SelectValue = SelectPrimitive.Value; -export const selectTriggerClassName = - "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-background_alt [&>span]:line-clamp-1"; - const SelectTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( span]:line-clamp-1", + className, + )} {...props} > {children} From 51dd890eb65406ff5f11e4a7a388a77dd4447941 Mon Sep 17 00:00:00 2001 From: Dmitry Marchuk Date: Sun, 7 Jun 2026 08:03:55 +0300 Subject: [PATCH 6/6] Remove accidential changes --- .devcontainer/devcontainer.json | 15 +++++++++++++-- .devcontainer/post_create.sh | 8 ++------ 2 files changed, 15 insertions(+), 8 deletions(-) mode change 100755 => 100644 .devcontainer/post_create.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6a0f447f13..c782fb32ff 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,8 +6,19 @@ "initializeCommand": ".devcontainer/initialize.sh", "postCreateCommand": ".devcontainer/post_create.sh", "overrideCommand": false, - "remoteUser": "root", - "features": {}, + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": {} + // Uncomment the following lines to use ONNX Runtime with CUDA support + // "ghcr.io/devcontainers/features/nvidia-cuda:1": { + // "installCudnn": true, + // "installNvtx": true, + // "installToolkit": true, + // "cudaVersion": "12.5", + // "cudnnVersion": "9.4.0.58" + // }, + // "./features/onnxruntime-gpu": {} + }, "forwardPorts": [ 8971, 5000, diff --git a/.devcontainer/post_create.sh b/.devcontainer/post_create.sh old mode 100755 new mode 100644 index 9bad7f208c..fcf7ca693f --- a/.devcontainer/post_create.sh +++ b/.devcontainer/post_create.sh @@ -13,12 +13,8 @@ fi # Frigate normal container runs as root, so it have permission to create # the folders. But the devcontainer runs as the host user, so we need to # create the folders and give the host user permission to write to them. -SUDO="" -if [[ $EUID -ne 0 ]]; then - SUDO="sudo" -fi -$SUDO mkdir -p /media/frigate -$SUDO chown -R "$(id -u):$(id -g)" /media/frigate +sudo mkdir -p /media/frigate +sudo chown -R "$(id -u):$(id -g)" /media/frigate # When started as a service, LIBAVFORMAT_VERSION_MAJOR is defined in the # s6 service file. For dev, where frigate is started from an interactive