mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
Events performance (#1645)
* rearrange event route and splitted into several components * useIntersectionObserver * re-arrange * searchstring improvement * added xs tailwind breakpoint * useOuterClick hook * cleaned up * removed some video controls for mobile devices * lint * moved hooks to global folder * moved buttons for small devices * added button groups Co-authored-by: Bernt Christian Egeland <cbegelan@gmail.com>
This commit is contained in:
parent
b8df419bad
commit
00ff76a0b9
@ -37,7 +37,8 @@ export default function AppBar({ title: Title, overflowRef, onOverflowClick }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full border-b border-gray-200 dark:border-gray-700 flex items-center align-middle p-2 fixed left-0 right-0 z-10 bg-white dark:bg-gray-900 transform transition-all duration-200 ${
|
||||
id="appbar"
|
||||
className={`w-full border-b border-gray-200 dark:border-gray-700 flex items-center align-middle p-2 fixed left-0 right-0 z-10 bg-white dark:bg-gray-900 transform transition-all duration-200 ${
|
||||
!show ? '-translate-y-full' : 'translate-y-0'
|
||||
} ${!atZero ? 'shadow-sm' : ''}`}
|
||||
data-testid="appbar"
|
||||
|
@ -14,9 +14,9 @@ export function Thead({ children, className, ...attrs }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Tbody({ children, className, ...attrs }) {
|
||||
export function Tbody({ children, className, reference, ...attrs }) {
|
||||
return (
|
||||
<tbody className={className} {...attrs}>
|
||||
<tbody ref={reference} className={className} {...attrs}>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
@ -30,9 +30,10 @@ export function Tfoot({ children, className = '', ...attrs }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Tr({ children, className = '', ...attrs }) {
|
||||
export function Tr({ children, className = '', reference, ...attrs }) {
|
||||
return (
|
||||
<tr
|
||||
ref={reference}
|
||||
className={`border-b border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${className}`}
|
||||
{...attrs}
|
||||
>
|
||||
@ -49,9 +50,9 @@ export function Th({ children, className = '', colspan, ...attrs }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Td({ children, className = '', colspan, ...attrs }) {
|
||||
export function Td({ children, className = '', reference, colspan, ...attrs }) {
|
||||
return (
|
||||
<td className={`p-2 px-1 lg:p-4 ${className}`} colSpan={colspan} {...attrs}>
|
||||
<td ref={reference} className={`p-2 px-1 lg:p-4 ${className}`} colSpan={colspan} {...attrs}>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
|
@ -88,7 +88,7 @@ export default function VideoPlayer({ children, options, seekOptions = {}, onRea
|
||||
|
||||
return (
|
||||
<div data-vjs-player>
|
||||
<video ref={playerRef} className="video-js vjs-default-skin" controls playsinline />
|
||||
<video ref={playerRef} className="small-player video-js vjs-default-skin" controls playsinline />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
22
web/src/hooks/useClickOutside.jsx
Normal file
22
web/src/hooks/useClickOutside.jsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
|
||||
// https://stackoverflow.com/a/54292872/2693528
|
||||
export const useClickOutside = (callback) => {
|
||||
const callbackRef = useRef(); // initialize mutable ref, which stores callback
|
||||
const innerRef = useRef(); // returned to client, who marks "border" element
|
||||
|
||||
// update cb on each render, so second useEffect has access to current value
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => document.removeEventListener('click', handleClick);
|
||||
function handleClick(e) {
|
||||
if (innerRef.current && callbackRef.current && !innerRef.current.contains(e.target)) callbackRef.current(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return innerRef; // convenience for client (doesn't need to init ref himself)
|
||||
};
|
25
web/src/hooks/useSearchString.jsx
Normal file
25
web/src/hooks/useSearchString.jsx
Normal file
@ -0,0 +1,25 @@
|
||||
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 || initialSearchParams.toString();
|
||||
|
||||
const [searchString, changeSearchString] = useState(`${defaultSearchString(limit)}&${_searchParams}`);
|
||||
|
||||
const setSearchString = useCallback(
|
||||
(limit, searchString) => {
|
||||
changeSearchString(`${defaultSearchString(limit)}&${searchString}`);
|
||||
},
|
||||
[changeSearchString]
|
||||
);
|
||||
|
||||
const removeDefaultSearchKeys = useCallback((searchParams) => {
|
||||
searchParams.delete('limit');
|
||||
searchParams.delete('include_thumbnails');
|
||||
searchParams.delete('before');
|
||||
}, []);
|
||||
|
||||
return { searchString, setSearchString, removeDefaultSearchKeys };
|
||||
};
|
@ -36,5 +36,20 @@ Maintain aspect ratio and scale down the video container
|
||||
Could not find a proper tailwind css.
|
||||
*/
|
||||
.outer-max-width {
|
||||
max-width: 60%;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
/*
|
||||
Hide some videoplayer controls on mobile devices to
|
||||
align the video player and bottom control bar properly.
|
||||
*/
|
||||
@media only screen and (max-width: 700px) {
|
||||
.small-player .vjs-time-control,
|
||||
.small-player .vjs-time-divider {
|
||||
display: none;
|
||||
}
|
||||
div.vjs-control-bar > .skip-back.skip-5,
|
||||
div.vjs-control-bar > .skip-forward.skip-10 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,10 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useCallback, useState, useEffect } from 'preact/hooks';
|
||||
import Link from '../components/Link';
|
||||
import ActivityIndicator from '../components/ActivityIndicator';
|
||||
import Button from '../components/Button';
|
||||
import ArrowDown from '../icons/ArrowDropdown';
|
||||
import ArrowDropup from '../icons/ArrowDropup';
|
||||
import Clip from '../icons/Clip';
|
||||
import Close from '../icons/Close';
|
||||
import Delete from '../icons/Delete';
|
||||
@ -9,12 +12,46 @@ import Snapshot from '../icons/Snapshot';
|
||||
import Dialog from '../components/Dialog';
|
||||
import Heading from '../components/Heading';
|
||||
import VideoPlayer from '../components/VideoPlayer';
|
||||
import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
|
||||
import { FetchStatus, useApiHost, useEvent, useDelete } from '../api';
|
||||
|
||||
const ActionButtonGroup = ({ className, handleClickDelete, close }) => (
|
||||
<div className={`space-y-2 space-x-2 sm:space-y-0 xs:space-x-4 ${className}`}>
|
||||
<Button className="xs:w-auto" color="red" onClick={handleClickDelete}>
|
||||
<Delete className="w-6" /> Delete event
|
||||
</Button>
|
||||
<Button color="gray" className="xs:w-auto" onClick={() => close()}>
|
||||
<Close className="w-6" /> Close
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DownloadButtonGroup = ({ className, apiHost, eventId }) => (
|
||||
<span className={`space-y-2 sm:space-y-0 space-x-0 sm:space-x-4 ${className}`}>
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
color="blue"
|
||||
href={`${apiHost}/api/events/${eventId}/clip.mp4?download=true`}
|
||||
download
|
||||
>
|
||||
<Clip className="w-6" /> Download Clip
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
color="blue"
|
||||
href={`${apiHost}/api/events/${eventId}/snapshot.jpg?download=true`}
|
||||
download
|
||||
>
|
||||
<Snapshot className="w-6" /> Download Snapshot
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
|
||||
export default function Event({ eventId, close, scrollRef }) {
|
||||
const apiHost = useApiHost();
|
||||
const { data, status } = useEvent(eventId);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [shouldScroll, setShouldScroll] = useState(true);
|
||||
const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE);
|
||||
const setDeleteEvent = useDelete();
|
||||
@ -25,6 +62,13 @@ export default function Event({ eventId, close, scrollRef }) {
|
||||
scrollRef[eventId].scrollIntoView();
|
||||
setShouldScroll(false);
|
||||
}
|
||||
return () => {
|
||||
// When opening new event window, the previous one will sometimes cause the
|
||||
// navbar to be visible, hence the "hide nav" code bellow.
|
||||
// Navbar will be hided if we add the - translate - y - full class.appBar.js
|
||||
const element = document.getElementById('appbar');
|
||||
if (element) element.classList.add('-translate-y-full');
|
||||
};
|
||||
}, [data, scrollRef, eventId, shouldScroll]);
|
||||
|
||||
const handleClickDelete = () => {
|
||||
@ -54,25 +98,28 @@ export default function Event({ eventId, close, scrollRef }) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
const startime = new Date(data.start_time * 1000);
|
||||
const endtime = new Date(data.end_time * 1000);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
<div class="col-start-1 col-end-8 md:space-x-4">
|
||||
<Button color="blue" href={`${apiHost}/api/events/${eventId}/clip.mp4?download=true`} download>
|
||||
<Clip className="w-6" /> Download Clip
|
||||
</Button>
|
||||
<Button color="blue" href={`${apiHost}/api/events/${eventId}/snapshot.jpg?download=true`} download>
|
||||
<Snapshot className="w-6" /> Download Snapshot
|
||||
</Button>
|
||||
</div>
|
||||
<div class="col-end-10 col-span-2 space-x-4">
|
||||
<Button className="self-start" color="red" onClick={handleClickDelete}>
|
||||
<Delete className="w-6" /> Delete event
|
||||
</Button>
|
||||
<Button color="gray" className="self-start" onClick={() => close()}>
|
||||
<Close className="w-6" /> Close
|
||||
<div className="flex md:flex-row justify-between flex-wrap flex-col">
|
||||
<div className="space-y-2 xs:space-y-0 sm:space-x-4">
|
||||
<DownloadButtonGroup apiHost={apiHost} eventId={eventId} className="hidden sm:inline" />
|
||||
<Button className="w-full sm:w-auto" onClick={() => setShowDetails(!showDetails)}>
|
||||
{showDetails ? (
|
||||
<Fragment>
|
||||
<ArrowDropup className="w-6" />
|
||||
Hide event Details
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment>
|
||||
<ArrowDown className="w-6" />
|
||||
Show event Details
|
||||
</Fragment>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<ActionButtonGroup handleClickDelete={handleClickDelete} close={close} className="hidden sm:block" />
|
||||
{showDialog ? (
|
||||
<Dialog
|
||||
onDismiss={handleDismissDeleteDialog}
|
||||
@ -91,13 +138,47 @@ export default function Event({ eventId, close, scrollRef }) {
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="outer-max-width m-auto">
|
||||
<div className="w-full pt-5 relative pb-20">
|
||||
<div>
|
||||
{showDetails ? (
|
||||
<Table class="w-full">
|
||||
<Thead>
|
||||
<Th>Key</Th>
|
||||
<Th>Value</Th>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
<Tr>
|
||||
<Td>Camera</Td>
|
||||
<Td>
|
||||
<Link href={`/cameras/${data.camera}`}>{data.camera}</Link>
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr index={1}>
|
||||
<Td>Timeframe</Td>
|
||||
<Td>
|
||||
{startime.toLocaleString()} – {endtime.toLocaleString()}
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Score</Td>
|
||||
<Td>{(data.top_score * 100).toFixed(2)}%</Td>
|
||||
</Tr>
|
||||
<Tr index={1}>
|
||||
<Td>Zones</Td>
|
||||
<Td>{data.zones.join(', ')}</Td>
|
||||
</Tr>
|
||||
</Tbody>
|
||||
</Table>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="outer-max-width xs:m-auto">
|
||||
<div className="pt-5 relative pb-20 w-screen xs:w-full">
|
||||
{data.has_clip ? (
|
||||
<Fragment>
|
||||
<Heading size="lg">Clip</Heading>
|
||||
<VideoPlayer
|
||||
options={{
|
||||
preload: 'none',
|
||||
sources: [
|
||||
{
|
||||
src: `${apiHost}/vod/event/${eventId}/index.m3u8`,
|
||||
@ -127,6 +208,10 @@ export default function Event({ eventId, close, scrollRef }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 xs:space-y-0">
|
||||
<DownloadButtonGroup apiHost={apiHost} eventId={eventId} className="block sm:hidden" />
|
||||
<ActionButtonGroup handleClickDelete={handleClickDelete} close={close} className="block sm:hidden" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
32
web/src/routes/Events/components/filterable.jsx
Normal file
32
web/src/routes/Events/components/filterable.jsx
Normal file
@ -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 (
|
||||
<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;
|
3
web/src/routes/Events/components/index.jsx
Normal file
3
web/src/routes/Events/components/index.jsx
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as TableHead } from './tableHead';
|
||||
export { default as TableRow } from './tableRow';
|
||||
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;
|
119
web/src/routes/Events/components/tableRow.jsx
Normal file
119
web/src/routes/Events/components/tableRow.jsx
Normal file
@ -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 (
|
||||
<Tbody reference={innerRef}>
|
||||
<Tr data-testid={`event-${id}`} className={`${viewEvent === id ? 'border-none' : ''}`}>
|
||||
<Td className="w-40">
|
||||
<a
|
||||
onClick={() => viewEventHandler(id)}
|
||||
ref={lastRowRef}
|
||||
data-start-time={startTime}
|
||||
// data-reached-end={reachedEnd} <-- Enable this will cause all events to re-render when reaching end.
|
||||
>
|
||||
<img
|
||||
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" reference={(el) => (scrollToRef[id] = el)}>
|
||||
<Event eventId={id} close={() => setViewEvent(null)} scrollRef={scrollToRef} />
|
||||
</Td>
|
||||
</Tr>
|
||||
) : null}
|
||||
</Tbody>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default EventsRow;
|
107
web/src/routes/Events/index.jsx
Normal file
107
web/src/routes/Events/index.jsx
Normal file
@ -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) => (
|
||||
<TableRow
|
||||
key={props.id}
|
||||
apiHost={apiHost}
|
||||
scrollToRef={scrollToRef}
|
||||
pathname={pathname}
|
||||
limit={API_LIMIT}
|
||||
handleFilter={handleFilter}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
[apiHost, handleFilter, pathname, scrollToRef]
|
||||
);
|
||||
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 />
|
||||
|
||||
{events.map((props, idx) => {
|
||||
const lastRowRef = idx === events.length - 1 ? lastCellRef : undefined;
|
||||
return <RenderTableRow {...props} lastRowRef={lastRowRef} idx={idx} />;
|
||||
})}
|
||||
|
||||
<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) {
|
||||
const module = await import('./Events.jsx');
|
||||
const module = await import('./Events');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
screens: {
|
||||
xs: '480px',
|
||||
'2xl': '1536px',
|
||||
'3xl': '1720px',
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user