+
+ {showDetails ? (
+
+
+ Key |
+ Value |
+
+
+
+ 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 (
-
- );
-}
-
-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..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',
},