Move event-view to events table. (#1596)

* fixed position for Dialog

* added eventId to deleted item

* removed page route redirect + New Close Button

* event component added to events list. New delete reducer

* removed event route

* moved delete reducer to event page

* removed redundant event details

* keep aspect ratio

* keep aspect ratio

* removed old buttons - repositioned to top

* removed console.log

* event view function

* removed clip header

* top position

* centered image if no clips avail

* comments

* linting

* lint

* added scrollIntoView when event has been mounted

* added Clip header

* added scrollIntoView to test

* lint

* useRef to scroll event into view

* removed unused functions

* reverted changes to event.test

* scroll into view

* moved delete reducer

* removed commented code

* styling

* moved close button to right side

* Added new close svg icon

Co-authored-by: Bernt Christian Egeland <cbegelan@gmail.com>
This commit is contained in:
Bernt Christian Egeland 2021-08-26 13:54:36 +02:00 committed by GitHub
parent 10ab70080a
commit 4efc584816
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 192 additions and 174 deletions

View File

@ -28,7 +28,6 @@ export default function App() {
<AsyncRoute path="/cameras/:camera/editor" getComponent={Routes.getCameraMap} /> <AsyncRoute path="/cameras/:camera/editor" getComponent={Routes.getCameraMap} />
<AsyncRoute path="/cameras/:camera" getComponent={Routes.getCamera} /> <AsyncRoute path="/cameras/:camera" getComponent={Routes.getCamera} />
<AsyncRoute path="/birdseye" getComponent={Routes.getBirdseye} /> <AsyncRoute path="/birdseye" getComponent={Routes.getBirdseye} />
<AsyncRoute path="/events/:eventId" getComponent={Routes.getEvent} />
<AsyncRoute path="/events" getComponent={Routes.getEvents} /> <AsyncRoute path="/events" getComponent={Routes.getEvents} />
<AsyncRoute path="/recording/:camera/:date?/:hour?/:seconds?" getComponent={Routes.getRecording} /> <AsyncRoute path="/recording/:camera/:date?/:hour?/:seconds?" getComponent={Routes.getRecording} />
<AsyncRoute path="/debug" getComponent={Routes.getDebug} /> <AsyncRoute path="/debug" getComponent={Routes.getDebug} />

View File

@ -18,7 +18,7 @@ const initialState = Object.freeze({
const Api = createContext(initialState); const Api = createContext(initialState);
function reducer(state, { type, payload, meta }) { function reducer(state, { type, payload }) {
switch (type) { switch (type) {
case 'REQUEST': { case 'REQUEST': {
const { url, fetchId } = payload; const { url, fetchId } = payload;
@ -36,22 +36,9 @@ function reducer(state, { type, payload, meta }) {
} }
case 'DELETE': { case 'DELETE': {
const { eventId } = payload; const { eventId } = payload;
return produce(state, (draftState) => { return produce(state, (draftState) => {
Object.keys(draftState.queries).map((url, index) => { Object.keys(draftState.queries).map((url) => {
// If data has no array length then just return state. draftState.queries[url].deletedId = eventId;
if (!('data' in draftState.queries[url]) || !draftState.queries[url].data.length) return state;
//Find the index to remove
const removeIndex = draftState.queries[url].data.map((event) => event.id).indexOf(eventId);
if (removeIndex === -1) return state;
// We need to keep track of deleted items, This will be used to re-calculate "ReachEnd" for auto load new events. Events.jsx
const totDeleted = state.queries[url].deleted || 0;
// Splice the deleted index.
draftState.queries[url].data.splice(removeIndex, 1);
draftState.queries[url].deleted = totDeleted + 1;
}); });
}); });
} }
@ -111,9 +98,9 @@ export function useFetch(url, fetchId) {
const data = state.queries[url].data || null; const data = state.queries[url].data || null;
const status = state.queries[url].status; const status = state.queries[url].status;
const deleted = state.queries[url].deleted || 0; const deletedId = state.queries[url].deletedId || 0;
return { data, status, deleted }; return { data, status, deletedId };
} }
export function useDelete() { export function useDelete() {

View File

@ -19,7 +19,7 @@ export default function Dialog({ actions = [], portalRootID = 'dialogs', title,
<div <div
data-testid="scrim" data-testid="scrim"
key="scrim" key="scrim"
className="absolute inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40" className="fixed bg-fixed inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
> >
<div <div
role="modal" role="modal"

13
web/src/icons/Close.jsx Normal file
View File

@ -0,0 +1,13 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Close({ className = '' }) {
return (
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
</svg>
);
}
export default memo(Close);

View File

@ -29,3 +29,12 @@
.jsmpeg canvas { .jsmpeg canvas {
position: static !important; position: static !important;
} }
/*
Event.js
Maintain aspect ratio and scale down the video container
Could not find a proper tailwind css.
*/
.outer-max-width {
max-width: 60%;
}

View File

@ -1,25 +1,32 @@
import { h, Fragment } from 'preact'; import { h, Fragment } from 'preact';
import { useCallback, useState } from 'preact/hooks'; import { useCallback, useState, useEffect } from 'preact/hooks';
import { route } from 'preact-router';
import ActivityIndicator from '../components/ActivityIndicator'; import ActivityIndicator from '../components/ActivityIndicator';
import Button from '../components/Button'; import Button from '../components/Button';
import Clip from '../icons/Clip'; import Clip from '../icons/Clip';
import Close from '../icons/Close';
import Delete from '../icons/Delete'; import Delete from '../icons/Delete';
import Snapshot from '../icons/Snapshot'; import Snapshot from '../icons/Snapshot';
import Dialog from '../components/Dialog'; import Dialog from '../components/Dialog';
import Heading from '../components/Heading'; import Heading from '../components/Heading';
import Link from '../components/Link';
import VideoPlayer from '../components/VideoPlayer'; import VideoPlayer from '../components/VideoPlayer';
import { FetchStatus, useApiHost, useEvent, useDelete } from '../api'; import { FetchStatus, useApiHost, useEvent, useDelete } from '../api';
import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
export default function Event({ eventId }) { export default function Event({ eventId, close, scrollRef }) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const { data, status } = useEvent(eventId); const { data, status } = useEvent(eventId);
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const [shouldScroll, setShouldScroll] = useState(true);
const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE); const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE);
const setDeleteEvent = useDelete(); const setDeleteEvent = useDelete();
useEffect(() => {
// Scroll event into view when component has been mounted.
if (shouldScroll && scrollRef && scrollRef[eventId]) {
scrollRef[eventId].scrollIntoView();
setShouldScroll(false);
}
}, [data, scrollRef, eventId, shouldScroll]);
const handleClickDelete = () => { const handleClickDelete = () => {
setShowDialog(true); setShowDialog(true);
}; };
@ -40,7 +47,6 @@ export default function Event({ eventId }) {
if (success) { if (success) {
setDeleteStatus(FetchStatus.LOADED); setDeleteStatus(FetchStatus.LOADED);
setShowDialog(false); setShowDialog(false);
route('/events', true);
} }
}, [eventId, setShowDialog, setDeleteEvent]); }, [eventId, setShowDialog, setDeleteEvent]);
@ -48,18 +54,25 @@ export default function Event({ eventId }) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
const startime = new Date(data.start_time * 1000);
const endtime = new Date(data.end_time * 1000);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex"> <div className="grid grid-cols-6 gap-4">
<Heading className="flex-grow"> <div class="col-start-1 col-end-8 md:space-x-4">
{data.camera} {data.label} <span className="text-sm">{startime.toLocaleString()}</span> <Button color="blue" href={`${apiHost}/api/events/${eventId}/clip.mp4?download=true`} download>
</Heading> <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}> <Button className="self-start" color="red" onClick={handleClickDelete}>
<Delete className="w-6" /> Delete event <Delete className="w-6" /> Delete event
</Button> </Button>
<Button color="gray" className="self-start" onClick={() => close()}>
<Close className="w-6" /> Close
</Button>
</div>
{showDialog ? ( {showDialog ? (
<Dialog <Dialog
onDismiss={handleDismissDeleteDialog} onDismiss={handleDismissDeleteDialog}
@ -78,36 +91,8 @@ export default function Event({ eventId }) {
/> />
) : null} ) : null}
</div> </div>
<div className="outer-max-width m-auto">
<Table class="w-full"> <div className="w-full pt-5 relative pb-20">
<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>
{data.has_clip ? ( {data.has_clip ? (
<Fragment> <Fragment>
<Heading size="lg">Clip</Heading> <Heading size="lg">Clip</Heading>
@ -124,26 +109,8 @@ export default function Event({ eventId }) {
: `data:image/jpeg;base64,${data.thumbnail}`, : `data:image/jpeg;base64,${data.thumbnail}`,
}} }}
seekOptions={{ forward: 10, back: 5 }} seekOptions={{ forward: 10, back: 5 }}
onReady={(player) => {}} onReady={() => {}}
/> />
<div className="text-center">
<Button
className="mx-2"
color="blue"
href={`${apiHost}/api/events/${eventId}/clip.mp4?download=true`}
download
>
<Clip className="w-6" /> Download Clip
</Button>
<Button
className="mx-2"
color="blue"
href={`${apiHost}/api/events/${eventId}/snapshot.jpg?download=true`}
download
>
<Snapshot className="w-6" /> Download Snapshot
</Button>
</div>
</Fragment> </Fragment>
) : ( ) : (
<Fragment> <Fragment>
@ -159,5 +126,7 @@ export default function Event({ eventId }) {
</Fragment> </Fragment>
)} )}
</div> </div>
</div>
</div>
); );
} }

View File

@ -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,20 +151,25 @@ 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, top_score: score, zones }, i) => {
{ camera, id, label, start_time: startTime, end_time: endTime, thumbnail, 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}> <Fragment key={id}>
<Tr data-testid={`event-${id}`} className={`${viewEvent === id ? 'border-none' : ''}`}>
<Td className="w-40"> <Td className="w-40">
<a href={`/events/${id}`} ref={ref} data-start-time={startTime} data-reached-end={reachedEnd}> <a
onClick={() => viewEventHandler(id)}
ref={ref}
data-start-time={startTime}
data-reached-end={reachedEnd}
>
<img <img
ref={(el) => (scrollToRef[id] = el)}
width="150" width="150"
height="150" height="150"
className="cursor-pointer"
style="min-height: 48px; min-width: 48px;" style="min-height: 48px; min-width: 48px;"
src={`${apiHost}/api/events/${id}/thumbnail.jpg`} src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
/> />
@ -180,13 +213,21 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
<Td>{start.toLocaleTimeString()}</Td> <Td>{start.toLocaleTimeString()}</Td>
<Td>{end.toLocaleTimeString()}</Td> <Td>{end.toLocaleTimeString()}</Td>
</Tr> </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>