diff --git a/web/src/components/Calender.jsx b/web/src/components/Calender.jsx new file mode 100644 index 000000000..4505b8283 --- /dev/null +++ b/web/src/components/Calender.jsx @@ -0,0 +1,297 @@ +import { h } from 'preact'; +import { useEffect, useState, useRef } from 'preact/hooks'; +import ArrowRight from '../icons/ArrowRight'; +import ArrowRightDouble from '../icons/ArrowRightDouble'; + +let oneDay = 60 * 60 * 24 * 1000; +let todayTimestamp = Date.now() - (Date.now() % oneDay) + new Date().getTimezoneOffset() * 1000 * 60; + +const Calender = ({ onChange, calenderRef }) => { + const inputRef = useRef(); + // const inputRefs = useRef(); + let date = new Date(); + let year = date.getFullYear(); + let month = date.getMonth(); + + const [state, setState] = useState({ + getMonthDetails: [], + year, + month, + selectedDay: null, + selectedRange: { before: null, after: null }, + selectedRangeBefore: false, + monthDetails: null, + }); + const [showDatePicker, setShowDatePicker] = useState(false); + + useEffect(() => { + setState((prev) => ({ ...prev, selectedDay: todayTimestamp, monthDetails: getMonthDetails(year, month) })); + }, []); + + const daysMap = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + const monthMap = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + const getDayDetails = (args) => { + let date = args.index - args.firstDay; + let day = args.index % 7; + let prevMonth = args.month - 1; + let prevYear = args.year; + if (prevMonth < 0) { + prevMonth = 11; + prevYear--; + } + let prevMonthNumberOfDays = getNumberOfDays(prevYear, prevMonth); + let _date = (date < 0 ? prevMonthNumberOfDays + date : date % args.numberOfDays) + 1; + let month = date < 0 ? -1 : date >= args.numberOfDays ? 1 : 0; + let timestamp = new Date(args.year, args.month, _date).getTime(); + return { + date: _date, + day, + month, + timestamp, + dayString: daysMap[day], + }; + }; + + const getNumberOfDays = (year, month) => { + return 40 - new Date(year, month, 40).getDate(); + }; + + const getMonthDetails = (year, month) => { + let firstDay = new Date(year, month).getDay(); + let numberOfDays = getNumberOfDays(year, month); + let monthArray = []; + let rows = 6; + let currentDay = null; + let index = 0; + let cols = 7; + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + currentDay = getDayDetails({ + index, + numberOfDays, + firstDay, + year, + month, + }); + monthArray.push(currentDay); + index++; + } + } + return monthArray; + }; + + const isCurrentDay = (day) => { + return day.timestamp === todayTimestamp; + }; + + const isSelectedDay = (day) => { + return day.timestamp === state.selectedDay; + }; + const isSelectedRange = (day) => { + if (!state.selectedRange.after || !state.selectedRange.before) return; + + return day.timestamp < state.selectedRange.before * 1000 && day.timestamp >= state.selectedRange.after * 1000; + }; + + const getDateFromDateString = (dateValue) => { + const dateData = dateValue.split('-').map((d) => parseInt(d, 10)); + if (dateData.length < 3) return null; + + const year = dateData[0]; + const month = dateData[1]; + const date = dateData[2]; + return { year, month, date }; + }; + + const getMonthStr = (month) => monthMap[Math.max(Math.min(11, month), 0)] || 'Month'; + + const getDateStringFromTimestamp = (timestamp) => { + let dateObject = new Date(timestamp); + let month = dateObject.getMonth() + 1; + let date = dateObject.getDate(); + return dateObject.getFullYear() + '-' + (month < 10 ? '0' + month : month) + '-' + (date < 10 ? '0' + date : date); + }; + + const setDate = (dateData) => { + let selectedDay = new Date(dateData.year, dateData.month - 1, dateData.date).getTime(); + setState((prev) => ({ ...prev, selectedDay })); + + if (onChange) { + // onChange(selectedDay); + } + }; + + const updateDateFromInput = () => { + let dateValue = inputRef.current.value; + let dateData = getDateFromDateString(dateValue); + if (dateData !== null) { + setDate(dateData); + setState( + (prev = { + ...prev, + year: dateData.year, + month: dateData.month - 1, + monthDetails: getMonthDetails(dateData.year, dateData.month - 1), + }) + ); + } + }; + + // const setDateToInput = (timestamp) => { + // const dateString = getDateStringFromTimestamp(timestamp); + // inputRef.current.value = dateString; + // }; + + const onDateClick = (day) => { + const range = { + selectedRange: state.selectedRangeBefore + ? { ...state.selectedRange, before: new Date(day.timestamp).setHours(24, 0, 0, 0) / 1000 } + : { ...state.selectedRange, after: day.timestamp / 1000 }, + }; + + setState((prev) => ({ + ...prev, + ...range, + selectedDay: day.timestamp, + selectedRangeBefore: !state.selectedRangeBefore, + })); + + // setDateToInput(day.timestamp); + if (onChange) { + onChange([{ before: range.selectedRange.before, after: range.selectedRange.after }]); + } + }; + + const setYear = (offset) => { + const year = state.year + offset; + const month = state.month; + setState((prev) => { + return { + ...prev, + year, + monthDetails: getMonthDetails(year, month), + }; + }); + }; + + const setMonth = (offset) => { + const year = state.year; + const month = state.month + offset; + if (month === -1) { + month = 11; + year--; + } else if (month === 12) { + month = 0; + year++; + } + setState((prev) => { + return { + ...prev, + year, + month, + monthDetails: getMonthDetails(year, month), + }; + }); + }; + + /** + * Renderers + */ + + const renderCalendar = () => { + let days = + state.monthDetails && + state.monthDetails.map((day, index) => { + return ( +
onDateClick(day)} + className={ + 'h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ' + + (day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : '') + + (isCurrentDay(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : '') + + (isSelectedDay(day) ? 'bg-gray-100 dark:hover:bg-gray-100' : '') + + (isSelectedRange(day) ? ' bg-blue-500 dark:hover:bg-blue-500' : '') + } + key={index} + > +
+ {day.date} +
+
+ ); + }); + + return ( +
+
+ {['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'].map((d, i) => ( +
+ {d} +
+ ))} +
+
{days}
+
+ ); + }; + + return ( +
+
+
+
+
setYear(-1)} + > + +
+
+
+
setMonth(-1)} + > + +
+
+
+
{state.year}
+
{getMonthStr(state.month)}
+
+
+
setMonth(1)} + > + +
+
+
setYear(1)}> +
+ +
+
+
+
{renderCalendar()}
+
+
+ ); +}; + +export default Calender; diff --git a/web/src/components/DatePicker.jsx b/web/src/components/DatePicker.jsx new file mode 100644 index 000000000..54d00bef5 --- /dev/null +++ b/web/src/components/DatePicker.jsx @@ -0,0 +1,192 @@ +import { h } from 'preact'; +import { useCallback, useEffect, useState } from 'preact/hooks'; + +export const DateFilterOptions = [ + { + label: 'All', + value: ['all'], + }, + { + label: 'Today', + value: [ + { + //Before + before: new Date().setHours(24, 0, 0, 0) / 1000, + //After + after: new Date().setHours(0, 0, 0, 0) / 1000, + }, + ], + }, + { + label: 'Yesterday', + value: [ + { + //Before + before: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(24, 0, 0, 0) / 1000, + //After + after: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(0, 0, 0, 0) / 1000, + }, + ], + }, + { + label: 'Last 7 Days', + value: [ + { + //Before + before: new Date().setHours(24, 0, 0, 0) / 1000, + //After + after: new Date(new Date().setDate(new Date().getDate() - 7)).setHours(0, 0, 0, 0) / 1000, + }, + ], + }, + { + label: 'This Month', + value: [ + { + //Before + before: new Date().setHours(24, 0, 0, 0) / 1000, + //After + after: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000, + }, + ], + }, + { + label: 'Last Month', + value: [ + { + //Before + before: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000, + //After + after: new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1).getTime() / 1000, + }, + ], + }, + { + label: 'Custom Range', + value: null, + }, +]; + +export default function DatePicker({ + helpText, + keyboardType = 'date', + inputRef, + label, + leadingIcon: LeadingIcon, + onBlur, + onChangeText, + onFocus, + readonly, + trailingIcon: TrailingIcon, + value: propValue = '', + ...props +}) { + const [isFocused, setFocused] = useState(false); + const [value, setValue] = useState(propValue); + + useEffect(() => { + if (propValue !== value) { + setValue(propValue); + } + // DO NOT include `value` + }, [propValue, setValue]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + setDateToInput(value); + }, []); + + const handleFocus = useCallback( + (event) => { + setFocused(true); + onFocus && onFocus(event); + }, + [onFocus] + ); + + const handleBlur = useCallback( + (event) => { + setFocused(false); + onBlur && onBlur(event); + }, + [onBlur] + ); + + const handleChange = useCallback( + (event) => { + const { value } = event.target; + setValue(value); + onChangeText && onChangeText(value); + }, + [onChangeText, setValue] + ); + + const getDateFromDateString = (dateValue) => { + const dateData = dateValue.split('-').map((d) => parseInt(d, 10)); + if (dateData.length < 3) return null; + + const year = dateData[0]; + const month = dateData[1]; + const date = dateData[2]; + return { year, month, date }; + }; + + const getDateStringFromTimestamp = (timestamp) => { + const dateObject = new Date(timestamp * 1000); + const month = dateObject.getMonth() + 1; + const date = dateObject.getDate(); + return dateObject.getFullYear() + '-' + (month < 10 ? '0' + month : month) + '-' + (date < 10 ? '0' + date : date); + }; + + const setDateClick = (dateData) => { + const selectedDay = new Date(dateData.year, dateData.month - 1, dateData.date).getTime(); + if (props.onchange) { + props.onchange(new Date(selectedDay).getTime() / 1000); + } + }; + + const setDateToInput = (timestamp) => { + const dateString = getDateStringFromTimestamp(timestamp); + // inputRef.current.value = dateString; + }; + const onClick = (e) => { + props.onclick(e); + }; + const labelMoved = isFocused || value !== ''; + + return ( +
+ {props.children} +
+ +
+
+ ); +} diff --git a/web/src/components/RelativeModal.jsx b/web/src/components/RelativeModal.jsx index 2398f643f..7dd1bf76e 100644 --- a/web/src/components/RelativeModal.jsx +++ b/web/src/components/RelativeModal.jsx @@ -79,7 +79,7 @@ export default function RelativeModal({ } // too close to bottom if (top + menuHeight > windowHeight - WINDOW_PADDING + window.scrollY) { - newTop = relativeToY - menuHeight; + newTop = WINDOW_PADDING; } if (top <= WINDOW_PADDING + window.scrollY) { diff --git a/web/src/components/Select.jsx b/web/src/components/Select.jsx index 4b9fac7f5..456b68597 100644 --- a/web/src/components/Select.jsx +++ b/web/src/components/Select.jsx @@ -3,29 +3,56 @@ import ArrowDropdown from '../icons/ArrowDropdown'; import ArrowDropup from '../icons/ArrowDropup'; import Menu, { MenuItem } from './Menu'; import TextField from './TextField'; +import DatePicker from './DatePicker'; +import Calender from './Calender'; import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; -export default function Select({ label, onChange, options: inputOptions = [], selected: propSelected }) { +export default function Select({ + type, + label, + onChange, + paramName, + options: inputOptions = [], + selected: propSelected, +}) { const options = useMemo( () => - typeof inputOptions[0] === 'string' ? inputOptions.map((opt) => ({ value: opt, label: opt })) : inputOptions, + typeof inputOptions[1] === 'string' ? inputOptions.map((opt) => ({ value: opt, label: opt })) : inputOptions, [inputOptions] ); - const [showMenu, setShowMenu] = useState(false); - const [selected, setSelected] = useState( - Math.max( - options.findIndex(({ value }) => value === propSelected), - 0 - ) - ); - const [focused, setFocused] = useState(null); + const [showMenu, setShowMenu] = useState(false); + const [selected, setSelected] = useState(); + + useEffect(() => { + setSelected( + Math.max( + options.findIndex(({ value }) => propSelected.includes(value)), + 0 + ) + ); + }, [options, propSelected]); + + const [focused, setFocused] = useState(null); + const [showDatePicker, setShowDatePicker] = useState(false); + const calenderRef = useRef(null); const ref = useRef(null); const handleSelect = useCallback( (value, label) => { setSelected(options.findIndex((opt) => opt.value === value)); + setShowMenu(false); + + if (!value) return setShowDatePicker(true); onChange && onChange(value, label); + }, + [onChange, options] + ); + const handleDateRange = useCallback( + (range) => { + // setSelected(options.findIndex((opt) => opt.value === value)); + + onChange && onChange(range, 'range'); setShowMenu(false); }, [onChange, options] @@ -85,25 +112,80 @@ export default function Select({ label, onChange, options: inputOptions = [], se // DO NOT include `selected` }, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps - return ( - - - {showMenu ? ( - - {options.map(({ value, label }, i) => ( - - ))} - - ) : null} - - ); + useEffect(() => { + window.addEventListener('click', addBackDrop); + // setDateToInput(state.selectedDay); + return function cleanup() { + window.removeEventListener('click', addBackDrop); + }; + }, [showDatePicker]); + + const findDOMNode = (component) => { + return (component && (component.base || (component.nodeType === 1 && component))) || null; + }; + + const addBackDrop = (e) => { + if (showDatePicker && !findDOMNode(calenderRef.current).contains(e.target)) { + setShowDatePicker(false); + } + }; + + switch (type) { + case 'datepicker': + return ( + + + {showDatePicker && ( + + + + )} + {showMenu ? ( + + {options.map(({ value, label }, i) => ( + + ))} + + ) : null} + + ); + case 'dropdown': + return ( + + + {showMenu ? ( + + {options.map(({ value, label }, i) => ( + + ))} + + ) : null} + + ); + default: + return
; + } } diff --git a/web/src/icons/ArrowLeft.jsx b/web/src/icons/ArrowLeft.jsx new file mode 100644 index 000000000..6ff892695 --- /dev/null +++ b/web/src/icons/ArrowLeft.jsx @@ -0,0 +1,18 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function ArrowLeft({ className = '' }) { + return ( + + + + ); +} + +export default memo(ArrowLeft); diff --git a/web/src/icons/ArrowRight.jsx b/web/src/icons/ArrowRight.jsx new file mode 100644 index 000000000..455548887 --- /dev/null +++ b/web/src/icons/ArrowRight.jsx @@ -0,0 +1,12 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function ArrowRight({ className = '' }) { + return ( + + + + ); +} + +export default memo(ArrowRight); diff --git a/web/src/icons/ArrowRightDouble.jsx b/web/src/icons/ArrowRightDouble.jsx new file mode 100644 index 000000000..7487a4d5c --- /dev/null +++ b/web/src/icons/ArrowRightDouble.jsx @@ -0,0 +1,12 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function ArrowRightDouble({ className = '' }) { + return ( + + + + ); +} + +export default memo(ArrowRightDouble); diff --git a/web/src/routes/Events.jsx b/web/src/routes/Events.jsx index e74bbadc4..86a51d20b 100644 --- a/web/src/routes/Events.jsx +++ b/web/src/routes/Events.jsx @@ -1,4 +1,4 @@ -import { h } from 'preact'; +import { h, Fragment } from 'preact'; import ActivityIndicator from '../components/ActivityIndicator'; import Heading from '../components/Heading'; import Link from '../components/Link'; @@ -9,6 +9,7 @@ import { useIntersectionObserver } from '../hooks'; import { FetchStatus, useApiHost, useConfig, useEvents } from '../api'; import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from '../components/Table'; import { useCallback, useEffect, useMemo, useReducer, useState } from 'preact/hooks'; +import { DateFilterOptions } from '../components/DatePicker'; const API_LIMIT = 25; @@ -49,7 +50,7 @@ const defaultSearchString = (limit) => `include_thumbnails=0&limit=${limit}`; function removeDefaultSearchKeys(searchParams) { searchParams.delete('limit'); searchParams.delete('include_thumbnails'); - searchParams.delete('before'); + // searchParams.delete('before'); } export default function Events({ path: pathname, limit = API_LIMIT } = {}) { @@ -101,6 +102,7 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) { ); const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]); + return (
Events @@ -249,23 +251,34 @@ function Filters({ onChange, searchParams }) { }, [data]); return ( -
- - - -
+ +
+ +
+
); } -function Filter({ onChange, searchParams, paramName, options }) { +function Filter({ onChange, searchParams, paramName, options, type, ...rest }) { const handleSelect = useCallback( (key) => { const newParams = new URLSearchParams(searchParams.toString()); - if (key !== 'all') { - newParams.set(paramName, key); - } else { - newParams.delete(paramName); - } + key.map((queryArray) => { + if (queryArray[paramName] !== 'all') { + for (let query in queryArray) { + newParams.set(query, queryArray[query]); + } + } else { + paramName.map((p) => newParams.delete(p)); + } + }); onChange(newParams); }, @@ -273,13 +286,16 @@ function Filter({ onChange, searchParams, paramName, options }) { ); const selectOptions = useMemo(() => ['all', ...options], [options]); + const selected = useMemo(() => paramName.map((p) => searchParams.get(p) || 'all')); return (