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 (
-
- );
-}
-
-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 (
-
- );
-}
diff --git a/web/src/routes/Events/components/filter.jsx b/web/src/routes/Events/components/filter.jsx
new file mode 100644
index 000000000..86d1bcd72
--- /dev/null
+++ b/web/src/routes/Events/components/filter.jsx
@@ -0,0 +1,31 @@
+import { h } from 'preact';
+import Select from '../../../components/Select';
+import { useCallback, useMemo } from 'preact/hooks';
+
+const 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;
}