mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-12 13:47:14 +02:00
* swr for infinite loading * search detail language change * drawer padding * spacing * center calendar * padding * catch error * use limit const
446 lines
14 KiB
TypeScript
446 lines
14 KiB
TypeScript
import { useState, useEffect, useRef } from "react";
|
|
import { Button } from "./button";
|
|
import { Calendar } from "./calendar";
|
|
import { Label } from "./label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "./select";
|
|
import { Switch } from "./switch";
|
|
import { cn } from "@/lib/utils";
|
|
import { LuCheck } from "react-icons/lu";
|
|
|
|
export interface DateRangePickerProps {
|
|
/** Click handler for applying the updates from DateRangePicker. */
|
|
onUpdate?: (values: { range: DateRange; rangeCompare?: DateRange }) => void;
|
|
onReset?: () => void;
|
|
/** Initial value for start date */
|
|
initialDateFrom?: Date | string;
|
|
/** Initial value for end date */
|
|
initialDateTo?: Date | string;
|
|
/** Initial value for start date for compare */
|
|
initialCompareFrom?: Date | string;
|
|
/** Initial value for end date for compare */
|
|
initialCompareTo?: Date | string;
|
|
/** Alignment of popover */
|
|
align?: "start" | "center" | "end";
|
|
/** Option for locale */
|
|
locale?: string;
|
|
/** Option for showing compare feature */
|
|
showCompare?: boolean;
|
|
}
|
|
|
|
const getDateAdjustedForTimezone = (dateInput: Date | string): Date => {
|
|
if (typeof dateInput === "string") {
|
|
// Split the date string to get year, month, and day parts
|
|
const parts = dateInput.split("-").map((part) => parseInt(part, 10));
|
|
// Create a new Date object using the local timezone
|
|
// Note: Month is 0-indexed, so subtract 1 from the month part
|
|
const date = new Date(parts[0], parts[1] - 1, parts[2]);
|
|
return date;
|
|
} else {
|
|
// If dateInput is already a Date object, return it directly
|
|
return dateInput;
|
|
}
|
|
};
|
|
|
|
interface DateRange {
|
|
from: Date;
|
|
to: Date | undefined;
|
|
}
|
|
|
|
interface Preset {
|
|
name: string;
|
|
label: string;
|
|
}
|
|
|
|
// Define presets
|
|
const PRESETS: Preset[] = [
|
|
{ name: "today", label: "Today" },
|
|
{ name: "yesterday", label: "Yesterday" },
|
|
{ name: "last7", label: "Last 7 days" },
|
|
{ name: "last14", label: "Last 14 days" },
|
|
{ name: "last30", label: "Last 30 days" },
|
|
{ name: "thisWeek", label: "This Week" },
|
|
{ name: "lastWeek", label: "Last Week" },
|
|
{ name: "thisMonth", label: "This Month" },
|
|
{ name: "lastMonth", label: "Last Month" },
|
|
];
|
|
|
|
/** The DateRangePicker component allows a user to select a range of dates */
|
|
export function DateRangePicker({
|
|
initialDateFrom = new Date(new Date().setHours(0, 0, 0, 0)),
|
|
initialDateTo,
|
|
initialCompareFrom,
|
|
initialCompareTo,
|
|
onUpdate,
|
|
onReset,
|
|
showCompare = true,
|
|
}: DateRangePickerProps) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
const [range, setRange] = useState<DateRange>({
|
|
from: getDateAdjustedForTimezone(initialDateFrom),
|
|
to: initialDateTo
|
|
? getDateAdjustedForTimezone(initialDateTo)
|
|
: getDateAdjustedForTimezone(initialDateFrom),
|
|
});
|
|
const [rangeCompare, setRangeCompare] = useState<DateRange | undefined>(
|
|
initialCompareFrom
|
|
? {
|
|
from: new Date(new Date(initialCompareFrom).setHours(0, 0, 0, 0)),
|
|
to: initialCompareTo
|
|
? new Date(new Date(initialCompareTo).setHours(0, 0, 0, 0))
|
|
: new Date(new Date(initialCompareFrom).setHours(0, 0, 0, 0)),
|
|
}
|
|
: undefined,
|
|
);
|
|
|
|
// Refs to store the values of range and rangeCompare when the date picker is opened
|
|
const openedRangeRef = useRef<DateRange | undefined>();
|
|
const openedRangeCompareRef = useRef<DateRange | undefined>();
|
|
|
|
const [selectedPreset, setSelectedPreset] = useState<string | undefined>(
|
|
undefined,
|
|
);
|
|
|
|
const [isSmallScreen, setIsSmallScreen] = useState(
|
|
typeof window !== "undefined" ? window.innerWidth < 960 : false,
|
|
);
|
|
|
|
useEffect(() => {
|
|
const handleResize = (): void => {
|
|
setIsSmallScreen(window.innerWidth < 960);
|
|
};
|
|
|
|
window.addEventListener("resize", handleResize);
|
|
|
|
// Clean up event listener on unmount
|
|
return () => {
|
|
window.removeEventListener("resize", handleResize);
|
|
};
|
|
}, []);
|
|
|
|
const getPresetRange = (presetName: string): DateRange => {
|
|
const preset = PRESETS.find(({ name }) => name === presetName);
|
|
if (!preset) throw new Error(`Unknown date range preset: ${presetName}`);
|
|
const from = new Date();
|
|
const to = new Date();
|
|
const first = from.getDate() - from.getDay();
|
|
|
|
switch (preset.name) {
|
|
case "today":
|
|
from.setHours(0, 0, 0, 0);
|
|
to.setHours(23, 59, 59, 999);
|
|
break;
|
|
case "yesterday":
|
|
from.setDate(from.getDate() - 1);
|
|
from.setHours(0, 0, 0, 0);
|
|
to.setDate(to.getDate() - 1);
|
|
to.setHours(23, 59, 59, 999);
|
|
break;
|
|
case "last7":
|
|
from.setDate(from.getDate() - 6);
|
|
from.setHours(0, 0, 0, 0);
|
|
to.setHours(23, 59, 59, 999);
|
|
break;
|
|
case "last14":
|
|
from.setDate(from.getDate() - 13);
|
|
from.setHours(0, 0, 0, 0);
|
|
to.setHours(23, 59, 59, 999);
|
|
break;
|
|
case "last30":
|
|
from.setDate(from.getDate() - 29);
|
|
from.setHours(0, 0, 0, 0);
|
|
to.setHours(23, 59, 59, 999);
|
|
break;
|
|
case "thisWeek":
|
|
from.setDate(first);
|
|
from.setHours(0, 0, 0, 0);
|
|
to.setHours(23, 59, 59, 999);
|
|
break;
|
|
case "lastWeek":
|
|
from.setDate(from.getDate() - 7 - from.getDay());
|
|
to.setDate(to.getDate() - to.getDay() - 1);
|
|
from.setHours(0, 0, 0, 0);
|
|
to.setHours(23, 59, 59, 999);
|
|
break;
|
|
case "thisMonth":
|
|
from.setDate(1);
|
|
from.setHours(0, 0, 0, 0);
|
|
to.setHours(23, 59, 59, 999);
|
|
break;
|
|
case "lastMonth":
|
|
from.setMonth(from.getMonth() - 1);
|
|
from.setDate(1);
|
|
from.setHours(0, 0, 0, 0);
|
|
to.setDate(0);
|
|
to.setHours(23, 59, 59, 999);
|
|
break;
|
|
}
|
|
|
|
return { from, to };
|
|
};
|
|
|
|
const setPreset = (preset: string): void => {
|
|
const range = getPresetRange(preset);
|
|
setRange(range);
|
|
if (rangeCompare) {
|
|
const rangeCompare = {
|
|
from: new Date(
|
|
range.from.getFullYear() - 1,
|
|
range.from.getMonth(),
|
|
range.from.getDate(),
|
|
),
|
|
to: range.to
|
|
? new Date(
|
|
range.to.getFullYear() - 1,
|
|
range.to.getMonth(),
|
|
range.to.getDate(),
|
|
)
|
|
: undefined,
|
|
};
|
|
setRangeCompare(rangeCompare);
|
|
}
|
|
};
|
|
|
|
const checkPreset = (): void => {
|
|
for (const preset of PRESETS) {
|
|
const presetRange = getPresetRange(preset.name);
|
|
|
|
const normalizedRangeFrom = new Date(range.from);
|
|
normalizedRangeFrom.setHours(0, 0, 0, 0);
|
|
const normalizedPresetFrom = new Date(
|
|
presetRange.from.setHours(0, 0, 0, 0),
|
|
);
|
|
|
|
const normalizedRangeTo = new Date(range.to ?? 0);
|
|
normalizedRangeTo.setHours(0, 0, 0, 0);
|
|
const normalizedPresetTo = new Date(
|
|
presetRange.to?.setHours(0, 0, 0, 0) ?? 0,
|
|
);
|
|
|
|
if (
|
|
normalizedRangeFrom.getTime() === normalizedPresetFrom.getTime() &&
|
|
normalizedRangeTo.getTime() === normalizedPresetTo.getTime()
|
|
) {
|
|
setSelectedPreset(preset.name);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setSelectedPreset(undefined);
|
|
};
|
|
|
|
const resetValues = (): void => {
|
|
setRange({
|
|
from:
|
|
typeof initialDateFrom === "string"
|
|
? getDateAdjustedForTimezone(initialDateFrom)
|
|
: initialDateFrom,
|
|
to: initialDateTo
|
|
? typeof initialDateTo === "string"
|
|
? getDateAdjustedForTimezone(initialDateTo)
|
|
: initialDateTo
|
|
: typeof initialDateFrom === "string"
|
|
? getDateAdjustedForTimezone(initialDateFrom)
|
|
: initialDateFrom,
|
|
});
|
|
setRangeCompare(
|
|
initialCompareFrom
|
|
? {
|
|
from:
|
|
typeof initialCompareFrom === "string"
|
|
? getDateAdjustedForTimezone(initialCompareFrom)
|
|
: initialCompareFrom,
|
|
to: initialCompareTo
|
|
? typeof initialCompareTo === "string"
|
|
? getDateAdjustedForTimezone(initialCompareTo)
|
|
: initialCompareTo
|
|
: typeof initialCompareFrom === "string"
|
|
? getDateAdjustedForTimezone(initialCompareFrom)
|
|
: initialCompareFrom,
|
|
}
|
|
: undefined,
|
|
);
|
|
};
|
|
|
|
useEffect(() => {
|
|
checkPreset();
|
|
}, [range]);
|
|
|
|
const PresetButton = ({
|
|
preset,
|
|
label,
|
|
isSelected,
|
|
}: {
|
|
preset: string;
|
|
label: string;
|
|
isSelected: boolean;
|
|
}): JSX.Element => (
|
|
<Button
|
|
className={cn(isSelected && "pointer-events-none text-primary")}
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setPreset(preset);
|
|
}}
|
|
>
|
|
<>
|
|
<span className={cn("pr-2 opacity-0", isSelected && "opacity-70")}>
|
|
<LuCheck width={18} height={18} />
|
|
</span>
|
|
{label}
|
|
</>
|
|
</Button>
|
|
);
|
|
|
|
// Helper function to check if two date ranges are equal
|
|
const areRangesEqual = (a?: DateRange, b?: DateRange): boolean => {
|
|
if (!a || !b) return a === b; // If either is undefined, return true if both are undefined
|
|
return (
|
|
a.from.getTime() === b.from.getTime() &&
|
|
(!a.to || !b.to || a.to.getTime() === b.to.getTime())
|
|
);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
openedRangeRef.current = range;
|
|
openedRangeCompareRef.current = rangeCompare;
|
|
}
|
|
}, [isOpen]);
|
|
|
|
return (
|
|
<div className="w-full">
|
|
<div className="flex flex-row items-start justify-center py-2">
|
|
<div className="flex">
|
|
<div className="flex flex-col">
|
|
<div className="flex flex-col items-center justify-end gap-2 px-3 pb-4 lg:flex-row lg:items-start lg:pb-0">
|
|
{showCompare && (
|
|
<div className="flex items-center space-x-2 py-1 pr-4">
|
|
<Switch
|
|
defaultChecked={Boolean(rangeCompare)}
|
|
onCheckedChange={(checked: boolean) => {
|
|
if (checked) {
|
|
if (!range.to) {
|
|
setRange({
|
|
from: range.from,
|
|
to: range.from,
|
|
});
|
|
}
|
|
setRangeCompare({
|
|
from: new Date(
|
|
range.from.getFullYear(),
|
|
range.from.getMonth(),
|
|
range.from.getDate() - 365,
|
|
),
|
|
to: range.to
|
|
? new Date(
|
|
range.to.getFullYear() - 1,
|
|
range.to.getMonth(),
|
|
range.to.getDate(),
|
|
)
|
|
: new Date(
|
|
range.from.getFullYear() - 1,
|
|
range.from.getMonth(),
|
|
range.from.getDate(),
|
|
),
|
|
});
|
|
} else {
|
|
setRangeCompare(undefined);
|
|
}
|
|
}}
|
|
id="compare-mode"
|
|
/>
|
|
<Label htmlFor="compare-mode">Compare</Label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isSmallScreen && (
|
|
<Select
|
|
defaultValue={selectedPreset}
|
|
onValueChange={(value) => {
|
|
setPreset(value);
|
|
}}
|
|
>
|
|
<SelectTrigger className="mx-auto mb-2 w-[180px]">
|
|
<SelectValue placeholder="Select..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{PRESETS.map((preset) => (
|
|
<SelectItem key={preset.name} value={preset.name}>
|
|
{preset.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
<div>
|
|
<Calendar
|
|
mode="range"
|
|
onSelect={(value: { from?: Date; to?: Date } | undefined) => {
|
|
if (value?.from != null) {
|
|
setRange({ from: value.from, to: value?.to });
|
|
}
|
|
}}
|
|
selected={range}
|
|
numberOfMonths={isSmallScreen ? 1 : 2}
|
|
defaultMonth={
|
|
new Date(
|
|
new Date().setMonth(
|
|
new Date().getMonth() - (isSmallScreen ? 0 : 1),
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{!isSmallScreen && (
|
|
<div className="flex flex-col items-end gap-1 pb-6 pl-6 pr-2">
|
|
<div className="flex w-full flex-col items-end gap-1 pb-6 pl-6 pr-2">
|
|
{PRESETS.map((preset) => (
|
|
<PresetButton
|
|
key={preset.name}
|
|
preset={preset.name}
|
|
label={preset.label}
|
|
isSelected={selectedPreset === preset.name}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex justify-center gap-2 py-2 pr-4">
|
|
<Button
|
|
onClick={() => {
|
|
setIsOpen(false);
|
|
resetValues();
|
|
onReset?.();
|
|
}}
|
|
variant="ghost"
|
|
>
|
|
Reset
|
|
</Button>
|
|
<Button
|
|
variant="select"
|
|
onClick={() => {
|
|
setIsOpen(false);
|
|
if (
|
|
!areRangesEqual(range, openedRangeRef.current) ||
|
|
!areRangesEqual(rangeCompare, openedRangeCompareRef.current)
|
|
) {
|
|
onUpdate?.({ range, rangeCompare });
|
|
}
|
|
}}
|
|
>
|
|
Apply
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|