|
@@ -1,10 +1,11 @@
|
|
-import { h } from 'preact';
|
|
|
|
|
|
+import { h, Fragment } from 'preact';
|
|
import ActivityIndicator from '../components/ActivityIndicator';
|
|
import ActivityIndicator from '../components/ActivityIndicator';
|
|
import Heading from '../components/Heading';
|
|
import Heading from '../components/Heading';
|
|
import Link from '../components/Link';
|
|
import Link from '../components/Link';
|
|
import Select from '../components/Select';
|
|
import Select from '../components/Select';
|
|
import produce from 'immer';
|
|
import produce from 'immer';
|
|
import { route } from 'preact-router';
|
|
import { route } from 'preact-router';
|
|
|
|
+import Event from './Event';
|
|
import { useIntersectionObserver } from '../hooks';
|
|
import { useIntersectionObserver } from '../hooks';
|
|
import { FetchStatus, useApiHost, useConfig, useEvents } from '../api';
|
|
import { FetchStatus, useApiHost, useConfig, useEvents } from '../api';
|
|
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from '../components/Table';
|
|
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from '../components/Table';
|
|
@@ -12,9 +13,20 @@ import { useCallback, useEffect, useMemo, useReducer, useState } from 'preact/ho
|
|
|
|
|
|
const API_LIMIT = 25;
|
|
const API_LIMIT = 25;
|
|
|
|
|
|
-const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {} });
|
|
|
|
|
|
+const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {}, deleted: 0 });
|
|
const reducer = (state = initialState, action) => {
|
|
const reducer = (state = initialState, action) => {
|
|
switch (action.type) {
|
|
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': {
|
|
case 'APPEND_EVENTS': {
|
|
const {
|
|
const {
|
|
meta: { searchString },
|
|
meta: { searchString },
|
|
@@ -24,6 +36,7 @@ const reducer = (state = initialState, action) => {
|
|
return produce(state, (draftState) => {
|
|
return produce(state, (draftState) => {
|
|
draftState.searchStrings[searchString] = true;
|
|
draftState.searchStrings[searchString] = true;
|
|
draftState.events.push(...payload);
|
|
draftState.events.push(...payload);
|
|
|
|
+ draftState.deleted = 0;
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
@@ -54,11 +67,13 @@ function removeDefaultSearchKeys(searchParams) {
|
|
|
|
|
|
export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
|
|
export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
|
|
const apiHost = useApiHost();
|
|
const apiHost = useApiHost();
|
|
- const [{ events, reachedEnd, searchStrings }, dispatch] = useReducer(reducer, initialState);
|
|
|
|
|
|
+ const [{ events, reachedEnd, searchStrings, deleted }, dispatch] = useReducer(reducer, initialState);
|
|
const { searchParams: initialSearchParams } = new URL(window.location);
|
|
const { searchParams: initialSearchParams } = new URL(window.location);
|
|
|
|
+ const [viewEvent, setViewEvent] = useState(null);
|
|
const [searchString, setSearchString] = useState(`${defaultSearchString(limit)}&${initialSearchParams.toString()}`);
|
|
const [searchString, setSearchString] = useState(`${defaultSearchString(limit)}&${initialSearchParams.toString()}`);
|
|
- const { data, status, deleted } = useEvents(searchString);
|
|
|
|
|
|
+ const { data, status, deletedId } = useEvents(searchString);
|
|
|
|
|
|
|
|
+ const scrollToRef = {};
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
if (data && !(searchString in searchStrings)) {
|
|
if (data && !(searchString in searchStrings)) {
|
|
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
|
|
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
|
|
@@ -67,7 +82,11 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
|
|
if (data && Array.isArray(data) && data.length + deleted < limit) {
|
|
if (data && Array.isArray(data) && data.length + deleted < limit) {
|
|
dispatch({ type: 'REACHED_END', meta: { searchString } });
|
|
dispatch({ type: 'REACHED_END', meta: { searchString } });
|
|
}
|
|
}
|
|
- }, [data, limit, searchString, searchStrings, deleted]);
|
|
|
|
|
|
+
|
|
|
|
+ if (deletedId) {
|
|
|
|
+ dispatch({ type: 'DELETE_EVENT', deletedId });
|
|
|
|
+ }
|
|
|
|
+ }, [data, limit, searchString, searchStrings, deleted, deletedId]);
|
|
|
|
|
|
const [entry, setIntersectNode] = useIntersectionObserver();
|
|
const [entry, setIntersectNode] = useIntersectionObserver();
|
|
|
|
|
|
@@ -100,7 +119,16 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
|
|
[limit, pathname, setSearchString]
|
|
[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]);
|
|
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
|
|
|
|
+
|
|
return (
|
|
return (
|
|
<div className="space-y-4 w-full">
|
|
<div className="space-y-4 w-full">
|
|
<Heading>Events</Heading>
|
|
<Heading>Events</Heading>
|
|
@@ -123,70 +151,83 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
|
|
</Thead>
|
|
</Thead>
|
|
<Tbody>
|
|
<Tbody>
|
|
{events.map(
|
|
{events.map(
|
|
- (
|
|
|
|
- { camera, id, label, start_time: startTime, end_time: endTime, thumbnail, top_score: score, zones },
|
|
|
|
- i
|
|
|
|
- ) => {
|
|
|
|
|
|
+ ({ camera, id, label, start_time: startTime, end_time: endTime, top_score: score, zones }, i) => {
|
|
const start = new Date(parseInt(startTime * 1000, 10));
|
|
const start = new Date(parseInt(startTime * 1000, 10));
|
|
const end = new Date(parseInt(endTime * 1000, 10));
|
|
const end = new Date(parseInt(endTime * 1000, 10));
|
|
const ref = i === events.length - 1 ? lastCellRef : undefined;
|
|
const ref = i === events.length - 1 ? lastCellRef : undefined;
|
|
return (
|
|
return (
|
|
- <Tr data-testid={`event-${id}`} key={id}>
|
|
|
|
- <Td className="w-40">
|
|
|
|
- <a href={`/events/${id}`} ref={ref} data-start-time={startTime} data-reached-end={reachedEnd}>
|
|
|
|
- <img
|
|
|
|
- width="150"
|
|
|
|
- height="150"
|
|
|
|
- style="min-height: 48px; min-width: 48px;"
|
|
|
|
- src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
|
|
|
|
|
|
+ <Fragment key={id}>
|
|
|
|
+ <Tr data-testid={`event-${id}`} className={`${viewEvent === id ? 'border-none' : ''}`}>
|
|
|
|
+ <Td className="w-40">
|
|
|
|
+ <a
|
|
|
|
+ onClick={() => viewEventHandler(id)}
|
|
|
|
+ ref={ref}
|
|
|
|
+ data-start-time={startTime}
|
|
|
|
+ data-reached-end={reachedEnd}
|
|
|
|
+ >
|
|
|
|
+ <img
|
|
|
|
+ ref={(el) => (scrollToRef[id] = el)}
|
|
|
|
+ width="150"
|
|
|
|
+ height="150"
|
|
|
|
+ className="cursor-pointer"
|
|
|
|
+ style="min-height: 48px; min-width: 48px;"
|
|
|
|
+ src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
|
|
|
|
+ />
|
|
|
|
+ </a>
|
|
|
|
+ </Td>
|
|
|
|
+ <Td>
|
|
|
|
+ <Filterable
|
|
|
|
+ onFilter={handleFilter}
|
|
|
|
+ pathname={pathname}
|
|
|
|
+ searchParams={searchParams}
|
|
|
|
+ paramName="camera"
|
|
|
|
+ name={camera}
|
|
|
|
+ />
|
|
|
|
+ </Td>
|
|
|
|
+ <Td>
|
|
|
|
+ <Filterable
|
|
|
|
+ onFilter={handleFilter}
|
|
|
|
+ pathname={pathname}
|
|
|
|
+ searchParams={searchParams}
|
|
|
|
+ paramName="label"
|
|
|
|
+ name={label}
|
|
/>
|
|
/>
|
|
- </a>
|
|
|
|
- </Td>
|
|
|
|
- <Td>
|
|
|
|
- <Filterable
|
|
|
|
- onFilter={handleFilter}
|
|
|
|
- pathname={pathname}
|
|
|
|
- searchParams={searchParams}
|
|
|
|
- paramName="camera"
|
|
|
|
- name={camera}
|
|
|
|
- />
|
|
|
|
- </Td>
|
|
|
|
- <Td>
|
|
|
|
- <Filterable
|
|
|
|
- onFilter={handleFilter}
|
|
|
|
- pathname={pathname}
|
|
|
|
- searchParams={searchParams}
|
|
|
|
- paramName="label"
|
|
|
|
- name={label}
|
|
|
|
- />
|
|
|
|
- </Td>
|
|
|
|
- <Td>{(score * 100).toFixed(2)}%</Td>
|
|
|
|
- <Td>
|
|
|
|
- <ul>
|
|
|
|
- {zones.map((zone) => (
|
|
|
|
- <li>
|
|
|
|
- <Filterable
|
|
|
|
- onFilter={handleFilter}
|
|
|
|
- pathname={pathname}
|
|
|
|
- searchParams={searchString}
|
|
|
|
- paramName="zone"
|
|
|
|
- name={zone}
|
|
|
|
- />
|
|
|
|
- </li>
|
|
|
|
- ))}
|
|
|
|
- </ul>
|
|
|
|
- </Td>
|
|
|
|
- <Td>{start.toLocaleDateString()}</Td>
|
|
|
|
- <Td>{start.toLocaleTimeString()}</Td>
|
|
|
|
- <Td>{end.toLocaleTimeString()}</Td>
|
|
|
|
- </Tr>
|
|
|
|
|
|
+ </Td>
|
|
|
|
+ <Td>{(score * 100).toFixed(2)}%</Td>
|
|
|
|
+ <Td>
|
|
|
|
+ <ul>
|
|
|
|
+ {zones.map((zone) => (
|
|
|
|
+ <li>
|
|
|
|
+ <Filterable
|
|
|
|
+ onFilter={handleFilter}
|
|
|
|
+ pathname={pathname}
|
|
|
|
+ searchParams={searchString}
|
|
|
|
+ paramName="zone"
|
|
|
|
+ name={zone}
|
|
|
|
+ />
|
|
|
|
+ </li>
|
|
|
|
+ ))}
|
|
|
|
+ </ul>
|
|
|
|
+ </Td>
|
|
|
|
+ <Td>{start.toLocaleDateString()}</Td>
|
|
|
|
+ <Td>{start.toLocaleTimeString()}</Td>
|
|
|
|
+ <Td>{end.toLocaleTimeString()}</Td>
|
|
|
|
+ </Tr>
|
|
|
|
+ {viewEvent === id ? (
|
|
|
|
+ <Tr className="border-b-1">
|
|
|
|
+ <Td colSpan="8">
|
|
|
|
+ <Event eventId={id} close={() => setViewEvent(null)} scrollRef={scrollToRef} />
|
|
|
|
+ </Td>
|
|
|
|
+ </Tr>
|
|
|
|
+ ) : null}
|
|
|
|
+ </Fragment>
|
|
);
|
|
);
|
|
}
|
|
}
|
|
)}
|
|
)}
|
|
</Tbody>
|
|
</Tbody>
|
|
<Tfoot>
|
|
<Tfoot>
|
|
<Tr>
|
|
<Tr>
|
|
- <Td className="text-center p-4" colspan="8">
|
|
|
|
|
|
+ <Td className="text-center p-4" colSpan="8">
|
|
{status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null}
|
|
{status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null}
|
|
</Td>
|
|
</Td>
|
|
</Tr>
|
|
</Tr>
|