mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-06-18 01:16:57 +02:00
feat(web): auto-paginate events page
This commit is contained in:
parent
40d5a9f890
commit
e6516235fa
@ -160,8 +160,8 @@ def events():
|
|||||||
camera = request.args.get('camera')
|
camera = request.args.get('camera')
|
||||||
label = request.args.get('label')
|
label = request.args.get('label')
|
||||||
zone = request.args.get('zone')
|
zone = request.args.get('zone')
|
||||||
after = request.args.get('after', type=int)
|
after = request.args.get('after', type=float)
|
||||||
before = request.args.get('before', type=int)
|
before = request.args.get('before', type=float)
|
||||||
has_clip = request.args.get('has_clip', type=int)
|
has_clip = request.args.get('has_clip', type=int)
|
||||||
has_snapshot = request.args.get('has_snapshot', type=int)
|
has_snapshot = request.args.get('has_snapshot', type=int)
|
||||||
include_thumbnails = request.args.get('include_thumbnails', default=1, type=int)
|
include_thumbnails = request.args.get('include_thumbnails', default=1, type=int)
|
||||||
|
5
web/package-lock.json
generated
5
web/package-lock.json
generated
@ -3574,6 +3574,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
|
||||||
"integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw=="
|
"integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw=="
|
||||||
},
|
},
|
||||||
|
"immer": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA=="
|
||||||
|
},
|
||||||
"import-cwd": {
|
"import-cwd": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz",
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
"@snowpack/plugin-webpack": "^2.3.0",
|
"@snowpack/plugin-webpack": "^2.3.0",
|
||||||
"autoprefixer": "^10.2.1",
|
"autoprefixer": "^10.2.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
|
"immer": "^8.0.1",
|
||||||
"postcss": "^8.2.2",
|
"postcss": "^8.2.2",
|
||||||
"postcss-cli": "^8.3.1",
|
"postcss-cli": "^8.3.1",
|
||||||
"preact": "^10.5.9",
|
"preact": "^10.5.9",
|
||||||
|
@ -7,26 +7,16 @@ import Event from './Event';
|
|||||||
import Events from './Events';
|
import Events from './Events';
|
||||||
import { Router } from 'preact-router';
|
import { Router } from 'preact-router';
|
||||||
import Sidebar from './Sidebar';
|
import Sidebar from './Sidebar';
|
||||||
import { ApiHost, Config } from './context';
|
import Api, { useConfig } from './api';
|
||||||
import { useContext, useEffect, useState } from 'preact/hooks';
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const apiHost = useContext(ApiHost);
|
const { data, status } = useConfig();
|
||||||
const [config, setConfig] = useState(null);
|
return !data ? (
|
||||||
|
|
||||||
useEffect(async () => {
|
|
||||||
const response = await fetch(`${apiHost}/api/config`);
|
|
||||||
const data = response.ok ? await response.json() : {};
|
|
||||||
setConfig(data);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return !config ? (
|
|
||||||
<div />
|
<div />
|
||||||
) : (
|
) : (
|
||||||
<Config.Provider value={config}>
|
|
||||||
<div className="md:flex flex-col md:flex-row md:min-h-screen w-full bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white">
|
<div className="md:flex flex-col md:flex-row md:min-h-screen w-full bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="flex-auto p-4 lg:pl-8 lg:pr-8 min-w-0">
|
<div className="flex-auto p-2 md:p-4 lg:pl-8 lg:pr-8 min-w-0">
|
||||||
<Router>
|
<Router>
|
||||||
<CameraMap path="/cameras/:camera/editor" />
|
<CameraMap path="/cameras/:camera/editor" />
|
||||||
<Camera path="/cameras/:camera" />
|
<Camera path="/cameras/:camera" />
|
||||||
@ -37,6 +27,5 @@ export default function App() {
|
|||||||
</Router>
|
</Router>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Config.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -6,13 +6,13 @@ import Link from './components/Link';
|
|||||||
import Switch from './components/Switch';
|
import Switch from './components/Switch';
|
||||||
import { route } from 'preact-router';
|
import { route } from 'preact-router';
|
||||||
import { useCallback, useContext } from 'preact/hooks';
|
import { useCallback, useContext } from 'preact/hooks';
|
||||||
import { ApiHost, Config } from './context';
|
import { useApiHost, useConfig } from './api';
|
||||||
|
|
||||||
export default function Camera({ camera, url }) {
|
export default function Camera({ camera, url }) {
|
||||||
const config = useContext(Config);
|
const { data: config } = useConfig();
|
||||||
const apiHost = useContext(ApiHost);
|
const apiHost = useApiHost();
|
||||||
|
|
||||||
if (!(camera in config.cameras)) {
|
if (!config) {
|
||||||
return <div>{`No camera named ${camera}`}</div>;
|
return <div>{`No camera named ${camera}`}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,11 +5,11 @@ import Heading from './components/Heading';
|
|||||||
import Switch from './components/Switch';
|
import Switch from './components/Switch';
|
||||||
import { route } from 'preact-router';
|
import { route } from 'preact-router';
|
||||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { ApiHost, Config } from './context';
|
import { useApiHost, useConfig } from './api';
|
||||||
|
|
||||||
export default function CameraMasks({ camera, url }) {
|
export default function CameraMasks({ camera, url }) {
|
||||||
const config = useContext(Config);
|
const { data: config } = useConfig();
|
||||||
const apiHost = useContext(ApiHost);
|
const apiHost = useApiHost();
|
||||||
const imageRef = useRef(null);
|
const imageRef = useRef(null);
|
||||||
const [imageScale, setImageScale] = useState(1);
|
const [imageScale, setImageScale] = useState(1);
|
||||||
const [snap, setSnap] = useState(true);
|
const [snap, setSnap] = useState(true);
|
||||||
|
@ -4,13 +4,12 @@ import CameraImage from './components/CameraImage';
|
|||||||
import Events from './Events';
|
import Events from './Events';
|
||||||
import Heading from './components/Heading';
|
import Heading from './components/Heading';
|
||||||
import { route } from 'preact-router';
|
import { route } from 'preact-router';
|
||||||
import { useContext } from 'preact/hooks';
|
import { useConfig } from './api';
|
||||||
import { ApiHost, Config } from './context';
|
|
||||||
|
|
||||||
export default function Cameras() {
|
export default function Cameras() {
|
||||||
const config = useContext(Config);
|
const { data: config, status } = useConfig();
|
||||||
|
|
||||||
if (!config.cameras) {
|
if (!config) {
|
||||||
return <p>loading…</p>;
|
return <p>loading…</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,25 +3,21 @@ import Box from './components/Box';
|
|||||||
import Button from './components/Button';
|
import Button from './components/Button';
|
||||||
import Heading from './components/Heading';
|
import Heading from './components/Heading';
|
||||||
import Link from './components/Link';
|
import Link from './components/Link';
|
||||||
import { ApiHost, Config } from './context';
|
import { useConfig, useStats } from './api';
|
||||||
import { Table, Tbody, Thead, Tr, Th, Td } from './components/Table';
|
import { Table, Tbody, Thead, Tr, Th, Td } from './components/Table';
|
||||||
import { useCallback, useContext, useEffect, useState } from 'preact/hooks';
|
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
export default function Debug() {
|
export default function Debug() {
|
||||||
const apiHost = useContext(ApiHost);
|
const config = useConfig();
|
||||||
const config = useContext(Config);
|
|
||||||
const [stats, setStats] = useState({});
|
|
||||||
const [timeoutId, setTimeoutId] = useState(null);
|
const [timeoutId, setTimeoutId] = useState(null);
|
||||||
|
|
||||||
const fetchStats = useCallback(async () => {
|
const forceUpdate = useCallback(async () => {
|
||||||
const statsResponse = await fetch(`${apiHost}/api/stats`);
|
setTimeoutId(setTimeout(forceUpdate, 1000));
|
||||||
const stats = statsResponse.ok ? await statsResponse.json() : {};
|
}, []);
|
||||||
setStats(stats);
|
|
||||||
setTimeoutId(setTimeout(fetchStats, 1000));
|
|
||||||
}, [setStats]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStats();
|
forceUpdate();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -29,12 +25,14 @@ export default function Debug() {
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
}, [timeoutId]);
|
}, [timeoutId]);
|
||||||
|
const { data: stats, status } = useStats(null, timeoutId);
|
||||||
|
|
||||||
const { detectors, detection_fps, service, ...cameras } = stats;
|
if (!stats) {
|
||||||
if (!service) {
|
|
||||||
return 'loading…';
|
return 'loading…';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { detectors, detection_fps, service, ...cameras } = stats;
|
||||||
|
|
||||||
const detectorNames = Object.keys(detectors);
|
const detectorNames = Object.keys(detectors);
|
||||||
const detectorDataKeys = Object.keys(detectors[detectorNames[0]]);
|
const detectorDataKeys = Object.keys(detectors[detectorNames[0]]);
|
||||||
|
|
||||||
|
@ -1,20 +1,13 @@
|
|||||||
import { h, Fragment } from 'preact';
|
import { h, Fragment } from 'preact';
|
||||||
import { ApiHost } from './context';
|
|
||||||
import Box from './components/Box';
|
import Box from './components/Box';
|
||||||
import Heading from './components/Heading';
|
import Heading from './components/Heading';
|
||||||
import Link from './components/Link';
|
import Link from './components/Link';
|
||||||
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from './components/Table';
|
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from './components/Table';
|
||||||
import { useContext, useEffect, useState } from 'preact/hooks';
|
import { useApiHost, useEvent } from './api';
|
||||||
|
|
||||||
export default function Event({ eventId }) {
|
export default function Event({ eventId }) {
|
||||||
const apiHost = useContext(ApiHost);
|
const apiHost = useApiHost();
|
||||||
const [data, setData] = useState(null);
|
const { data } = useEvent(eventId);
|
||||||
|
|
||||||
useEffect(async () => {
|
|
||||||
const response = await fetch(`${apiHost}/api/events/${eventId}`);
|
|
||||||
const data = response.ok ? await response.json() : null;
|
|
||||||
setData(data);
|
|
||||||
}, [apiHost, eventId]);
|
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
@ -57,6 +50,7 @@ export default function Event({ eventId }) {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
<Table>
|
<Table>
|
||||||
<Thead>
|
<Thead>
|
||||||
<Th>Key</Th>
|
<Th>Key</Th>
|
||||||
@ -85,6 +79,7 @@ export default function Event({ eventId }) {
|
|||||||
</Tr>
|
</Tr>
|
||||||
</Tbody>
|
</Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,49 +1,122 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { ApiHost } from './context';
|
|
||||||
import Box from './components/Box';
|
import Box from './components/Box';
|
||||||
import Heading from './components/Heading';
|
import Heading from './components/Heading';
|
||||||
import Link from './components/Link';
|
import Link from './components/Link';
|
||||||
|
import produce from 'immer';
|
||||||
import { route } from 'preact-router';
|
import { route } from 'preact-router';
|
||||||
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from './components/Table';
|
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from './components/Table';
|
||||||
import { useCallback, useContext, useEffect, useState } from 'preact/hooks';
|
import { useApiHost, useConfig, useEvents } from './api';
|
||||||
|
import { useCallback, useContext, useEffect, useMemo, useRef, useReducer, useState } from 'preact/hooks';
|
||||||
|
|
||||||
export default function Events({ url } = {}) {
|
const API_LIMIT = 25;
|
||||||
const apiHost = useContext(ApiHost);
|
|
||||||
const [events, setEvents] = useState([]);
|
|
||||||
|
|
||||||
const { pathname, searchParams } = new URL(`${window.location.protocol}//${window.location.host}${url || '/events'}`);
|
const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {} });
|
||||||
const searchParamsString = searchParams.toString();
|
const reducer = (state = initialState, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'APPEND_EVENTS': {
|
||||||
|
const {
|
||||||
|
meta: { searchString },
|
||||||
|
payload,
|
||||||
|
} = action;
|
||||||
|
return produce(state, (draftState) => {
|
||||||
|
draftState.searchStrings[searchString] = true;
|
||||||
|
draftState.events.push(...payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(async () => {
|
case 'REACHED_END': {
|
||||||
const response = await fetch(`${apiHost}/api/events?${searchParamsString}`);
|
const {
|
||||||
const data = response.ok ? await response.json() : {};
|
meta: { searchString },
|
||||||
setEvents(data);
|
} = action;
|
||||||
}, [searchParamsString]);
|
return produce(state, (draftState) => {
|
||||||
|
draftState.reachedEnd = true;
|
||||||
|
draftState.searchStrings[searchString] = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const searchKeys = Array.from(searchParams.keys());
|
case 'RESET':
|
||||||
|
return initialState;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultSearchString = `include_thumbnails=0&limit=${API_LIMIT}`;
|
||||||
|
function removeDefaultSearchKeys(searchParams) {
|
||||||
|
searchParams.delete('limit');
|
||||||
|
searchParams.delete('include_thumbnails');
|
||||||
|
searchParams.delete('before');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Events({ path: pathname } = {}) {
|
||||||
|
const apiHost = useApiHost();
|
||||||
|
const [{ events, reachedEnd, searchStrings }, dispatch] = useReducer(reducer, initialState);
|
||||||
|
const { searchParams: initialSearchParams } = new URL(window.location);
|
||||||
|
const [searchString, setSearchString] = useState(`${defaultSearchString}&${initialSearchParams.toString()}`);
|
||||||
|
const { data, status } = useEvents(searchString);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && !(searchString in searchStrings)) {
|
||||||
|
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
|
||||||
|
}
|
||||||
|
if (Array.isArray(data) && data.length < API_LIMIT) {
|
||||||
|
dispatch({ type: 'REACHED_END', meta: { searchString } });
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const observer = useRef(
|
||||||
|
new IntersectionObserver((entries, observer) => {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// under certain edge cases, a ref may be applied / in memory twice
|
||||||
|
// avoid fetching twice by grabbing the last observed entry only
|
||||||
|
const entry = entries[entries.length - 1];
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const { startTime } = entry.target.dataset;
|
||||||
|
const { searchParams } = new URL(window.location);
|
||||||
|
searchParams.set('before', parseFloat(startTime) - 0.0001);
|
||||||
|
|
||||||
|
setSearchString(`${defaultSearchString}&${searchParams.toString()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastCellRef = useCallback(
|
||||||
|
(node) => {
|
||||||
|
if (node !== null) {
|
||||||
|
observer.current.disconnect();
|
||||||
|
if (!reachedEnd) {
|
||||||
|
observer.current.observe(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[observer.current, reachedEnd]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilter = useCallback(
|
||||||
|
(searchParams) => {
|
||||||
|
dispatch({ type: 'RESET' });
|
||||||
|
removeDefaultSearchKeys(searchParams);
|
||||||
|
setSearchString(`${defaultSearchString}&${searchParams.toString()}`);
|
||||||
|
route(`${pathname}?${searchParams.toString()}`);
|
||||||
|
},
|
||||||
|
[pathname, setSearchString]
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
{searchKeys.length ? (
|
<Filters onChange={handleFilter} searchParams={searchParams} />
|
||||||
<Box>
|
|
||||||
<Heading size="sm">Filters</Heading>
|
|
||||||
<div className="flex flex-wrap space-x-2">
|
|
||||||
{searchKeys.map((filterKey) => (
|
|
||||||
<UnFilterable
|
|
||||||
name={`${filterKey}: ${searchParams.get(filterKey)}`}
|
|
||||||
paramName={filterKey}
|
|
||||||
pathname={pathname}
|
|
||||||
searchParams={searchParamsString}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Box className="min-w-0 overflow-auto">
|
<Box className="min-w-0 overflow-auto">
|
||||||
<Table className="w-full">
|
<Table className="min-w-full table-fixed">
|
||||||
<Thead>
|
<Thead>
|
||||||
<Tr>
|
<Tr>
|
||||||
<Th></Th>
|
<Th></Th>
|
||||||
@ -64,25 +137,33 @@ export default function Events({ url } = {}) {
|
|||||||
) => {
|
) => {
|
||||||
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;
|
||||||
return (
|
return (
|
||||||
<Tr key={id} index={i}>
|
<Tr key={id} index={i}>
|
||||||
<Td>
|
<Td className="w-40">
|
||||||
<a href={`/events/${id}`}>
|
<a href={`/events/${id}`} ref={ref} data-start-time={startTime} data-reached-end={reachedEnd}>
|
||||||
<img className="w-32 max-w-none" src={`data:image/jpeg;base64,${thumbnail}`} />
|
<img
|
||||||
|
width="150"
|
||||||
|
height="150"
|
||||||
|
style="min-height: 48px; min-width: 48px;"
|
||||||
|
src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<Filterable
|
<Filterable
|
||||||
|
onFilter={handleFilter}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
searchParams={searchParamsString}
|
searchParams={searchParams}
|
||||||
paramName="camera"
|
paramName="camera"
|
||||||
name={camera}
|
name={camera}
|
||||||
/>
|
/>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<Filterable
|
<Filterable
|
||||||
|
onFilter={handleFilter}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
searchParams={searchParamsString}
|
searchParams={searchParams}
|
||||||
paramName="label"
|
paramName="label"
|
||||||
name={label}
|
name={label}
|
||||||
/>
|
/>
|
||||||
@ -93,8 +174,9 @@ export default function Events({ url } = {}) {
|
|||||||
{zones.map((zone) => (
|
{zones.map((zone) => (
|
||||||
<li>
|
<li>
|
||||||
<Filterable
|
<Filterable
|
||||||
|
onFilter={handleFilter}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
searchParams={searchParamsString}
|
searchParams={searchString}
|
||||||
paramName="zone"
|
paramName="zone"
|
||||||
name={zone}
|
name={zone}
|
||||||
/>
|
/>
|
||||||
@ -110,27 +192,108 @@ export default function Events({ url } = {}) {
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</Tbody>
|
</Tbody>
|
||||||
|
<Tfoot>
|
||||||
|
<Tr>
|
||||||
|
<Td className="text-center" colspan="8">
|
||||||
|
{status === 'loading' ? 'Loading…' : reachedEnd ? 'No more events' : null}
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
</Tfoot>
|
||||||
</Table>
|
</Table>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Filterable({ pathname, searchParams, paramName, name }) {
|
function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
|
||||||
const params = new URLSearchParams(searchParams);
|
const href = useMemo(() => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set(paramName, name);
|
params.set(paramName, name);
|
||||||
return <Link href={`${pathname}?${params.toString()}`}>{name}</Link>;
|
removeDefaultSearchKeys(params);
|
||||||
}
|
return `${pathname}?${params.toString()}`;
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
route(href, true);
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set(paramName, name);
|
||||||
|
onFilter(params);
|
||||||
|
},
|
||||||
|
[href, searchParams]
|
||||||
|
);
|
||||||
|
|
||||||
function UnFilterable({ pathname, searchParams, paramName, name }) {
|
|
||||||
const params = new URLSearchParams(searchParams);
|
|
||||||
params.delete(paramName);
|
|
||||||
return (
|
return (
|
||||||
<a
|
<Link href={href} onclick={handleClick}>
|
||||||
className="bg-gray-700 text-white px-3 py-1 rounded-md hover:bg-gray-300 hover:text-gray-900 dark:bg-gray-300 dark:text-gray-900 dark:hover:bg-gray-700 dark:hover:text-white"
|
|
||||||
href={`${pathname}?${params.toString()}`}
|
|
||||||
>
|
|
||||||
{name}
|
{name}
|
||||||
</a>
|
</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 (
|
||||||
|
<Box className="flex space-y-0 space-x-8 flex-wrap">
|
||||||
|
<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} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Filter({ onChange, searchParams, paramName, options }) {
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(event) => {
|
||||||
|
const newParams = new URLSearchParams(searchParams.toString());
|
||||||
|
const value = event.target.value;
|
||||||
|
if (value) {
|
||||||
|
newParams.set(paramName, event.target.value);
|
||||||
|
} else {
|
||||||
|
newParams.delete(paramName);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(newParams);
|
||||||
|
},
|
||||||
|
[searchParams, paramName, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
<span className="block uppercase text-sm">{paramName}</span>
|
||||||
|
<select className="border-solid border border-gray-500 rounded dark:text-gray-900" onChange={handleSelect}>
|
||||||
|
<option>All</option>
|
||||||
|
{options.map((opt) => {
|
||||||
|
return (
|
||||||
|
<option value={opt} selected={searchParams.get(paramName) === opt}>
|
||||||
|
{opt}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
108
web/src/api/index.jsx
Normal file
108
web/src/api/index.jsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { h, createContext } from 'preact';
|
||||||
|
import produce from 'immer';
|
||||||
|
import { useCallback, useContext, useEffect, useMemo, useRef, useReducer, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
export const ApiHost = createContext(import.meta.env.SNOWPACK_PUBLIC_API_HOST || window.baseUrl || '');
|
||||||
|
|
||||||
|
export const FetchStatus = {
|
||||||
|
NONE: 'none',
|
||||||
|
LOADING: 'loading',
|
||||||
|
LOADED: 'loaded',
|
||||||
|
ERROR: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState = Object.freeze({
|
||||||
|
host: import.meta.env.SNOWPACK_PUBLIC_API_HOST || window.baseUrl || '',
|
||||||
|
queries: {},
|
||||||
|
});
|
||||||
|
export const Api = createContext(initialState);
|
||||||
|
export default Api;
|
||||||
|
|
||||||
|
function reducer(state, { type, payload, meta }) {
|
||||||
|
switch (type) {
|
||||||
|
case 'REQUEST': {
|
||||||
|
const { url, request } = payload;
|
||||||
|
const data = state.queries[url]?.data || null;
|
||||||
|
return produce(state, (draftState) => {
|
||||||
|
draftState.queries[url] = { status: FetchStatus.LOADING, data };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'RESPONSE': {
|
||||||
|
const { url, ok, data } = payload;
|
||||||
|
return produce(state, (draftState) => {
|
||||||
|
draftState.queries[url] = { status: ok ? FetchStatus.LOADED : FetchStatus.ERROR, data };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApiProvider = ({ children }) => {
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
return <Api.Provider value={{ state, dispatch }}>{children}</Api.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function shouldFetch(state, url, forceRefetch = false) {
|
||||||
|
if (forceRefetch || !(url in state.queries)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const { status } = state.queries[url];
|
||||||
|
|
||||||
|
return status !== FetchStatus.LOADING && status !== FetchStatus.LOADED;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFetch(url, forceRefetch) {
|
||||||
|
const { state, dispatch } = useContext(Api);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldFetch(state, url, forceRefetch)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
await dispatch({ type: 'REQUEST', payload: { url } });
|
||||||
|
const response = await fetch(`${state.host}${url}`);
|
||||||
|
const data = await response.json();
|
||||||
|
await dispatch({ type: 'RESPONSE', payload: { url, ok: response.ok, data } });
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchConfig();
|
||||||
|
}, [url, forceRefetch]);
|
||||||
|
|
||||||
|
if (!(url in state.queries)) {
|
||||||
|
return { data: null, status: FetchStatus.NONE };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = state.queries[url].data || null;
|
||||||
|
const status = state.queries[url].status;
|
||||||
|
|
||||||
|
return { data, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApiHost() {
|
||||||
|
const { state, dispatch } = useContext(Api);
|
||||||
|
return state.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEvents(searchParams, forceRefetch) {
|
||||||
|
const url = `/api/events${searchParams ? `?${searchParams.toString()}` : ''}`;
|
||||||
|
return useFetch(url, forceRefetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEvent(eventId, forceRefetch) {
|
||||||
|
const url = `/api/events/${eventId}`;
|
||||||
|
return useFetch(url, forceRefetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConfig(searchParams, forceRefetch) {
|
||||||
|
const url = `/api/config${searchParams ? `?${searchParams.toString()}` : ''}`;
|
||||||
|
return useFetch(url, forceRefetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStats(searchParams, forceRefetch) {
|
||||||
|
const url = `/api/stats${searchParams ? `?${searchParams.toString()}` : ''}`;
|
||||||
|
return useFetch(url, forceRefetch);
|
||||||
|
}
|
@ -1,6 +1,5 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import CameraImage from './CameraImage';
|
import CameraImage from './CameraImage';
|
||||||
import { ApiHost, Config } from '../context';
|
|
||||||
import { useCallback, useState } from 'preact/hooks';
|
import { useCallback, useState } from 'preact/hooks';
|
||||||
|
|
||||||
const MIN_LOAD_TIMEOUT_MS = 200;
|
const MIN_LOAD_TIMEOUT_MS = 200;
|
||||||
|
@ -4,7 +4,7 @@ export default function Box({ children, className = '', hover = false, href, ...
|
|||||||
const Element = href ? 'a' : 'div';
|
const Element = href ? 'a' : 'div';
|
||||||
return (
|
return (
|
||||||
<Element
|
<Element
|
||||||
className={`bg-white dark:bg-gray-700 shadow-lg rounded-lg p-4 ${
|
className={`bg-white dark:bg-gray-700 shadow-lg rounded-lg p-2 lg:p-4 ${
|
||||||
hover ? 'hover:bg-gray-300 hover:dark:bg-gray-500 dark:hover:text-gray-900 dark:hover:text-gray-900' : ''
|
hover ? 'hover:bg-gray-300 hover:dark:bg-gray-500 dark:hover:text-gray-900 dark:hover:text-gray-900' : ''
|
||||||
} ${className}`}
|
} ${className}`}
|
||||||
href={href}
|
href={href}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { ApiHost, Config } from '../context';
|
import { useApiHost, useConfig } from '../api';
|
||||||
import { useCallback, useEffect, useContext, useMemo, useRef, useState } from 'preact/hooks';
|
import { useCallback, useEffect, useContext, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
export default function CameraImage({ camera, onload, searchParams = '' }) {
|
export default function CameraImage({ camera, onload, searchParams = '' }) {
|
||||||
const config = useContext(Config);
|
const { data: config } = useConfig();
|
||||||
const apiHost = useContext(ApiHost);
|
const apiHost = useApiHost();
|
||||||
const [availableWidth, setAvailableWidth] = useState(0);
|
const [availableWidth, setAvailableWidth] = useState(0);
|
||||||
const [loadedSrc, setLoadedSrc] = useState(null);
|
const [loadedSrc, setLoadedSrc] = useState(null);
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
|
@ -6,12 +6,12 @@ export function Table({ children, className = '' }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Thead({ children, className = '' }) {
|
export function Thead({ children, className }) {
|
||||||
return <thead className={`${className}`}>{children}</thead>;
|
return <thead className={className}>{children}</thead>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Tbody({ children, className = '' }) {
|
export function Tbody({ children, className }) {
|
||||||
return <tbody className={`${className}`}>{children}</tbody>;
|
return <tbody className={className}>{children}</tbody>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Tfoot({ children, className = '' }) {
|
export function Tfoot({ children, className = '' }) {
|
||||||
@ -19,13 +19,21 @@ export function Tfoot({ children, className = '' }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Tr({ children, className = '', index }) {
|
export function Tr({ children, className = '', index }) {
|
||||||
return <tr className={`${index % 2 ? 'bg-gray-200 dark:bg-gray-700' : ''} ${className}`}>{children}</tr>;
|
return <tr className={`${index % 2 ? 'bg-gray-200 dark:bg-gray-600' : ''} ${className}`}>{children}</tr>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Th({ children, className = '' }) {
|
export function Th({ children, className = '', colspan }) {
|
||||||
return <th className={`border-b-2 border-gray-400 p-4 text-left ${className}`}>{children}</th>;
|
return (
|
||||||
|
<th className={`border-b-2 border-gray-400 p-1 md:p-2 text-left ${className}`} colspan={colspan}>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Td({ children, className = '' }) {
|
export function Td({ children, className = '', colspan }) {
|
||||||
return <td className={`p-4 ${className}`}>{children}</td>;
|
return (
|
||||||
|
<td className={`p-1 md:p-2 ${className}`} colspan={colspan}>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
import { createContext } from 'preact';
|
|
||||||
|
|
||||||
export const Config = createContext({});
|
|
||||||
|
|
||||||
export const ApiHost = createContext(import.meta.env.SNOWPACK_PUBLIC_API_HOST || window.baseUrl || '');
|
|
@ -1,9 +1,12 @@
|
|||||||
import App from './App';
|
import App from './App';
|
||||||
|
import { ApiProvider } from './api';
|
||||||
import { h, render } from 'preact';
|
import { h, render } from 'preact';
|
||||||
import 'preact/devtools';
|
import 'preact/devtools';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<App />,
|
<ApiProvider>
|
||||||
|
<App />
|
||||||
|
</ApiProvider>,
|
||||||
document.getElementById('root')
|
document.getElementById('root')
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user