mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-01 16:55:21 +03:00
rearrange event route and splitted into several components
This commit is contained in:
parent
46fe06e779
commit
a0de6b7a73
@ -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 (
|
|
||||||
<div className="space-y-4 w-full">
|
|
||||||
<Heading>Events</Heading>
|
|
||||||
|
|
||||||
<Filters onChange={handleFilter} searchParams={searchParams} />
|
|
||||||
|
|
||||||
<div className="min-w-0 overflow-auto">
|
|
||||||
<Table className="min-w-full table-fixed">
|
|
||||||
<Thead>
|
|
||||||
<Tr>
|
|
||||||
<Th />
|
|
||||||
<Th>Camera</Th>
|
|
||||||
<Th>Label</Th>
|
|
||||||
<Th>Score</Th>
|
|
||||||
<Th>Zones</Th>
|
|
||||||
<Th>Date</Th>
|
|
||||||
<Th>Start</Th>
|
|
||||||
<Th>End</Th>
|
|
||||||
</Tr>
|
|
||||||
</Thead>
|
|
||||||
<Tbody>
|
|
||||||
{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 (
|
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
<Tfoot>
|
|
||||||
<Tr>
|
|
||||||
<Td className="text-center p-4" colSpan="8">
|
|
||||||
{status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null}
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
</Tfoot>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Link href={href} onclick={handleClick}>
|
|
||||||
{name}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="flex space-x-4">
|
|
||||||
<Filter onChange={onChange} options={cameras} paramName="camera" searchParams={searchParams} />
|
|
||||||
<Filter onChange={onChange} options={zones} paramName="zone" searchParams={searchParams} />
|
|
||||||
<Filter onChange={onChange} options={labels} paramName="label" searchParams={searchParams} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Select
|
|
||||||
label={`${paramName.charAt(0).toUpperCase()}${paramName.substr(1)}`}
|
|
||||||
onChange={handleSelect}
|
|
||||||
options={selectOptions}
|
|
||||||
selected={searchParams.get(paramName) || 'all'}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
31
web/src/routes/Events/components/filter.jsx
Normal file
31
web/src/routes/Events/components/filter.jsx
Normal file
@ -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 (
|
||||||
|
<Select
|
||||||
|
label={`${paramName.charAt(0).toUpperCase()}${paramName.substr(1)}`}
|
||||||
|
onChange={handleSelect}
|
||||||
|
options={selectOptions}
|
||||||
|
selected={searchParams.get(paramName) || 'all'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Filter;
|
||||||
31
web/src/routes/Events/components/filterable.jsx
Normal file
31
web/src/routes/Events/components/filterable.jsx
Normal file
@ -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 (
|
||||||
|
<Link href={href} onclick={handleClick}>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Filterable;
|
||||||
39
web/src/routes/Events/components/filters.jsx
Normal file
39
web/src/routes/Events/components/filters.jsx
Normal file
@ -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 (
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<Filter onChange={onChange} options={cameras} paramName="camera" searchParams={searchParams} />
|
||||||
|
<Filter onChange={onChange} options={zones} paramName="zone" searchParams={searchParams} />
|
||||||
|
<Filter onChange={onChange} options={labels} paramName="label" searchParams={searchParams} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Filters;
|
||||||
2
web/src/routes/Events/components/index.jsx
Normal file
2
web/src/routes/Events/components/index.jsx
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { default as TableHead } from './tableHead';
|
||||||
|
export { default as Filters } from './filters';
|
||||||
18
web/src/routes/Events/components/tableHead.jsx
Normal file
18
web/src/routes/Events/components/tableHead.jsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { Thead, Th, Tr } from '../../../components/Table';
|
||||||
|
|
||||||
|
const TableHead = () => (
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<Th />
|
||||||
|
<Th>Camera</Th>
|
||||||
|
<Th>Label</Th>
|
||||||
|
<Th>Score</Th>
|
||||||
|
<Th>Zones</Th>
|
||||||
|
<Th>Date</Th>
|
||||||
|
<Th>Start</Th>
|
||||||
|
<Th>End</Th>
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
);
|
||||||
|
export default TableHead;
|
||||||
186
web/src/routes/Events/components/tableRow.jsx
Normal file
186
web/src/routes/Events/components/tableRow.jsx
Normal file
@ -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 (
|
||||||
|
<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}
|
||||||
|
removeDefaultSearchKeys={removeDefaultSearchKeys}
|
||||||
|
/>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Filterable
|
||||||
|
onFilter={handleFilter}
|
||||||
|
pathname={pathname}
|
||||||
|
searchParams={searchParams}
|
||||||
|
paramName="label"
|
||||||
|
name={label}
|
||||||
|
removeDefaultSearchKeys={removeDefaultSearchKeys}
|
||||||
|
/>
|
||||||
|
</Td>
|
||||||
|
<Td>{(score * 100).toFixed(2)}%</Td>
|
||||||
|
<Td>
|
||||||
|
<ul>
|
||||||
|
{zones.map((zone) => (
|
||||||
|
<li>
|
||||||
|
<Filterable
|
||||||
|
onFilter={handleFilter}
|
||||||
|
pathname={pathname}
|
||||||
|
searchParams={searchString}
|
||||||
|
paramName="zone"
|
||||||
|
name={zone}
|
||||||
|
removeDefaultSearchKeys={removeDefaultSearchKeys}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
// <Fragment>
|
||||||
|
// <Tr>
|
||||||
|
// <Td className="w-40">
|
||||||
|
// <a
|
||||||
|
// onClick={() => viewEventHandler(id)}
|
||||||
|
// ref={lastCellRef}
|
||||||
|
// 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>
|
||||||
|
// {viewEvent === id ? (
|
||||||
|
// <span ref={ref}>
|
||||||
|
// <Tr className="border-b-1">
|
||||||
|
// <Td colSpan="8">
|
||||||
|
// <Event eventId={id} close={() => setViewEvent(null)} scrollRef={scrollToRef} />
|
||||||
|
// </Td>
|
||||||
|
// </Tr>
|
||||||
|
// </span>
|
||||||
|
// ) : null}
|
||||||
|
// </Tr>
|
||||||
|
// </Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default EventsRow;
|
||||||
29
web/src/routes/Events/hooks/useSearchString.jsx
Normal file
29
web/src/routes/Events/hooks/useSearchString.jsx
Normal file
@ -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];
|
||||||
|
};
|
||||||
99
web/src/routes/Events/index.jsx
Normal file
99
web/src/routes/Events/index.jsx
Normal file
@ -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 (
|
||||||
|
<div className="space-y-4 w-full">
|
||||||
|
<Heading>Events</Heading>
|
||||||
|
|
||||||
|
<Filters onChange={handleFilter} searchParams={searchParams} />
|
||||||
|
|
||||||
|
<div className="min-w-0 overflow-auto">
|
||||||
|
<Table className="min-w-full table-fixed">
|
||||||
|
<TableHead />
|
||||||
|
<Tbody>
|
||||||
|
{events.map(({ id, ...rest }, idx) => {
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={id}
|
||||||
|
idx={idx}
|
||||||
|
numberOfEvents={events.length}
|
||||||
|
id={id}
|
||||||
|
apiHost={apiHost}
|
||||||
|
scrollToRef={scrollToRef}
|
||||||
|
pathname={pathname}
|
||||||
|
searchParams={searchParams}
|
||||||
|
limit={API_LIMIT}
|
||||||
|
setSearchString={setSearchString}
|
||||||
|
handleFilter={handleFilter}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tbody>
|
||||||
|
<Tfoot>
|
||||||
|
<Tr>
|
||||||
|
<Td className="text-center p-4" colSpan="8">
|
||||||
|
{status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null}
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
</Tfoot>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
web/src/routes/Events/reducer.jsx
Normal file
47
web/src/routes/Events/reducer.jsx
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -19,7 +19,7 @@ export async function getBirdseye(url, cb, props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getEvents(url, cb, props) {
|
export async function getEvents(url, cb, props) {
|
||||||
const module = await import('./Events.jsx');
|
const module = await import('./Events');
|
||||||
return module.default;
|
return module.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user