mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html
362 lines
11 KiB
TypeScript
362 lines
11 KiB
TypeScript
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Drawer, DrawerContent } from "@/components/ui/drawer";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { useFormattedTimestamp, useTimeFormat } from "@/hooks/use-date-utils";
|
|
import { cn } from "@/lib/utils";
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
|
import { getUTCOffset } from "@/utils/dateUtil";
|
|
import { useCallback, useMemo, useState } from "react";
|
|
import useSWR from "swr";
|
|
import { TimezoneAwareCalendar } from "./ReviewActivityCalendar";
|
|
import { FaCalendarAlt } from "react-icons/fa";
|
|
import { isDesktop, isIOS, isMobile } from "react-device-detect";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
type ShareTimestampDialogProps = {
|
|
currentTime: number;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
selectedOption: "current" | "custom";
|
|
setSelectedOption: (option: "current" | "custom") => void;
|
|
customTimestamp: number;
|
|
setCustomTimestamp: (timestamp: number) => void;
|
|
onShareTimestamp: (timestamp: number) => void;
|
|
};
|
|
|
|
export default function ShareTimestampDialog({
|
|
currentTime,
|
|
open,
|
|
onOpenChange,
|
|
selectedOption,
|
|
setSelectedOption,
|
|
customTimestamp,
|
|
setCustomTimestamp,
|
|
onShareTimestamp,
|
|
}: Readonly<ShareTimestampDialogProps>) {
|
|
const { t } = useTranslation(["components/dialog"]);
|
|
|
|
const handleOpenChange = useCallback(
|
|
(nextOpen: boolean) => onOpenChange(nextOpen),
|
|
[onOpenChange],
|
|
);
|
|
|
|
const content = (
|
|
<ShareTimestampContent
|
|
currentTime={currentTime}
|
|
selectedOption={selectedOption}
|
|
setSelectedOption={setSelectedOption}
|
|
customTimestamp={customTimestamp}
|
|
setCustomTimestamp={setCustomTimestamp}
|
|
onShareTimestamp={(timestamp) => {
|
|
onShareTimestamp(timestamp);
|
|
onOpenChange(false);
|
|
}}
|
|
onCancel={() => onOpenChange(false)}
|
|
/>
|
|
);
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<Drawer open={open} onOpenChange={handleOpenChange}>
|
|
<DrawerContent className="mx-4 rounded-lg px-4 pb-4 md:rounded-2xl">
|
|
{content}
|
|
</DrawerContent>
|
|
</Drawer>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
<DialogContent className="sm:rounded-lg md:rounded-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="whitespace-nowrap">
|
|
{t("recording.shareTimestamp.title", { ns: "components/dialog" })}
|
|
</DialogTitle>
|
|
<DialogDescription className="sr-only">
|
|
{t("recording.shareTimestamp.description", {
|
|
ns: "components/dialog",
|
|
})}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{content}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
type ShareTimestampContentProps = {
|
|
currentTime: number;
|
|
selectedOption: "current" | "custom";
|
|
setSelectedOption: (option: "current" | "custom") => void;
|
|
customTimestamp: number;
|
|
setCustomTimestamp: (timestamp: number) => void;
|
|
onShareTimestamp: (timestamp: number) => void;
|
|
onCancel?: () => void;
|
|
};
|
|
|
|
export function ShareTimestampContent({
|
|
currentTime,
|
|
selectedOption,
|
|
setSelectedOption,
|
|
customTimestamp,
|
|
setCustomTimestamp,
|
|
onShareTimestamp,
|
|
onCancel,
|
|
}: Readonly<ShareTimestampContentProps>) {
|
|
const { t } = useTranslation(["common", "components/dialog"]);
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
const timeFormat = useTimeFormat(config);
|
|
const currentTimestampLabel = useFormattedTimestamp(
|
|
currentTime,
|
|
timeFormat == "24hour"
|
|
? t("time.formattedTimestamp.24hour")
|
|
: t("time.formattedTimestamp.12hour"),
|
|
config?.ui.timezone,
|
|
);
|
|
const selectedTimestamp =
|
|
selectedOption === "current" ? currentTime : customTimestamp;
|
|
|
|
return (
|
|
<div className="w-full">
|
|
<div className="space-y-1">
|
|
<div className="text-sm text-muted-foreground">
|
|
{t("recording.shareTimestamp.description", {
|
|
ns: "components/dialog",
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{isDesktop && <Separator className="my-4 bg-secondary" />}
|
|
|
|
<RadioGroup
|
|
className="mt-4 flex flex-col gap-4"
|
|
value={selectedOption}
|
|
onValueChange={(value) =>
|
|
setSelectedOption(value as "current" | "custom")
|
|
}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<RadioGroupItem
|
|
className={
|
|
selectedOption == "current"
|
|
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
|
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
|
}
|
|
id="share-current"
|
|
value="current"
|
|
/>
|
|
<Label className="cursor-pointer text-sm" htmlFor="share-current">
|
|
{currentTimestampLabel}
|
|
</Label>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<RadioGroupItem
|
|
className={
|
|
selectedOption == "custom"
|
|
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
|
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
|
}
|
|
id="share-custom"
|
|
value="custom"
|
|
/>
|
|
<div className="space-y-3">
|
|
<Label className="cursor-pointer text-sm" htmlFor="share-custom">
|
|
{t("recording.shareTimestamp.custom", {
|
|
ns: "components/dialog",
|
|
})}
|
|
</Label>
|
|
{selectedOption === "custom" && (
|
|
<CustomTimestampSelector
|
|
timestamp={customTimestamp}
|
|
setTimestamp={setCustomTimestamp}
|
|
label={t("recording.shareTimestamp.custom", {
|
|
ns: "components/dialog",
|
|
})}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</RadioGroup>
|
|
|
|
{isDesktop && <Separator className="my-4 bg-secondary" />}
|
|
|
|
<DialogFooter className="mt-3 sm:mt-0">
|
|
{onCancel && (
|
|
<Button
|
|
aria-label={t("button.cancel", { ns: "common" })}
|
|
onClick={onCancel}
|
|
>
|
|
{t("button.cancel", { ns: "common" })}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="select"
|
|
onClick={() => onShareTimestamp(Math.floor(selectedTimestamp))}
|
|
>
|
|
{t("recording.shareTimestamp.button", { ns: "components/dialog" })}
|
|
</Button>
|
|
</DialogFooter>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type CustomTimestampSelectorProps = {
|
|
timestamp: number;
|
|
setTimestamp: (timestamp: number) => void;
|
|
label: string;
|
|
};
|
|
|
|
function CustomTimestampSelector({
|
|
timestamp,
|
|
setTimestamp,
|
|
label,
|
|
}: Readonly<CustomTimestampSelectorProps>) {
|
|
const { t } = useTranslation(["common"]);
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
const timeFormat = useTimeFormat(config);
|
|
|
|
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 offsetDeltaSeconds = useMemo(() => {
|
|
if (timezoneOffset === undefined) {
|
|
return 0;
|
|
}
|
|
|
|
// the picker edits a timestamp in the configured UI timezone,
|
|
// but the stored value remains a unix timestamp
|
|
return (timezoneOffset - localTimeOffset) * 60;
|
|
}, [timezoneOffset, localTimeOffset]);
|
|
|
|
const displayTimestamp = useMemo(
|
|
() => timestamp + offsetDeltaSeconds,
|
|
[timestamp, offsetDeltaSeconds],
|
|
);
|
|
|
|
const formattedTimestamp = useFormattedTimestamp(
|
|
displayTimestamp,
|
|
timeFormat == "24hour"
|
|
? t("time.formattedTimestamp.24hour")
|
|
: t("time.formattedTimestamp.12hour"),
|
|
);
|
|
|
|
const clock = useMemo(() => {
|
|
const date = new Date(displayTimestamp * 1000);
|
|
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`;
|
|
}, [displayTimestamp]);
|
|
|
|
const [selectorOpen, setSelectorOpen] = useState(false);
|
|
|
|
const setFromDisplayDate = useCallback(
|
|
(date: Date) => {
|
|
// convert the edited display time back into the underlying Unix timestamp
|
|
setTimestamp(date.getTime() / 1000 - offsetDeltaSeconds);
|
|
},
|
|
[offsetDeltaSeconds, setTimestamp],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex items-center rounded-lg bg-secondary text-secondary-foreground",
|
|
isDesktop ? "gap-2 px-2" : "pl-2",
|
|
)}
|
|
>
|
|
<FaCalendarAlt />
|
|
<div className="flex flex-wrap items-center">
|
|
<Popover
|
|
open={selectorOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setSelectorOpen(false);
|
|
}
|
|
}}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
className={cn("text-primary", !isDesktop && "text-xs")}
|
|
aria-label={label}
|
|
variant={selectorOpen ? "select" : "default"}
|
|
size="sm"
|
|
onClick={() => setSelectorOpen(true)}
|
|
>
|
|
{formattedTimestamp}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="flex flex-col items-center">
|
|
<TimezoneAwareCalendar
|
|
timezone={config?.ui.timezone}
|
|
selectedDay={new Date(displayTimestamp * 1000)}
|
|
onSelect={(day) => {
|
|
if (!day) {
|
|
return;
|
|
}
|
|
|
|
const nextTimestamp = new Date(displayTimestamp * 1000);
|
|
nextTimestamp.setFullYear(
|
|
day.getFullYear(),
|
|
day.getMonth(),
|
|
day.getDate(),
|
|
);
|
|
setFromDisplayDate(nextTimestamp);
|
|
}}
|
|
/>
|
|
<div className="my-3 h-px w-full bg-secondary" />
|
|
<input
|
|
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}
|
|
step={isIOS ? "60" : "1"}
|
|
onChange={(e) => {
|
|
const nextClock = e.target.value;
|
|
const [hour, minute, second] = isIOS
|
|
? [...nextClock.split(":"), "00"]
|
|
: nextClock.split(":");
|
|
const nextTimestamp = new Date(displayTimestamp * 1000);
|
|
nextTimestamp.setHours(
|
|
Number.parseInt(hour),
|
|
Number.parseInt(minute),
|
|
Number.parseInt(second ?? "0"),
|
|
0,
|
|
);
|
|
setFromDisplayDate(nextTimestamp);
|
|
}}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|