diff --git a/web/src/App.jsx b/web/src/App.jsx index 4ddedece5..f6dd29451 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -28,7 +28,6 @@ export default function App() { - diff --git a/web/src/api/index.jsx b/web/src/api/index.jsx index 3b68a3a87..f6ef556d8 100644 --- a/web/src/api/index.jsx +++ b/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() { diff --git a/web/src/components/Dialog.jsx b/web/src/components/Dialog.jsx index aefc323b4..472dc3e92 100644 --- a/web/src/components/Dialog.jsx +++ b/web/src/components/Dialog.jsx @@ -19,7 +19,7 @@ export default function Dialog({ actions = [], portalRootID = 'dialogs', title,
+ + + + ); +} + +export default memo(Close); diff --git a/web/src/index.css b/web/src/index.css index 1ccb2fad7..2278ef964 100644 --- a/web/src/index.css +++ b/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%; +} diff --git a/web/src/routes/Event.jsx b/web/src/routes/Event.jsx index 3cbe4e60f..06025d75e 100644 --- a/web/src/routes/Event.jsx +++ b/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 ; } - const startime = new Date(data.start_time * 1000); - const endtime = new Date(data.end_time * 1000); - return (
-
- - {data.camera} {data.label} {startime.toLocaleString()} - - +
+
+ + +
+
+ + +
{showDialog ? ( ) : null}
- - - - - - - - - - - - - - - - - - - - - - - - -
KeyValue
Camera - {data.camera} -
Timeframe - {startime.toLocaleString()} – {endtime.toLocaleString()} -
Score{(data.top_score * 100).toFixed(2)}%
Zones{data.zones.join(', ')}
- - {data.has_clip ? ( - - Clip - {}} - /> -
- - -
-
- ) : ( - - {data.has_snapshot ? 'Best Image' : 'Thumbnail'} - {`${data.label} - - )} +
+
+ {data.has_clip ? ( + + Clip + {}} + /> + + ) : ( + + {data.has_snapshot ? 'Best Image' : 'Thumbnail'} + {`${data.label} + + )} +
+
); } diff --git a/web/src/routes/Events.jsx b/web/src/routes/Events.jsx index e74bbadc4..4db9413df 100644 --- a/web/src/routes/Events.jsx +++ b/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 (
Events @@ -123,70 +151,83 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) { {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 ( - - - - + + + viewEventHandler(id)} + ref={ref} + data-start-time={startTime} + data-reached-end={reachedEnd} + > + (scrollToRef[id] = el)} + width="150" + height="150" + className="cursor-pointer" + style="min-height: 48px; min-width: 48px;" + src={`${apiHost}/api/events/${id}/thumbnail.jpg`} + /> + + + + - - - - - - - - - {(score * 100).toFixed(2)}% - -
    - {zones.map((zone) => ( -
  • - -
  • - ))} -
- - {start.toLocaleDateString()} - {start.toLocaleTimeString()} - {end.toLocaleTimeString()} - + + + + + {(score * 100).toFixed(2)}% + +
    + {zones.map((zone) => ( +
  • + +
  • + ))} +
+ + {start.toLocaleDateString()} + {start.toLocaleTimeString()} + {end.toLocaleTimeString()} + + {viewEvent === id ? ( + + + setViewEvent(null)} scrollRef={scrollToRef} /> + + + ) : null} + ); } )} - + {status === FetchStatus.LOADING ? : reachedEnd ? 'No more events' : null}