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..d37250c3f --- /dev/null +++ b/web/src/routes/Events/components/filterable.jsx @@ -0,0 +1,31 @@ +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..016fb2bad --- /dev/null +++ b/web/src/routes/Events/components/index.jsx @@ -0,0 +1,2 @@ +export { default as TableHead } from './tableHead'; +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..f61ae95ab --- /dev/null +++ b/web/src/routes/Events/components/tableRow.jsx @@ -0,0 +1,186 @@ +import { h, Fragment } from 'preact'; +import { useCallback, useEffect, useState, useRef } from 'preact/hooks'; +import { Tr, Td } from '../../../components/Table'; +import Filterable from './filterable'; +import Event from '../../Event'; +import { useIntersectionObserver } from '../../../hooks'; +import { memo } from 'preact/compat'; +import { useSearchString } from '../hooks/useSearchString'; + +const EventsRow = memo( + ( + { + id, + apiHost, + start_time: startTime, + end_time: endTime, + handleFilter, + reachedEnd, + scrollToRef, + pathname, + searchParams, + camera, + label, + score, + zones, + numberOfEvents, + limit, + idx, + }, + i + ) => { + const [viewEvent, setViewEvent] = useState(null); + const [searchString, setSearchString, removeDefaultSearchKeys] = useSearchString(limit); + // const ref = useRef(); + + const viewEventHandler = useCallback( + (id) => { + //Toggle event view + if (viewEvent === id) return setViewEvent(null); + + //Set event id to be rendered. + setViewEvent(id); + }, + [viewEvent] + ); + + // useEffect(() => { + // const checkIfClickedOutside = (e) => { + // // If the menu is open and the clicked target is not within the menu, + // // then close the menu + // if (viewEvent && ref.current && !ref.current.contains(e.target)) { + // setViewEvent(null); + // } + // }; + // document.addEventListener('mousedown', checkIfClickedOutside); + // return () => { + // // Cleanup the event listener + // document.removeEventListener('mousedown', checkIfClickedOutside); + // }; + // }, [setViewEvent, viewEvent]); + + const [entry, setIntersectNode] = useIntersectionObserver(); + + const lastCellRef = useCallback( + (node) => { + if (node !== null && !reachedEnd) { + setIntersectNode(node); + } + }, + [setIntersectNode, reachedEnd] + ); + + 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]); + + const start = new Date(parseInt(startTime * 1000, 10)); + const end = new Date(parseInt(endTime * 1000, 10)); + const ref = idx === numberOfEvents - 1 ? lastCellRef : undefined; + + console.log('table row renders'); + + return ( + + + + 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()} + + {viewEvent === id ? ( + + + setViewEvent(null)} scrollRef={scrollToRef} /> + + + ) : null} +
+ // + // + // + // viewEventHandler(id)} + // ref={lastCellRef} + // 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`} + // /> + // + // + // {viewEvent === id ? ( + // + // + // + // setViewEvent(null)} scrollRef={scrollToRef} /> + // + // + // + // ) : null} + // + // + ); + } +); + +export default EventsRow; diff --git a/web/src/routes/Events/hooks/useSearchString.jsx b/web/src/routes/Events/hooks/useSearchString.jsx new file mode 100644 index 000000000..6c9ff172d --- /dev/null +++ b/web/src/routes/Events/hooks/useSearchString.jsx @@ -0,0 +1,29 @@ +import { h } from 'preact'; +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 ? searchParams : initialSearchParams.toString(); + + const [searchString, setSearchString] = useState(`${defaultSearchString(limit)}&${_searchParams}`); + + const changeSearchString = useCallback( + (limit, searchString) => { + setSearchString(`${defaultSearchString(limit)}&${searchString}`); + }, + [setSearchString, defaultSearchString] + ); + + const removeDefaultSearchKeys = useCallback( + (searchParams) => { + searchParams.delete('limit'); + searchParams.delete('include_thumbnails'); + searchParams.delete('before'); + }, + [searchParams] + ); + + return [searchString, changeSearchString, removeDefaultSearchKeys]; +}; diff --git a/web/src/routes/Events/index.jsx b/web/src/routes/Events/index.jsx new file mode 100644 index 000000000..d9c65b1d1 --- /dev/null +++ b/web/src/routes/Events/index.jsx @@ -0,0 +1,99 @@ +import { h, Fragment } from 'preact'; +import ActivityIndicator from '../../components/ActivityIndicator'; +import Heading from '../../components/Heading'; +import { TableHead, Filters } from './components'; +import { route } from 'preact-router'; +import TableRow from './components/tableRow'; +import { FetchStatus, useApiHost, useEvents } from '../../api'; +import { Table, Tbody, Tfoot, Tr, Td } from '../../components/Table'; +import { useCallback, useEffect, useMemo, useReducer, useState } from 'preact/hooks'; +import { reducer, initialState } from './reducer'; +import { useSearchString } from './hooks/useSearchString'; + +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 [counter, setCounter] = useState(0); + + let 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]); + + useEffect(() => { + setInterval(() => { + setCounter((prev) => prev + 1); + }, 1000); + }, []); + + 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]); + + console.log('counter ' + counter); + return ( +
+ Events + + + +
+ + + + {events.map(({ id, ...rest }, idx) => { + 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; }