mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
[Feature] Add timepicker to calendar (#5183)
* added timepicker as children to calendar * new timepicker component * Add timepicker * new timePicker component * timepicker as calendar child * hover:border and rounded * adjusted width * complete rework * more code comments * memorization * preselect hover, transition * numberOfDaysSelected has minimum of 1 * prefill hours when component mounts * persist hours when component mount * accommodate for the new timePicker * add reset state * scroll into view * reuse before, after * fix LastDayInRange when a time is selected * do not add hours if before is zero * use hours instead of days * useeffect to reset hour. check timerange before scroll * scroll to last element, not first
This commit is contained in:
parent
0f1cf67b91
commit
b359ff1b8e
@ -5,7 +5,7 @@ import ArrowRightDouble from '../icons/ArrowRightDouble';
|
|||||||
|
|
||||||
const todayTimestamp = new Date().setHours(0, 0, 0, 0).valueOf();
|
const todayTimestamp = new Date().setHours(0, 0, 0, 0).valueOf();
|
||||||
|
|
||||||
const Calendar = ({ onChange, calendarRef, close, dateRange }) => {
|
const Calendar = ({ onChange, calendarRef, close, dateRange, children }) => {
|
||||||
const keyRef = useRef([]);
|
const keyRef = useRef([]);
|
||||||
|
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
@ -102,8 +102,9 @@ const Calendar = ({ onChange, calendarRef, close, dateRange }) => {
|
|||||||
...prev,
|
...prev,
|
||||||
selectedDay: todayTimestamp,
|
selectedDay: todayTimestamp,
|
||||||
monthDetails: getMonthDetails(year, month),
|
monthDetails: getMonthDetails(year, month),
|
||||||
|
timeRange: { ...dateRange },
|
||||||
}));
|
}));
|
||||||
}, [year, month, getMonthDetails]);
|
}, [year, month, getMonthDetails, dateRange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// add refs for keyboard navigation
|
// add refs for keyboard navigation
|
||||||
@ -121,7 +122,7 @@ const Calendar = ({ onChange, calendarRef, close, dateRange }) => {
|
|||||||
(day) => {
|
(day) => {
|
||||||
if (!state.timeRange.after || !state.timeRange.before) return;
|
if (!state.timeRange.after || !state.timeRange.before) return;
|
||||||
|
|
||||||
return day.timestamp < state.timeRange.before && day.timestamp >= state.timeRange.after;
|
return day.timestamp < state.timeRange.before && day.timestamp >= new Date(state.timeRange.after).setHours(0);
|
||||||
},
|
},
|
||||||
[state.timeRange]
|
[state.timeRange]
|
||||||
);
|
);
|
||||||
@ -129,14 +130,21 @@ const Calendar = ({ onChange, calendarRef, close, dateRange }) => {
|
|||||||
const isFirstDayInRange = useCallback(
|
const isFirstDayInRange = useCallback(
|
||||||
(day) => {
|
(day) => {
|
||||||
if (isCurrentDay(day)) return;
|
if (isCurrentDay(day)) return;
|
||||||
return state.timeRange.after === day.timestamp;
|
return new Date(state.timeRange.after).setHours(0) === day.timestamp;
|
||||||
},
|
},
|
||||||
[state.timeRange.after]
|
[state.timeRange.after]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLastDayInRange = useCallback(
|
const isLastDayInRange = useCallback(
|
||||||
(day) => {
|
(day) => {
|
||||||
return state.timeRange.before === new Date(day.timestamp).setHours(24, 0, 0, 0);
|
// if the hour is not above 0, we will use 24 hour.
|
||||||
|
const beforeHour = new Date(state.timeRange.before).getHours() || 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When user selects a day in the calendar, the before will be 00:00.
|
||||||
|
* When user selects a time in timepicker, the day.timestamp hour must be changed to match the selected end () hour.
|
||||||
|
*/
|
||||||
|
return state.timeRange.before === new Date(day.timestamp).setHours(beforeHour);
|
||||||
},
|
},
|
||||||
[state.timeRange.before]
|
[state.timeRange.before]
|
||||||
);
|
);
|
||||||
@ -256,12 +264,12 @@ const Calendar = ({ onChange, calendarRef, close, dateRange }) => {
|
|||||||
onkeydown={(e) => handleKeydown(e, day, idx)}
|
onkeydown={(e) => handleKeydown(e, day, idx)}
|
||||||
ref={(ref) => (keyRef.current[idx] = ref)}
|
ref={(ref) => (keyRef.current[idx] = ref)}
|
||||||
tabIndex={day.month === 0 ? day.date : null}
|
tabIndex={day.month === 0 ? day.date : null}
|
||||||
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
|
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer hover:border hover:rounded-md border-gray-600 ${
|
||||||
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
|
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
|
||||||
}
|
}
|
||||||
${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''}
|
${isFirstDayInRange(day) ? ' rounded-l-xl hover:rounded-l-xl' : ''}
|
||||||
${isSelectedRange(day) ? ' bg-blue-600 dark:hover:bg-blue-600' : ''}
|
${isSelectedRange(day) ? ' bg-blue-600 hover:rounded-none' : ''}
|
||||||
${isLastDayInRange(day) ? ' rounded-r-xl ' : ''}
|
${isLastDayInRange(day) ? ' rounded-r-xl hover:rounded-r-xl' : ''}
|
||||||
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
|
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
|
||||||
key={idx}
|
key={idx}
|
||||||
>
|
>
|
||||||
@ -287,8 +295,8 @@ const Calendar = ({ onChange, calendarRef, close, dateRange }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="select-none w-96 flex flex-shrink" ref={calendarRef}>
|
<div className="select-none w-11/12 flex" ref={calendarRef}>
|
||||||
<div className="py-4 px-6">
|
<div className="px-6">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-1/6 relative flex justify-around">
|
<div className="w-1/6 relative flex justify-around">
|
||||||
<div
|
<div
|
||||||
@ -329,6 +337,7 @@ const Calendar = ({ onChange, calendarRef, close, dateRange }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3">{renderCalendar()}</div>
|
<div className="mt-3">{renderCalendar()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
239
web/src/components/TimePicker.jsx
Normal file
239
web/src/components/TimePicker.jsx
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
|
import { ArrowDropdown } from '../icons/ArrowDropdown';
|
||||||
|
import { ArrowDropup } from '../icons/ArrowDropup';
|
||||||
|
|
||||||
|
const TimePicker = ({ dateRange, onChange }) => {
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [timeRange, setTimeRange] = useState(new Set());
|
||||||
|
const [hoverIdx, setHoverIdx] = useState(null);
|
||||||
|
const [reset, setReset] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes two variables before and after with date objects,
|
||||||
|
* If they are not null, it creates a new Date object with the value of the property and if not,
|
||||||
|
* it creates a new Date object with the current hours to 0 and 24 respectively.
|
||||||
|
*/
|
||||||
|
const before = useMemo(() => {
|
||||||
|
return dateRange.before ? new Date(dateRange.before) : new Date(new Date().setHours(24, 0, 0, 0));
|
||||||
|
}, [dateRange]);
|
||||||
|
|
||||||
|
const after = useMemo(() => {
|
||||||
|
return dateRange.after ? new Date(dateRange.after) : new Date(new Date().setHours(0, 0, 0, 0));
|
||||||
|
}, [dateRange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
/**
|
||||||
|
* This will reset hours when user selects another date in the calendar.
|
||||||
|
*/
|
||||||
|
if (before.getHours() === 0 && after.getHours() === 0 && timeRange.size > 1) return setTimeRange(new Set());
|
||||||
|
}, [after, before, timeRange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reset || !after) return;
|
||||||
|
/**
|
||||||
|
* calculates the number of hours between two dates, by finding the difference in days,
|
||||||
|
* converting it to hours and adding the hours from the before date.
|
||||||
|
*/
|
||||||
|
const days = Math.max(before.getDate() - after.getDate());
|
||||||
|
const hourOffset = days * 24;
|
||||||
|
const beforeOffset = before.getHours() ? hourOffset + before.getHours() : 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills the timeRange by iterating over the hours between 'after' and 'before' during component mount, to keep the selected hours persistent.
|
||||||
|
*/
|
||||||
|
for (let hour = after.getHours(); hour < beforeOffset; hour++) {
|
||||||
|
setTimeRange((timeRange) => timeRange.add(hour));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* find an element by the id timeIndex- concatenated with the minimum value from timeRange array,
|
||||||
|
* and if that element is present, it will scroll into view if needed
|
||||||
|
*/
|
||||||
|
if (timeRange.size > 1) {
|
||||||
|
const element = document.getElementById(`timeIndex-${Math.max(...timeRange)}`);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoViewIfNeeded(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [after, before, timeRange, reset]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* numberOfDaysSelected is a set that holds the number of days selected in the dateRange.
|
||||||
|
* The loop iterates through the days starting from the after date's day to the before date's day.
|
||||||
|
* If the before date's hour is 0, it skips it.
|
||||||
|
*/
|
||||||
|
const numberOfDaysSelected = useMemo(() => {
|
||||||
|
return new Set([...Array(Math.max(1, before.getDate() - after.getDate() + 1))].map((_, i) => after.getDate() + i));
|
||||||
|
}, [before, after]);
|
||||||
|
|
||||||
|
if (before.getHours() === 0) numberOfDaysSelected.delete(before.getDate());
|
||||||
|
|
||||||
|
// Create repeating array with the number of hours for each day selected ...23,24,0,1,2...
|
||||||
|
const hoursInDays = useMemo(() => {
|
||||||
|
return Array.from({ length: numberOfDaysSelected.size * 24 }, (_, i) => i % 24);
|
||||||
|
}, [numberOfDaysSelected]);
|
||||||
|
|
||||||
|
// function for handling the selected time from the provided list
|
||||||
|
const handleTime = useCallback(
|
||||||
|
(hour) => {
|
||||||
|
if (isNaN(hour)) return;
|
||||||
|
|
||||||
|
const _timeRange = new Set([...timeRange]);
|
||||||
|
_timeRange.add(hour);
|
||||||
|
|
||||||
|
// reset error messages
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the variable "hour" exists in the "timeRange" set.
|
||||||
|
* If it does, reset the timepicker
|
||||||
|
*/
|
||||||
|
if (timeRange.has(hour)) {
|
||||||
|
setTimeRange(new Set());
|
||||||
|
setReset(true);
|
||||||
|
const resetBefore = before.setDate(after.getDate() + numberOfDaysSelected.size - 1);
|
||||||
|
return onChange({
|
||||||
|
after: after.setHours(0, 0, 0, 0) / 1000,
|
||||||
|
before: new Date(resetBefore).setHours(24, 0, 0, 0) / 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//update after
|
||||||
|
if (_timeRange.size === 1) {
|
||||||
|
// check if the first selected value is within first day
|
||||||
|
const firstSelectedHour = Math.ceil(Math.max(..._timeRange));
|
||||||
|
if (firstSelectedHour > 23) {
|
||||||
|
return setError('Select a time on the initial day!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculate days offset
|
||||||
|
const dayOffsetAfter = new Date(after).setHours(Math.min(..._timeRange));
|
||||||
|
|
||||||
|
let dayOffsetBefore = before;
|
||||||
|
if (numberOfDaysSelected.size === 1) {
|
||||||
|
dayOffsetBefore = new Date(after).setHours(Math.min(..._timeRange) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
after: dayOffsetAfter / 1000,
|
||||||
|
before: dayOffsetBefore / 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//update before
|
||||||
|
if (_timeRange.size > 1) {
|
||||||
|
let selectedDay = Math.ceil(Math.max(..._timeRange) / 24);
|
||||||
|
|
||||||
|
// if user selects time 00:00 for the next day, add one day
|
||||||
|
if (hour === 24 && selectedDay === numberOfDaysSelected.size - 1) {
|
||||||
|
selectedDay += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if end time is on the last day
|
||||||
|
if (selectedDay !== numberOfDaysSelected.size) {
|
||||||
|
return setError('Ending must occur on final day!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if end time is later than start time
|
||||||
|
const startHour = Math.min(..._timeRange);
|
||||||
|
if (hour <= startHour) {
|
||||||
|
return setError('Ending hour must be greater than start time!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all hours between start and end times to the set
|
||||||
|
for (let x = startHour; x <= hour; x++) {
|
||||||
|
_timeRange.add(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculate days offset
|
||||||
|
const dayOffsetBefore = new Date(dateRange.after);
|
||||||
|
onChange({
|
||||||
|
after: dateRange.after / 1000,
|
||||||
|
// we add one hour to get full 60min of last selected hour
|
||||||
|
before: dayOffsetBefore.setHours(Math.max(..._timeRange) + 1) / 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < _timeRange.size; i++) {
|
||||||
|
setTimeRange((timeRange) => timeRange.add(Array.from(_timeRange)[i]));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[after, before, timeRange, dateRange.after, numberOfDaysSelected.size, onChange]
|
||||||
|
);
|
||||||
|
const isSelected = useCallback(
|
||||||
|
(idx) => {
|
||||||
|
return !!timeRange.has(idx);
|
||||||
|
},
|
||||||
|
[timeRange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isHovered = useCallback(
|
||||||
|
(idx) => {
|
||||||
|
return timeRange.size === 1 && idx > Math.max(...timeRange) && idx <= hoverIdx;
|
||||||
|
},
|
||||||
|
[timeRange, hoverIdx]
|
||||||
|
);
|
||||||
|
|
||||||
|
// background colors for each day
|
||||||
|
const isSelectedCss = 'bg-blue-600 transition duration-300 ease-in-out hover:rounded-none';
|
||||||
|
function randomGrayTone(shade) {
|
||||||
|
const grayTones = [
|
||||||
|
'bg-[#212529]/50',
|
||||||
|
'bg-[#343a40]/50',
|
||||||
|
'bg-[#495057]/50',
|
||||||
|
'bg-[#666463]/50',
|
||||||
|
'bg-[#817D7C]/50',
|
||||||
|
'bg-[#73706F]/50',
|
||||||
|
'bg-[#585655]/50',
|
||||||
|
'bg-[#4F4D4D]/50',
|
||||||
|
'bg-[#454343]/50',
|
||||||
|
'bg-[#363434]/50',
|
||||||
|
];
|
||||||
|
return grayTones[shade % grayTones.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error ? <span className="text-red-400 text-center text-xs absolute top-1 right-0 pr-2">{error}</span> : null}
|
||||||
|
<div className="mt-2 pr-3 hidden xs:block" aria-label="Calendar timepicker, select a time range">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<ArrowDropup className="w-10 text-center" />
|
||||||
|
</div>
|
||||||
|
<div className="w-20 px-1">
|
||||||
|
<div
|
||||||
|
className="border border-gray-400/50 cursor-pointer hide-scroll shadow-md rounded-md"
|
||||||
|
style={{ maxHeight: '17rem', overflowY: 'scroll' }}
|
||||||
|
>
|
||||||
|
{hoursInDays.map((_, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
id={`timeIndex-${idx}`}
|
||||||
|
className={`${isSelected(idx) ? isSelectedCss : ''}
|
||||||
|
${isHovered(idx) ? 'opacity-30 bg-slate-900 transition duration-150 ease-in-out' : ''}
|
||||||
|
${Math.min(...timeRange) === idx ? 'rounded-t-lg' : ''}
|
||||||
|
${timeRange.size > 1 && Math.max(...timeRange) === idx ? 'rounded-b-lg' : ''}`}
|
||||||
|
onMouseEnter={() => setHoverIdx(idx)}
|
||||||
|
onMouseLeave={() => setHoverIdx(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
text-gray-300 w-full font-light border border-transparent hover:border hover:rounded-md hover:border-gray-600 text-center text-sm
|
||||||
|
${randomGrayTone([Math.floor(idx / 24)])}`}
|
||||||
|
onClick={() => handleTime(idx)}
|
||||||
|
>
|
||||||
|
<span aria-label={`${idx}:00`}>{hoursInDays[idx]}:00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<ArrowDropdown className="w-10 text-center" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimePicker;
|
@ -27,6 +27,7 @@ import Dialog from '../components/Dialog';
|
|||||||
import MultiSelect from '../components/MultiSelect';
|
import MultiSelect from '../components/MultiSelect';
|
||||||
import { formatUnixTimestampToDateTime, getDurationFromTimestamps } from '../utils/dateUtil';
|
import { formatUnixTimestampToDateTime, getDurationFromTimestamps } from '../utils/dateUtil';
|
||||||
import TimeAgo from '../components/TimeAgo';
|
import TimeAgo from '../components/TimeAgo';
|
||||||
|
import Timepicker from '../components/TimePicker';
|
||||||
import TimelineSummary from '../components/TimelineSummary';
|
import TimelineSummary from '../components/TimelineSummary';
|
||||||
|
|
||||||
const API_LIMIT = 25;
|
const API_LIMIT = 25;
|
||||||
@ -437,7 +438,9 @@ export default function Events({ path, ...props }) {
|
|||||||
/>
|
/>
|
||||||
</Menu>
|
</Menu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state.showCalendar && (
|
{state.showCalendar && (
|
||||||
|
<span>
|
||||||
<Menu
|
<Menu
|
||||||
className="rounded-t-none"
|
className="rounded-t-none"
|
||||||
onDismiss={() => setState({ ...state, showCalendar: false })}
|
onDismiss={() => setState({ ...state, showCalendar: false })}
|
||||||
@ -447,8 +450,14 @@ export default function Events({ path, ...props }) {
|
|||||||
onChange={handleSelectDateRange}
|
onChange={handleSelectDateRange}
|
||||||
dateRange={{ before: searchParams.before * 1000 || null, after: searchParams.after * 1000 || null }}
|
dateRange={{ before: searchParams.before * 1000 || null, after: searchParams.after * 1000 || null }}
|
||||||
close={() => setState({ ...state, showCalendar: false })}
|
close={() => setState({ ...state, showCalendar: false })}
|
||||||
|
>
|
||||||
|
<Timepicker
|
||||||
|
dateRange={{ before: searchParams.before * 1000 || null, after: searchParams.after * 1000 || null }}
|
||||||
|
onChange={handleSelectDateRange}
|
||||||
/>
|
/>
|
||||||
|
</Calendar>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{state.showPlusSubmit && (
|
{state.showPlusSubmit && (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
|
Loading…
Reference in New Issue
Block a user