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")} + + + + + + + + {t("notification.customSuspension.hours")} + + { + const n = Number.parseInt(e.target.value, 10); + setHours(Number.isNaN(n) ? 0 : Math.max(0, n)); + }} + /> + + + + {t("notification.customSuspension.minutes")} + + { + const n = Number.parseInt(e.target.value, 10); + setMinutes( + Number.isNaN(n) ? 0 : Math.min(59, Math.max(0, n)), + ); + }} + /> + + + + + + + {t("notification.customSuspension.untilLabel")} + + + + + + {dateText} + + + + { + 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")} + + )} + + + + + + onOpenChange(false)}> + {t("notification.customSuspension.cancel")} + + + {t("notification.customSuspension.apply")} + + + + + ); +}
+ {t("notification.customSuspension.invalidTime")} +