From 00ff76a0b982bffc894bb817c23acfcd1621979b Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Fri, 3 Sep 2021 14:11:23 +0200 Subject: [PATCH] Events performance (#1645) * rearrange event route and splitted into several components * useIntersectionObserver * re-arrange * searchstring improvement * added xs tailwind breakpoint * useOuterClick hook * cleaned up * removed some video controls for mobile devices * lint * moved hooks to global folder * moved buttons for small devices * added button groups Co-authored-by: Bernt Christian Egeland --- web/src/components/AppBar.jsx | 3 +- web/src/components/Table.jsx | 11 +- web/src/components/VideoPlayer.jsx | 2 +- web/src/hooks/useClickOutside.jsx | 22 ++ web/src/hooks/useSearchString.jsx | 25 ++ web/src/index.css | 17 +- web/src/routes/Event.jsx | 119 ++++++- web/src/routes/Events.jsx | 326 ------------------ web/src/routes/Events/components/filter.jsx | 31 ++ .../routes/Events/components/filterable.jsx | 32 ++ web/src/routes/Events/components/filters.jsx | 39 +++ web/src/routes/Events/components/index.jsx | 3 + .../routes/Events/components/tableHead.jsx | 18 + web/src/routes/Events/components/tableRow.jsx | 119 +++++++ web/src/routes/Events/index.jsx | 107 ++++++ web/src/routes/Events/reducer.jsx | 47 +++ web/src/routes/index.js | 2 +- web/tailwind.config.js | 1 + 18 files changed, 572 insertions(+), 352 deletions(-) create mode 100644 web/src/hooks/useClickOutside.jsx create mode 100644 web/src/hooks/useSearchString.jsx delete mode 100644 web/src/routes/Events.jsx create mode 100644 web/src/routes/Events/components/filter.jsx create mode 100644 web/src/routes/Events/components/filterable.jsx create mode 100644 web/src/routes/Events/components/filters.jsx create mode 100644 web/src/routes/Events/components/index.jsx create mode 100644 web/src/routes/Events/components/tableHead.jsx create mode 100644 web/src/routes/Events/components/tableRow.jsx create mode 100644 web/src/routes/Events/index.jsx create mode 100644 web/src/routes/Events/reducer.jsx diff --git a/web/src/components/AppBar.jsx b/web/src/components/AppBar.jsx index 567fe291d..1003fee55 100644 --- a/web/src/components/AppBar.jsx +++ b/web/src/components/AppBar.jsx @@ -37,7 +37,8 @@ export default function AppBar({ title: Title, overflowRef, onOverflowClick }) { return (
+ {children} ); @@ -30,9 +30,10 @@ export function Tfoot({ children, className = '', ...attrs }) { ); } -export function Tr({ children, className = '', ...attrs }) { +export function Tr({ children, className = '', reference, ...attrs }) { return ( @@ -49,9 +50,9 @@ export function Th({ children, className = '', colspan, ...attrs }) { ); } -export function Td({ children, className = '', colspan, ...attrs }) { +export function Td({ children, className = '', reference, colspan, ...attrs }) { return ( - + {children} ); diff --git a/web/src/components/VideoPlayer.jsx b/web/src/components/VideoPlayer.jsx index 3560d964a..24ba747b5 100644 --- a/web/src/components/VideoPlayer.jsx +++ b/web/src/components/VideoPlayer.jsx @@ -88,7 +88,7 @@ export default function VideoPlayer({ children, options, seekOptions = {}, onRea return (
-
); diff --git a/web/src/hooks/useClickOutside.jsx b/web/src/hooks/useClickOutside.jsx new file mode 100644 index 000000000..8fc14660c --- /dev/null +++ b/web/src/hooks/useClickOutside.jsx @@ -0,0 +1,22 @@ +import { useEffect, useRef } from 'preact/hooks'; + +// https://stackoverflow.com/a/54292872/2693528 +export const useClickOutside = (callback) => { + const callbackRef = useRef(); // initialize mutable ref, which stores callback + const innerRef = useRef(); // returned to client, who marks "border" element + + // update cb on each render, so second useEffect has access to current value + useEffect(() => { + callbackRef.current = callback; + }); + + useEffect(() => { + document.addEventListener('click', handleClick); + return () => document.removeEventListener('click', handleClick); + function handleClick(e) { + if (innerRef.current && callbackRef.current && !innerRef.current.contains(e.target)) callbackRef.current(e); + } + }, []); + + return innerRef; // convenience for client (doesn't need to init ref himself) +}; diff --git a/web/src/hooks/useSearchString.jsx b/web/src/hooks/useSearchString.jsx new file mode 100644 index 000000000..1dde57dcc --- /dev/null +++ b/web/src/hooks/useSearchString.jsx @@ -0,0 +1,25 @@ +import { useState, useCallback } from 'preact/hooks'; + +const defaultSearchString = (limit) => `include_thumbnails=0&limit=${limit}`; + +export const useSearchString = (limit, searchParams) => { + const { searchParams: initialSearchParams } = new URL(window.location); + const _searchParams = searchParams || initialSearchParams.toString(); + + const [searchString, changeSearchString] = useState(`${defaultSearchString(limit)}&${_searchParams}`); + + const setSearchString = useCallback( + (limit, searchString) => { + changeSearchString(`${defaultSearchString(limit)}&${searchString}`); + }, + [changeSearchString] + ); + + const removeDefaultSearchKeys = useCallback((searchParams) => { + searchParams.delete('limit'); + searchParams.delete('include_thumbnails'); + searchParams.delete('before'); + }, []); + + return { searchString, setSearchString, removeDefaultSearchKeys }; +}; diff --git a/web/src/index.css b/web/src/index.css index 2278ef964..b7b93a69e 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -36,5 +36,20 @@ Maintain aspect ratio and scale down the video container Could not find a proper tailwind css. */ .outer-max-width { - max-width: 60%; + max-width: 70%; +} + +/* + Hide some videoplayer controls on mobile devices to + align the video player and bottom control bar properly. +*/ +@media only screen and (max-width: 700px) { + .small-player .vjs-time-control, + .small-player .vjs-time-divider { + display: none; + } + div.vjs-control-bar > .skip-back.skip-5, + div.vjs-control-bar > .skip-forward.skip-10 { + display: none; + } } diff --git a/web/src/routes/Event.jsx b/web/src/routes/Event.jsx index 06025d75e..d69a882e0 100644 --- a/web/src/routes/Event.jsx +++ b/web/src/routes/Event.jsx @@ -1,7 +1,10 @@ import { h, Fragment } from 'preact'; import { useCallback, useState, useEffect } from 'preact/hooks'; +import Link from '../components/Link'; import ActivityIndicator from '../components/ActivityIndicator'; import Button from '../components/Button'; +import ArrowDown from '../icons/ArrowDropdown'; +import ArrowDropup from '../icons/ArrowDropup'; import Clip from '../icons/Clip'; import Close from '../icons/Close'; import Delete from '../icons/Delete'; @@ -9,12 +12,46 @@ import Snapshot from '../icons/Snapshot'; import Dialog from '../components/Dialog'; import Heading from '../components/Heading'; import VideoPlayer from '../components/VideoPlayer'; +import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table'; import { FetchStatus, useApiHost, useEvent, useDelete } from '../api'; +const ActionButtonGroup = ({ className, handleClickDelete, close }) => ( +
+ + +
+); + +const DownloadButtonGroup = ({ className, apiHost, eventId }) => ( + + + + +); + export default function Event({ eventId, close, scrollRef }) { const apiHost = useApiHost(); const { data, status } = useEvent(eventId); const [showDialog, setShowDialog] = useState(false); + const [showDetails, setShowDetails] = useState(false); const [shouldScroll, setShouldScroll] = useState(true); const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE); const setDeleteEvent = useDelete(); @@ -25,6 +62,13 @@ export default function Event({ eventId, close, scrollRef }) { scrollRef[eventId].scrollIntoView(); setShouldScroll(false); } + return () => { + // When opening new event window, the previous one will sometimes cause the + // navbar to be visible, hence the "hide nav" code bellow. + // Navbar will be hided if we add the - translate - y - full class.appBar.js + const element = document.getElementById('appbar'); + if (element) element.classList.add('-translate-y-full'); + }; }, [data, scrollRef, eventId, shouldScroll]); const handleClickDelete = () => { @@ -54,25 +98,28 @@ export default function Event({ eventId, close, scrollRef }) { return ; } + const startime = new Date(data.start_time * 1000); + const endtime = new Date(data.end_time * 1000); return (
-
-
- - -
-
- -
+ {showDialog ? ( ) : null}
-
-
+
+ {showDetails ? ( + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue
Camera + {data.camera} +
Timeframe + {startime.toLocaleString()} – {endtime.toLocaleString()} +
Score{(data.top_score * 100).toFixed(2)}%
Zones{data.zones.join(', ')}
+ ) : null} +
+ +
+
{data.has_clip ? ( Clip
+
+ + +
); } diff --git a/web/src/routes/Events.jsx b/web/src/routes/Events.jsx deleted file mode 100644 index 4db9413df..000000000 --- a/web/src/routes/Events.jsx +++ /dev/null @@ -1,326 +0,0 @@ -import { h, Fragment } from 'preact'; -import ActivityIndicator from '../components/ActivityIndicator'; -import Heading from '../components/Heading'; -import Link from '../components/Link'; -import Select from '../components/Select'; -import produce from 'immer'; -import { route } from 'preact-router'; -import Event from './Event'; -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'; - -const API_LIMIT = 25; - -const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {}, deleted: 0 }); -const reducer = (state = initialState, action) => { - switch (action.type) { - case 'DELETE_EVENT': { - const { deletedId } = action; - - return produce(state, (draftState) => { - const idx = draftState.events.findIndex((e) => e.id === deletedId); - if (idx === -1) return state; - - draftState.events.splice(idx, 1); - draftState.deleted++; - }); - } - case 'APPEND_EVENTS': { - const { - meta: { searchString }, - payload, - } = action; - - return produce(state, (draftState) => { - draftState.searchStrings[searchString] = true; - draftState.events.push(...payload); - draftState.deleted = 0; - }); - } - - case 'REACHED_END': { - const { - meta: { searchString }, - } = action; - return produce(state, (draftState) => { - draftState.reachedEnd = true; - draftState.searchStrings[searchString] = true; - }); - } - - case 'RESET': - return initialState; - - default: - return state; - } -}; - -const defaultSearchString = (limit) => `include_thumbnails=0&limit=${limit}`; -function removeDefaultSearchKeys(searchParams) { - searchParams.delete('limit'); - searchParams.delete('include_thumbnails'); - searchParams.delete('before'); -} - -export default function Events({ path: pathname, limit = API_LIMIT } = {}) { - const apiHost = useApiHost(); - const [{ events, reachedEnd, searchStrings, deleted }, dispatch] = useReducer(reducer, initialState); - const { searchParams: initialSearchParams } = new URL(window.location); - const [viewEvent, setViewEvent] = useState(null); - const [searchString, setSearchString] = useState(`${defaultSearchString(limit)}&${initialSearchParams.toString()}`); - const { data, status, deletedId } = useEvents(searchString); - - const scrollToRef = {}; - useEffect(() => { - if (data && !(searchString in searchStrings)) { - dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } }); - } - - if (data && Array.isArray(data) && data.length + deleted < limit) { - dispatch({ type: 'REACHED_END', meta: { searchString } }); - } - - if (deletedId) { - dispatch({ type: 'DELETE_EVENT', deletedId }); - } - }, [data, limit, searchString, searchStrings, deleted, deletedId]); - - const [entry, setIntersectNode] = useIntersectionObserver(); - - useEffect(() => { - if (entry && entry.isIntersecting) { - const { startTime } = entry.target.dataset; - const { searchParams } = new URL(window.location); - searchParams.set('before', parseFloat(startTime) - 0.0001); - - setSearchString(`${defaultSearchString(limit)}&${searchParams.toString()}`); - } - }, [entry, limit]); - - const lastCellRef = useCallback( - (node) => { - if (node !== null && !reachedEnd) { - setIntersectNode(node); - } - }, - [setIntersectNode, reachedEnd] - ); - - const handleFilter = useCallback( - (searchParams) => { - dispatch({ type: 'RESET' }); - removeDefaultSearchKeys(searchParams); - setSearchString(`${defaultSearchString(limit)}&${searchParams.toString()}`); - route(`${pathname}?${searchParams.toString()}`); - }, - [limit, pathname, setSearchString] - ); - - const viewEventHandler = (id) => { - //Toggle event view - if (viewEvent === id) return setViewEvent(null); - - //Set event id to be rendered. - setViewEvent(id); - }; - - const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]); - - return ( -
- Events - - - -
- - - - - - - - - - - - - - {events.map( - ({ camera, id, label, start_time: startTime, end_time: endTime, top_score: score, zones }, i) => { - const start = new Date(parseInt(startTime * 1000, 10)); - const end = new Date(parseInt(endTime * 1000, 10)); - const ref = i === events.length - 1 ? lastCellRef : undefined; - return ( - - - - - - - - - - - - {viewEvent === id ? ( - - - - ) : null} - - ); - } - )} - - - - - - -
- CameraLabelScoreZonesDateStartEnd
- viewEventHandler(id)} - ref={ref} - data-start-time={startTime} - data-reached-end={reachedEnd} - > - (scrollToRef[id] = el)} - width="150" - height="150" - className="cursor-pointer" - style="min-height: 48px; min-width: 48px;" - src={`${apiHost}/api/events/${id}/thumbnail.jpg`} - /> - - - - - - {(score * 100).toFixed(2)}% -
    - {zones.map((zone) => ( -
  • - -
  • - ))} -
-
{start.toLocaleDateString()}{start.toLocaleTimeString()}{end.toLocaleTimeString()}
- setViewEvent(null)} scrollRef={scrollToRef} /> -
- {status === FetchStatus.LOADING ? : reachedEnd ? 'No more events' : null} -
-
-
- ); -} - -function Filterable({ onFilter, pathname, searchParams, paramName, name }) { - const href = useMemo(() => { - const params = new URLSearchParams(searchParams.toString()); - params.set(paramName, name); - removeDefaultSearchKeys(params); - return `${pathname}?${params.toString()}`; - }, [searchParams, paramName, pathname, name]); - - const handleClick = useCallback( - (event) => { - event.preventDefault(); - route(href, true); - const params = new URLSearchParams(searchParams.toString()); - params.set(paramName, name); - onFilter(params); - }, - [href, searchParams, onFilter, paramName, name] - ); - - return ( - - {name} - - ); -} - -function Filters({ onChange, searchParams }) { - const { data } = useConfig(); - - const cameras = useMemo(() => Object.keys(data.cameras), [data]); - - const zones = useMemo( - () => - Object.values(data.cameras) - .reduce((memo, camera) => { - memo = memo.concat(Object.keys(camera.zones)); - return memo; - }, []) - .filter((value, i, self) => self.indexOf(value) === i), - [data] - ); - - const labels = useMemo(() => { - return Object.values(data.cameras) - .reduce((memo, camera) => { - memo = memo.concat(camera.objects?.track || []); - return memo; - }, data.objects?.track || []) - .filter((value, i, self) => self.indexOf(value) === i); - }, [data]); - - return ( -
- - - -
- ); -} - -function Filter({ onChange, searchParams, paramName, options }) { - const handleSelect = useCallback( - (key) => { - const newParams = new URLSearchParams(searchParams.toString()); - if (key !== 'all') { - newParams.set(paramName, key); - } else { - newParams.delete(paramName); - } - - onChange(newParams); - }, - [searchParams, paramName, onChange] - ); - - const selectOptions = useMemo(() => ['all', ...options], [options]); - - return ( - + ); +}; +export default Filter; diff --git a/web/src/routes/Events/components/filterable.jsx b/web/src/routes/Events/components/filterable.jsx new file mode 100644 index 000000000..b23e38eea --- /dev/null +++ b/web/src/routes/Events/components/filterable.jsx @@ -0,0 +1,32 @@ +import { h } from 'preact'; +import { useCallback, useMemo } from 'preact/hooks'; +import Link from '../../../components/Link'; +import { route } from 'preact-router'; + +const Filterable = ({ onFilter, pathname, searchParams, paramName, name, removeDefaultSearchKeys }) => { + const href = useMemo(() => { + const params = new URLSearchParams(searchParams.toString()); + params.set(paramName, name); + removeDefaultSearchKeys(params); + return `${pathname}?${params.toString()}`; + }, [searchParams, paramName, pathname, name, removeDefaultSearchKeys]); + + const handleClick = useCallback( + (event) => { + event.preventDefault(); + route(href, true); + const params = new URLSearchParams(searchParams.toString()); + params.set(paramName, name); + onFilter(params); + }, + [href, searchParams, onFilter, paramName, name] + ); + + return ( + + {name} + + ); +}; + +export default Filterable; diff --git a/web/src/routes/Events/components/filters.jsx b/web/src/routes/Events/components/filters.jsx new file mode 100644 index 000000000..e08b4ea65 --- /dev/null +++ b/web/src/routes/Events/components/filters.jsx @@ -0,0 +1,39 @@ +import { h } from 'preact'; +import Filter from './filter'; +import { useConfig } from '../../../api'; +import { useMemo } from 'preact/hooks'; + +const Filters = ({ onChange, searchParams }) => { + const { data } = useConfig(); + + const cameras = useMemo(() => Object.keys(data.cameras), [data]); + + const zones = useMemo( + () => + Object.values(data.cameras) + .reduce((memo, camera) => { + memo = memo.concat(Object.keys(camera.zones)); + return memo; + }, []) + .filter((value, i, self) => self.indexOf(value) === i), + [data] + ); + + const labels = useMemo(() => { + return Object.values(data.cameras) + .reduce((memo, camera) => { + memo = memo.concat(camera.objects?.track || []); + return memo; + }, data.objects?.track || []) + .filter((value, i, self) => self.indexOf(value) === i); + }, [data]); + + return ( +
+ + + +
+ ); +}; +export default Filters; diff --git a/web/src/routes/Events/components/index.jsx b/web/src/routes/Events/components/index.jsx new file mode 100644 index 000000000..6c03b671f --- /dev/null +++ b/web/src/routes/Events/components/index.jsx @@ -0,0 +1,3 @@ +export { default as TableHead } from './tableHead'; +export { default as TableRow } from './tableRow'; +export { default as Filters } from './filters'; diff --git a/web/src/routes/Events/components/tableHead.jsx b/web/src/routes/Events/components/tableHead.jsx new file mode 100644 index 000000000..69d60d65b --- /dev/null +++ b/web/src/routes/Events/components/tableHead.jsx @@ -0,0 +1,18 @@ +import { h } from 'preact'; +import { Thead, Th, Tr } from '../../../components/Table'; + +const TableHead = () => ( + + + + Camera + Label + Score + Zones + Date + Start + End + + +); +export default TableHead; diff --git a/web/src/routes/Events/components/tableRow.jsx b/web/src/routes/Events/components/tableRow.jsx new file mode 100644 index 000000000..262f3408a --- /dev/null +++ b/web/src/routes/Events/components/tableRow.jsx @@ -0,0 +1,119 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; +import { useCallback, useState, useMemo } from 'preact/hooks'; +import { Tr, Td, Tbody } from '../../../components/Table'; +import Filterable from './filterable'; +import Event from '../../Event'; +import { useSearchString } from '../../../hooks/useSearchString'; +import { useClickOutside } from '../../../hooks/useClickOutside'; + +const EventsRow = memo( + ({ + id, + apiHost, + start_time: startTime, + end_time: endTime, + scrollToRef, + lastRowRef, + handleFilter, + pathname, + limit, + camera, + label, + top_score: score, + zones, + }) => { + const [viewEvent, setViewEvent] = useState(null); + const { searchString, removeDefaultSearchKeys } = useSearchString(limit); + const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]); + + const innerRef = useClickOutside(() => { + setViewEvent(null); + }); + + const viewEventHandler = useCallback( + (id) => { + //Toggle event view + if (viewEvent === id) return setViewEvent(null); + //Set event id to be rendered. + setViewEvent(id); + }, + [viewEvent] + ); + + const start = new Date(parseInt(startTime * 1000, 10)); + const end = new Date(parseInt(endTime * 1000, 10)); + + return ( + + + + viewEventHandler(id)} + ref={lastRowRef} + data-start-time={startTime} + // data-reached-end={reachedEnd} <-- Enable this will cause all events to re-render when reaching end. + > + + + + + + + + + + {(score * 100).toFixed(2)}% + +
    + {zones.map((zone) => ( +
  • + +
  • + ))} +
+ + {start.toLocaleDateString()} + {start.toLocaleTimeString()} + {end.toLocaleTimeString()} + + {viewEvent === id ? ( + + (scrollToRef[id] = el)}> + setViewEvent(null)} scrollRef={scrollToRef} /> + + + ) : null} + + ); + } +); + +export default EventsRow; diff --git a/web/src/routes/Events/index.jsx b/web/src/routes/Events/index.jsx new file mode 100644 index 000000000..0f0c03cb8 --- /dev/null +++ b/web/src/routes/Events/index.jsx @@ -0,0 +1,107 @@ +import { h } from 'preact'; +import ActivityIndicator from '../../components/ActivityIndicator'; +import Heading from '../../components/Heading'; +import { TableHead, Filters, TableRow } from './components'; +import { route } from 'preact-router'; +import { FetchStatus, useApiHost, useEvents } from '../../api'; +import { Table, Tfoot, Tr, Td } from '../../components/Table'; +import { useCallback, useEffect, useMemo, useReducer } from 'preact/hooks'; +import { reducer, initialState } from './reducer'; +import { useSearchString } from '../../hooks/useSearchString'; +import { useIntersectionObserver } from '../../hooks'; + +const API_LIMIT = 25; + +export default function Events({ path: pathname, limit = API_LIMIT } = {}) { + const apiHost = useApiHost(); + const { searchString, setSearchString, removeDefaultSearchKeys } = useSearchString(limit); + const [{ events, reachedEnd, searchStrings, deleted }, dispatch] = useReducer(reducer, initialState); + const { data, status, deletedId } = useEvents(searchString); + + const scrollToRef = useMemo(() => Object, []); + + useEffect(() => { + if (data && !(searchString in searchStrings)) { + dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } }); + } + + if (data && Array.isArray(data) && data.length + deleted < limit) { + dispatch({ type: 'REACHED_END', meta: { searchString } }); + } + + if (deletedId) { + dispatch({ type: 'DELETE_EVENT', deletedId }); + } + }, [data, limit, searchString, searchStrings, deleted, deletedId]); + + const [entry, setIntersectNode] = useIntersectionObserver(); + + useEffect(() => { + if (entry && entry.isIntersecting) { + const { startTime } = entry.target.dataset; + const { searchParams } = new URL(window.location); + searchParams.set('before', parseFloat(startTime) - 0.0001); + setSearchString(limit, searchParams.toString()); + } + }, [entry, limit, setSearchString]); + + const lastCellRef = useCallback( + (node) => { + if (node !== null && !reachedEnd) { + setIntersectNode(node); + } + }, + [setIntersectNode, reachedEnd] + ); + + const handleFilter = useCallback( + (searchParams) => { + dispatch({ type: 'RESET' }); + removeDefaultSearchKeys(searchParams); + setSearchString(limit, searchParams.toString()); + route(`${pathname}?${searchParams.toString()}`); + }, + [limit, pathname, setSearchString, removeDefaultSearchKeys] + ); + + const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]); + + const RenderTableRow = useCallback( + (props) => ( + + ), + [apiHost, handleFilter, pathname, scrollToRef] + ); + return ( +
+ Events + +
+ + + + {events.map((props, idx) => { + const lastRowRef = idx === events.length - 1 ? lastCellRef : undefined; + return ; + })} + + + + + + +
+ {status === FetchStatus.LOADING ? : reachedEnd ? 'No more events' : null} +
+
+
+ ); +} diff --git a/web/src/routes/Events/reducer.jsx b/web/src/routes/Events/reducer.jsx new file mode 100644 index 000000000..8dce7cdb7 --- /dev/null +++ b/web/src/routes/Events/reducer.jsx @@ -0,0 +1,47 @@ +import produce from 'immer'; + +export const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {}, deleted: 0 }); + +export const reducer = (state = initialState, action) => { + switch (action.type) { + case 'DELETE_EVENT': { + const { deletedId } = action; + + return produce(state, (draftState) => { + const idx = draftState.events.findIndex((e) => e.id === deletedId); + if (idx === -1) return state; + + draftState.events.splice(idx, 1); + draftState.deleted++; + }); + } + case 'APPEND_EVENTS': { + const { + meta: { searchString }, + payload, + } = action; + + return produce(state, (draftState) => { + draftState.searchStrings[searchString] = true; + draftState.events.push(...payload); + draftState.deleted = 0; + }); + } + + case 'REACHED_END': { + const { + meta: { searchString }, + } = action; + return produce(state, (draftState) => { + draftState.reachedEnd = true; + draftState.searchStrings[searchString] = true; + }); + } + + case 'RESET': + return initialState; + + default: + return state; + } +}; diff --git a/web/src/routes/index.js b/web/src/routes/index.js index c5b40de8c..d9b776d87 100644 --- a/web/src/routes/index.js +++ b/web/src/routes/index.js @@ -19,7 +19,7 @@ export async function getBirdseye(url, cb, props) { } export async function getEvents(url, cb, props) { - const module = await import('./Events.jsx'); + const module = await import('./Events'); return module.default; } diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 025c0255c..1a1775327 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -4,6 +4,7 @@ module.exports = { theme: { extend: { screens: { + xs: '480px', '2xl': '1536px', '3xl': '1720px', },