Pārlūkot izejas kodu

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>
Bernt Christian Egeland 3 gadi atpakaļ
vecāks
revīzija
4efc584816

+ 0 - 1
web/src/App.jsx

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

+ 5 - 18
web/src/api/index.jsx

@@ -18,7 +18,7 @@ const initialState = Object.freeze({
 
 const Api = createContext(initialState);
 
-function reducer(state, { type, payload, meta }) {
+function reducer(state, { type, payload }) {
   switch (type) {
     case 'REQUEST': {
       const { url, fetchId } = payload;
@@ -36,22 +36,9 @@ function reducer(state, { type, payload, meta }) {
     }
     case 'DELETE': {
       const { eventId } = payload;
-
       return produce(state, (draftState) => {
-        Object.keys(draftState.queries).map((url, index) => {
-          // If data has no array length then just return state.
-          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;
+        Object.keys(draftState.queries).map((url) => {
+          draftState.queries[url].deletedId = eventId;
         });
       });
     }
@@ -111,9 +98,9 @@ export function useFetch(url, fetchId) {
 
   const data = state.queries[url].data || null;
   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() {

+ 1 - 1
web/src/components/Dialog.jsx

@@ -19,7 +19,7 @@ export default function Dialog({ actions = [], portalRootID = 'dialogs', title,
       <div
         data-testid="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
           role="modal"

+ 13 - 0
web/src/icons/Close.jsx

@@ -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);

+ 9 - 0
web/src/index.css

@@ -29,3 +29,12 @@
 .jsmpeg canvas {
   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%;
+}

+ 65 - 96
web/src/routes/Event.jsx

@@ -1,25 +1,32 @@
 import { h, Fragment } from 'preact';
-import { useCallback, useState } from 'preact/hooks';
-import { route } from 'preact-router';
+import { useCallback, useState, useEffect } from 'preact/hooks';
 import ActivityIndicator from '../components/ActivityIndicator';
 import Button from '../components/Button';
 import Clip from '../icons/Clip';
+import Close from '../icons/Close';
 import Delete from '../icons/Delete';
 import Snapshot from '../icons/Snapshot';
 import Dialog from '../components/Dialog';
 import Heading from '../components/Heading';
-import Link from '../components/Link';
 import VideoPlayer from '../components/VideoPlayer';
 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 { data, status } = useEvent(eventId);
   const [showDialog, setShowDialog] = useState(false);
+  const [shouldScroll, setShouldScroll] = useState(true);
   const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE);
   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 = () => {
     setShowDialog(true);
   };
@@ -40,7 +47,6 @@ export default function Event({ eventId }) {
     if (success) {
       setDeleteStatus(FetchStatus.LOADED);
       setShowDialog(false);
-      route('/events', true);
     }
   }, [eventId, setShowDialog, setDeleteEvent]);
 
@@ -48,18 +54,25 @@ export default function Event({ eventId }) {
     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="flex">
-        <Heading className="flex-grow">
-          {data.camera} {data.label} <span className="text-sm">{startime.toLocaleString()}</span>
-        </Heading>
-        <Button className="self-start" color="red" onClick={handleClickDelete}>
-          <Delete className="w-6" /> Delete event
-        </Button>
+      <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
+          </Button>
+        </div>
         {showDialog ? (
           <Dialog
             onDismiss={handleDismissDeleteDialog}
@@ -78,86 +91,42 @@ export default function Event({ eventId }) {
           />
         ) : null}
       </div>
-
-      <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>
-
-      {data.has_clip ? (
-        <Fragment>
-          <Heading size="lg">Clip</Heading>
-          <VideoPlayer
-            options={{
-              sources: [
-                {
-                  src: `${apiHost}/vod/event/${eventId}/index.m3u8`,
-                  type: 'application/vnd.apple.mpegurl',
-                },
-              ],
-              poster: data.has_snapshot
-                ? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
-                : `data:image/jpeg;base64,${data.thumbnail}`,
-            }}
-            seekOptions={{ forward: 10, back: 5 }}
-            onReady={(player) => {}}
-          />
-          <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>
-          <Heading size="sm">{data.has_snapshot ? 'Best Image' : 'Thumbnail'}</Heading>
-          <img
-            src={
-              data.has_snapshot
-                ? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
-                : `data:image/jpeg;base64,${data.thumbnail}`
-            }
-            alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
-          />
-        </Fragment>
-      )}
+      <div className="outer-max-width m-auto">
+        <div className="w-full pt-5 relative pb-20">
+          {data.has_clip ? (
+            <Fragment>
+              <Heading size="lg">Clip</Heading>
+              <VideoPlayer
+                options={{
+                  sources: [
+                    {
+                      src: `${apiHost}/vod/event/${eventId}/index.m3u8`,
+                      type: 'application/vnd.apple.mpegurl',
+                    },
+                  ],
+                  poster: data.has_snapshot
+                    ? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
+                    : `data:image/jpeg;base64,${data.thumbnail}`,
+                }}
+                seekOptions={{ forward: 10, back: 5 }}
+                onReady={() => {}}
+              />
+            </Fragment>
+          ) : (
+            <Fragment>
+              <Heading size="sm">{data.has_snapshot ? 'Best Image' : 'Thumbnail'}</Heading>
+              <img
+                src={
+                  data.has_snapshot
+                    ? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
+                    : `data:image/jpeg;base64,${data.thumbnail}`
+                }
+                alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
+              />
+            </Fragment>
+          )}
+        </div>
+      </div>
     </div>
   );
 }

+ 99 - 58
web/src/routes/Events.jsx

@@ -1,10 +1,11 @@
-import { h } from 'preact';
+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';
@@ -12,9 +13,20 @@ import { useCallback, useEffect, useMemo, useReducer, useState } from 'preact/ho
 
 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) => {
   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 },
@@ -24,6 +36,7 @@ const reducer = (state = initialState, action) => {
       return produce(state, (draftState) => {
         draftState.searchStrings[searchString] = true;
         draftState.events.push(...payload);
+        draftState.deleted = 0;
       });
     }
 
@@ -54,11 +67,13 @@ function removeDefaultSearchKeys(searchParams) {
 
 export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
   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 [viewEvent, setViewEvent] = useState(null);
   const [searchString, setSearchString] = useState(`${defaultSearchString(limit)}&${initialSearchParams.toString()}`);
-  const { data, status, deleted } = useEvents(searchString);
+  const { data, status, deletedId } = useEvents(searchString);
 
+  const scrollToRef = {};
   useEffect(() => {
     if (data && !(searchString in searchStrings)) {
       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) {
       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();
 
@@ -100,7 +119,16 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
     [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>
@@ -123,70 +151,83 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
           </Thead>
           <Tbody>
             {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 end = new Date(parseInt(endTime * 1000, 10));
                 const ref = i === events.length - 1 ? lastCellRef : undefined;
                 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>
           <Tfoot>
             <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}
               </Td>
             </Tr>