mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +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,6 +37,7 @@ export default function AppBar({ title: Title, overflowRef, onOverflowClick }) { | |||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div |     <div | ||||||
|  |       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 ${ |       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' |         !show ? '-translate-y-full' : 'translate-y-0' | ||||||
|       } ${!atZero ? 'shadow-sm' : ''}`} |       } ${!atZero ? 'shadow-sm' : ''}`} | ||||||
|  | |||||||
| @ -14,9 +14,9 @@ export function Thead({ children, className, ...attrs }) { | |||||||
|   ); |   ); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function Tbody({ children, className, ...attrs }) { | export function Tbody({ children, className, reference, ...attrs }) { | ||||||
|   return ( |   return ( | ||||||
|     <tbody className={className} {...attrs}> |     <tbody ref={reference} className={className} {...attrs}> | ||||||
|       {children} |       {children} | ||||||
|     </tbody> |     </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 ( |   return ( | ||||||
|     <tr |     <tr | ||||||
|  |       ref={reference} | ||||||
|       className={`border-b border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${className}`} |       className={`border-b border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${className}`} | ||||||
|       {...attrs} |       {...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 ( |   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} |       {children} | ||||||
|     </td> |     </td> | ||||||
|   ); |   ); | ||||||
|  | |||||||
| @ -88,7 +88,7 @@ export default function VideoPlayer({ children, options, seekOptions = {}, onRea | |||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div data-vjs-player> |     <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} |       {children} | ||||||
|     </div> |     </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. | Could not find a proper tailwind css. | ||||||
| */ | */ | ||||||
| .outer-max-width { | .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 { h, Fragment } from 'preact'; | ||||||
| import { useCallback, useState, useEffect } from 'preact/hooks'; | import { useCallback, useState, useEffect } from 'preact/hooks'; | ||||||
|  | import Link from '../components/Link'; | ||||||
| import ActivityIndicator from '../components/ActivityIndicator'; | import ActivityIndicator from '../components/ActivityIndicator'; | ||||||
| import Button from '../components/Button'; | import Button from '../components/Button'; | ||||||
|  | import ArrowDown from '../icons/ArrowDropdown'; | ||||||
|  | import ArrowDropup from '../icons/ArrowDropup'; | ||||||
| import Clip from '../icons/Clip'; | import Clip from '../icons/Clip'; | ||||||
| import Close from '../icons/Close'; | import Close from '../icons/Close'; | ||||||
| import Delete from '../icons/Delete'; | import Delete from '../icons/Delete'; | ||||||
| @ -9,12 +12,46 @@ 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 VideoPlayer from '../components/VideoPlayer'; | import VideoPlayer from '../components/VideoPlayer'; | ||||||
|  | import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table'; | ||||||
| import { FetchStatus, useApiHost, useEvent, useDelete } from '../api'; | 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 }) { | 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 [showDetails, setShowDetails] = useState(false); | ||||||
|   const [shouldScroll, setShouldScroll] = useState(true); |   const [shouldScroll, setShouldScroll] = useState(true); | ||||||
|   const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE); |   const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE); | ||||||
|   const setDeleteEvent = useDelete(); |   const setDeleteEvent = useDelete(); | ||||||
| @ -25,6 +62,13 @@ export default function Event({ eventId, close, scrollRef }) { | |||||||
|       scrollRef[eventId].scrollIntoView(); |       scrollRef[eventId].scrollIntoView(); | ||||||
|       setShouldScroll(false); |       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]); |   }, [data, scrollRef, eventId, shouldScroll]); | ||||||
| 
 | 
 | ||||||
|   const handleClickDelete = () => { |   const handleClickDelete = () => { | ||||||
| @ -54,25 +98,28 @@ export default function Event({ eventId, close, scrollRef }) { | |||||||
|     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="grid grid-cols-6 gap-4"> |       <div className="flex md:flex-row justify-between flex-wrap flex-col"> | ||||||
|         <div class="col-start-1 col-end-8 md:space-x-4"> |         <div className="space-y-2 xs:space-y-0 sm:space-x-4"> | ||||||
|           <Button color="blue" href={`${apiHost}/api/events/${eventId}/clip.mp4?download=true`} download> |           <DownloadButtonGroup apiHost={apiHost} eventId={eventId} className="hidden sm:inline" /> | ||||||
|             <Clip className="w-6" /> Download Clip |           <Button className="w-full sm:w-auto" onClick={() => setShowDetails(!showDetails)}> | ||||||
|           </Button> |             {showDetails ? ( | ||||||
|           <Button color="blue" href={`${apiHost}/api/events/${eventId}/snapshot.jpg?download=true`} download> |               <Fragment> | ||||||
|             <Snapshot className="w-6" /> Download Snapshot |                 <ArrowDropup className="w-6" /> | ||||||
|           </Button> |                 Hide event Details | ||||||
|         </div> |               </Fragment> | ||||||
|         <div class="col-end-10 col-span-2 space-x-4"> |             ) : ( | ||||||
|           <Button className="self-start" color="red" onClick={handleClickDelete}> |               <Fragment> | ||||||
|             <Delete className="w-6" /> Delete event |                 <ArrowDown className="w-6" /> | ||||||
|           </Button> |                 Show event Details | ||||||
|           <Button color="gray" className="self-start" onClick={() => close()}> |               </Fragment> | ||||||
|             <Close className="w-6" /> Close |             )} | ||||||
|           </Button> |           </Button> | ||||||
|         </div> |         </div> | ||||||
|  |         <ActionButtonGroup handleClickDelete={handleClickDelete} close={close} className="hidden sm:block" /> | ||||||
|         {showDialog ? ( |         {showDialog ? ( | ||||||
|           <Dialog |           <Dialog | ||||||
|             onDismiss={handleDismissDeleteDialog} |             onDismiss={handleDismissDeleteDialog} | ||||||
| @ -91,13 +138,47 @@ export default function Event({ eventId, close, scrollRef }) { | |||||||
|           /> |           /> | ||||||
|         ) : null} |         ) : null} | ||||||
|       </div> |       </div> | ||||||
|       <div className="outer-max-width m-auto"> |       <div> | ||||||
|         <div className="w-full pt-5 relative pb-20"> |         {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 ? ( |           {data.has_clip ? ( | ||||||
|             <Fragment> |             <Fragment> | ||||||
|               <Heading size="lg">Clip</Heading> |               <Heading size="lg">Clip</Heading> | ||||||
|               <VideoPlayer |               <VideoPlayer | ||||||
|                 options={{ |                 options={{ | ||||||
|  |                   preload: 'none', | ||||||
|                   sources: [ |                   sources: [ | ||||||
|                     { |                     { | ||||||
|                       src: `${apiHost}/vod/event/${eventId}/index.m3u8`, |                       src: `${apiHost}/vod/event/${eventId}/index.m3u8`, | ||||||
| @ -127,6 +208,10 @@ export default function Event({ eventId, close, scrollRef }) { | |||||||
|           )} |           )} | ||||||
|         </div> |         </div> | ||||||
|       </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> |     </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) { | export async function getEvents(url, cb, props) { | ||||||
|   const module = await import('./Events.jsx'); |   const module = await import('./Events'); | ||||||
|   return module.default; |   return module.default; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -4,6 +4,7 @@ module.exports = { | |||||||
|   theme: { |   theme: { | ||||||
|     extend: { |     extend: { | ||||||
|       screens: { |       screens: { | ||||||
|  |         xs: '480px', | ||||||
|         '2xl': '1536px', |         '2xl': '1536px', | ||||||
|         '3xl': '1720px', |         '3xl': '1720px', | ||||||
|       }, |       }, | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user