mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
feat(web): auto-paginate events page
This commit is contained in:
parent
40d5a9f890
commit
e6516235fa
@ -55,7 +55,7 @@ def events_summary():
|
||||
|
||||
if not has_clip is None:
|
||||
clauses.append((Event.has_clip == has_clip))
|
||||
|
||||
|
||||
if not has_snapshot is None:
|
||||
clauses.append((Event.has_snapshot == has_snapshot))
|
||||
|
||||
@ -160,8 +160,8 @@ def events():
|
||||
camera = request.args.get('camera')
|
||||
label = request.args.get('label')
|
||||
zone = request.args.get('zone')
|
||||
after = request.args.get('after', type=int)
|
||||
before = request.args.get('before', type=int)
|
||||
after = request.args.get('after', type=float)
|
||||
before = request.args.get('before', type=float)
|
||||
has_clip = request.args.get('has_clip', type=int)
|
||||
has_snapshot = request.args.get('has_snapshot', type=int)
|
||||
include_thumbnails = request.args.get('include_thumbnails', default=1, type=int)
|
||||
@ -186,7 +186,7 @@ def events():
|
||||
|
||||
if not has_clip is None:
|
||||
clauses.append((Event.has_clip == has_clip))
|
||||
|
||||
|
||||
if not has_snapshot is None:
|
||||
clauses.append((Event.has_snapshot == has_snapshot))
|
||||
|
||||
|
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",
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz",
|
||||
|
@ -12,6 +12,7 @@
|
||||
"@snowpack/plugin-webpack": "^2.3.0",
|
||||
"autoprefixer": "^10.2.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"immer": "^8.0.1",
|
||||
"postcss": "^8.2.2",
|
||||
"postcss-cli": "^8.3.1",
|
||||
"preact": "^10.5.9",
|
||||
|
@ -7,36 +7,25 @@ import Event from './Event';
|
||||
import Events from './Events';
|
||||
import { Router } from 'preact-router';
|
||||
import Sidebar from './Sidebar';
|
||||
import { ApiHost, Config } from './context';
|
||||
import { useContext, useEffect, useState } from 'preact/hooks';
|
||||
import Api, { useConfig } from './api';
|
||||
|
||||
export default function App() {
|
||||
const apiHost = useContext(ApiHost);
|
||||
const [config, setConfig] = useState(null);
|
||||
|
||||
useEffect(async () => {
|
||||
const response = await fetch(`${apiHost}/api/config`);
|
||||
const data = response.ok ? await response.json() : {};
|
||||
setConfig(data);
|
||||
}, []);
|
||||
|
||||
return !config ? (
|
||||
const { data, status } = useConfig();
|
||||
return !data ? (
|
||||
<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">
|
||||
<Sidebar />
|
||||
<div className="flex-auto p-4 lg:pl-8 lg:pr-8 min-w-0">
|
||||
<Router>
|
||||
<CameraMap path="/cameras/:camera/editor" />
|
||||
<Camera path="/cameras/:camera" />
|
||||
<Event path="/events/:eventId" />
|
||||
<Events path="/events" />
|
||||
<Debug path="/debug" />
|
||||
<Cameras default path="/" />
|
||||
</Router>
|
||||
</div>
|
||||
<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 />
|
||||
<div className="flex-auto p-2 md:p-4 lg:pl-8 lg:pr-8 min-w-0">
|
||||
<Router>
|
||||
<CameraMap path="/cameras/:camera/editor" />
|
||||
<Camera path="/cameras/:camera" />
|
||||
<Event path="/events/:eventId" />
|
||||
<Events path="/events" />
|
||||
<Debug path="/debug" />
|
||||
<Cameras default path="/" />
|
||||
</Router>
|
||||
</div>
|
||||
</Config.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -6,13 +6,13 @@ import Link from './components/Link';
|
||||
import Switch from './components/Switch';
|
||||
import { route } from 'preact-router';
|
||||
import { useCallback, useContext } from 'preact/hooks';
|
||||
import { ApiHost, Config } from './context';
|
||||
import { useApiHost, useConfig } from './api';
|
||||
|
||||
export default function Camera({ camera, url }) {
|
||||
const config = useContext(Config);
|
||||
const apiHost = useContext(ApiHost);
|
||||
const { data: config } = useConfig();
|
||||
const apiHost = useApiHost();
|
||||
|
||||
if (!(camera in config.cameras)) {
|
||||
if (!config) {
|
||||
return <div>{`No camera named ${camera}`}</div>;
|
||||
}
|
||||
|
||||
|
@ -5,11 +5,11 @@ import Heading from './components/Heading';
|
||||
import Switch from './components/Switch';
|
||||
import { route } from 'preact-router';
|
||||
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 }) {
|
||||
const config = useContext(Config);
|
||||
const apiHost = useContext(ApiHost);
|
||||
const { data: config } = useConfig();
|
||||
const apiHost = useApiHost();
|
||||
const imageRef = useRef(null);
|
||||
const [imageScale, setImageScale] = useState(1);
|
||||
const [snap, setSnap] = useState(true);
|
||||
|
@ -4,13 +4,12 @@ import CameraImage from './components/CameraImage';
|
||||
import Events from './Events';
|
||||
import Heading from './components/Heading';
|
||||
import { route } from 'preact-router';
|
||||
import { useContext } from 'preact/hooks';
|
||||
import { ApiHost, Config } from './context';
|
||||
import { useConfig } from './api';
|
||||
|
||||
export default function Cameras() {
|
||||
const config = useContext(Config);
|
||||
const { data: config, status } = useConfig();
|
||||
|
||||
if (!config.cameras) {
|
||||
if (!config) {
|
||||
return <p>loading…</p>;
|
||||
}
|
||||
|
||||
|
@ -3,25 +3,21 @@ import Box from './components/Box';
|
||||
import Button from './components/Button';
|
||||
import Heading from './components/Heading';
|
||||
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 { useCallback, useContext, useEffect, useState } from 'preact/hooks';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export default function Debug() {
|
||||
const apiHost = useContext(ApiHost);
|
||||
const config = useContext(Config);
|
||||
const [stats, setStats] = useState({});
|
||||
const config = useConfig();
|
||||
|
||||
const [timeoutId, setTimeoutId] = useState(null);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
const statsResponse = await fetch(`${apiHost}/api/stats`);
|
||||
const stats = statsResponse.ok ? await statsResponse.json() : {};
|
||||
setStats(stats);
|
||||
setTimeoutId(setTimeout(fetchStats, 1000));
|
||||
}, [setStats]);
|
||||
const forceUpdate = useCallback(async () => {
|
||||
setTimeoutId(setTimeout(forceUpdate, 1000));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
forceUpdate();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -29,12 +25,14 @@ export default function Debug() {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [timeoutId]);
|
||||
const { data: stats, status } = useStats(null, timeoutId);
|
||||
|
||||
const { detectors, detection_fps, service, ...cameras } = stats;
|
||||
if (!service) {
|
||||
if (!stats) {
|
||||
return 'loading…';
|
||||
}
|
||||
|
||||
const { detectors, detection_fps, service, ...cameras } = stats;
|
||||
|
||||
const detectorNames = Object.keys(detectors);
|
||||
const detectorDataKeys = Object.keys(detectors[detectorNames[0]]);
|
||||
|
||||
|
@ -1,20 +1,13 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { ApiHost } from './context';
|
||||
import Box from './components/Box';
|
||||
import Heading from './components/Heading';
|
||||
import Link from './components/Link';
|
||||
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 }) {
|
||||
const apiHost = useContext(ApiHost);
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
useEffect(async () => {
|
||||
const response = await fetch(`${apiHost}/api/events/${eventId}`);
|
||||
const data = response.ok ? await response.json() : null;
|
||||
setData(data);
|
||||
}, [apiHost, eventId]);
|
||||
const apiHost = useApiHost();
|
||||
const { data } = useEvent(eventId);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
@ -57,34 +50,36 @@ export default function Event({ eventId }) {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Table>
|
||||
<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>
|
||||
<Box>
|
||||
<Table>
|
||||
<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>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,49 +1,122 @@
|
||||
import { h } from 'preact';
|
||||
import { ApiHost } from './context';
|
||||
import Box from './components/Box';
|
||||
import Heading from './components/Heading';
|
||||
import Link from './components/Link';
|
||||
import produce from 'immer';
|
||||
import { route } from 'preact-router';
|
||||
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 apiHost = useContext(ApiHost);
|
||||
const [events, setEvents] = useState([]);
|
||||
const API_LIMIT = 25;
|
||||
|
||||
const { pathname, searchParams } = new URL(`${window.location.protocol}//${window.location.host}${url || '/events'}`);
|
||||
const searchParamsString = searchParams.toString();
|
||||
const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {} });
|
||||
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 () => {
|
||||
const response = await fetch(`${apiHost}/api/events?${searchParamsString}`);
|
||||
const data = response.ok ? await response.json() : {};
|
||||
setEvents(data);
|
||||
}, [searchParamsString]);
|
||||
case 'REACHED_END': {
|
||||
const {
|
||||
meta: { searchString },
|
||||
} = action;
|
||||
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 (
|
||||
<div className="space-y-4 w-full">
|
||||
<Heading>Events</Heading>
|
||||
|
||||
{searchKeys.length ? (
|
||||
<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}
|
||||
<Filters onChange={handleFilter} searchParams={searchParams} />
|
||||
|
||||
<Box className="min-w-0 overflow-auto">
|
||||
<Table className="w-full">
|
||||
<Table className="min-w-full table-fixed">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th></Th>
|
||||
@ -64,25 +137,33 @@ export default function Events({ url } = {}) {
|
||||
) => {
|
||||
const start = new Date(parseInt(startTime * 1000, 10));
|
||||
const end = new Date(parseInt(endTime * 1000, 10));
|
||||
const ref = i === events.length - 1 ? lastCellRef : undefined;
|
||||
return (
|
||||
<Tr key={id} index={i}>
|
||||
<Td>
|
||||
<a href={`/events/${id}`}>
|
||||
<img className="w-32 max-w-none" src={`data:image/jpeg;base64,${thumbnail}`} />
|
||||
<Td className="w-40">
|
||||
<a href={`/events/${id}`} ref={ref} data-start-time={startTime} data-reached-end={reachedEnd}>
|
||||
<img
|
||||
width="150"
|
||||
height="150"
|
||||
style="min-height: 48px; min-width: 48px;"
|
||||
src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
|
||||
/>
|
||||
</a>
|
||||
</Td>
|
||||
<Td>
|
||||
<Filterable
|
||||
onFilter={handleFilter}
|
||||
pathname={pathname}
|
||||
searchParams={searchParamsString}
|
||||
searchParams={searchParams}
|
||||
paramName="camera"
|
||||
name={camera}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
<Filterable
|
||||
onFilter={handleFilter}
|
||||
pathname={pathname}
|
||||
searchParams={searchParamsString}
|
||||
searchParams={searchParams}
|
||||
paramName="label"
|
||||
name={label}
|
||||
/>
|
||||
@ -93,8 +174,9 @@ export default function Events({ url } = {}) {
|
||||
{zones.map((zone) => (
|
||||
<li>
|
||||
<Filterable
|
||||
onFilter={handleFilter}
|
||||
pathname={pathname}
|
||||
searchParams={searchParamsString}
|
||||
searchParams={searchString}
|
||||
paramName="zone"
|
||||
name={zone}
|
||||
/>
|
||||
@ -110,27 +192,108 @@ export default function Events({ url } = {}) {
|
||||
}
|
||||
)}
|
||||
</Tbody>
|
||||
<Tfoot>
|
||||
<Tr>
|
||||
<Td className="text-center" colspan="8">
|
||||
{status === 'loading' ? 'Loading…' : reachedEnd ? 'No more events' : null}
|
||||
</Td>
|
||||
</Tr>
|
||||
</Tfoot>
|
||||
</Table>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Filterable({ pathname, searchParams, paramName, name }) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set(paramName, name);
|
||||
return <Link href={`${pathname}?${params.toString()}`}>{name}</Link>;
|
||||
}
|
||||
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]);
|
||||
|
||||
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 (
|
||||
<a
|
||||
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()}`}
|
||||
>
|
||||
<Link href={href} onclick={handleClick}>
|
||||
{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 CameraImage from './CameraImage';
|
||||
import { ApiHost, Config } from '../context';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
|
||||
const MIN_LOAD_TIMEOUT_MS = 200;
|
||||
|
@ -4,7 +4,7 @@ export default function Box({ children, className = '', hover = false, href, ...
|
||||
const Element = href ? 'a' : 'div';
|
||||
return (
|
||||
<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' : ''
|
||||
} ${className}`}
|
||||
href={href}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { h } from 'preact';
|
||||
import { ApiHost, Config } from '../context';
|
||||
import { useApiHost, useConfig } from '../api';
|
||||
import { useCallback, useEffect, useContext, useMemo, useRef, useState } from 'preact/hooks';
|
||||
|
||||
export default function CameraImage({ camera, onload, searchParams = '' }) {
|
||||
const config = useContext(Config);
|
||||
const apiHost = useContext(ApiHost);
|
||||
const { data: config } = useConfig();
|
||||
const apiHost = useApiHost();
|
||||
const [availableWidth, setAvailableWidth] = useState(0);
|
||||
const [loadedSrc, setLoadedSrc] = useState(null);
|
||||
const containerRef = useRef(null);
|
||||
|
@ -6,12 +6,12 @@ export function Table({ children, className = '' }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Thead({ children, className = '' }) {
|
||||
return <thead className={`${className}`}>{children}</thead>;
|
||||
export function Thead({ children, className }) {
|
||||
return <thead className={className}>{children}</thead>;
|
||||
}
|
||||
|
||||
export function Tbody({ children, className = '' }) {
|
||||
return <tbody className={`${className}`}>{children}</tbody>;
|
||||
export function Tbody({ children, className }) {
|
||||
return <tbody className={className}>{children}</tbody>;
|
||||
}
|
||||
|
||||
export function Tfoot({ children, className = '' }) {
|
||||
@ -19,13 +19,21 @@ export function Tfoot({ children, className = '' }) {
|
||||
}
|
||||
|
||||
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 = '' }) {
|
||||
return <th className={`border-b-2 border-gray-400 p-4 text-left ${className}`}>{children}</th>;
|
||||
export function Th({ children, className = '', colspan }) {
|
||||
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 = '' }) {
|
||||
return <td className={`p-4 ${className}`}>{children}</td>;
|
||||
export function Td({ children, className = '', colspan }) {
|
||||
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 { ApiProvider } from './api';
|
||||
import { h, render } from 'preact';
|
||||
import 'preact/devtools';
|
||||
import './index.css';
|
||||
|
||||
render(
|
||||
<App />,
|
||||
<ApiProvider>
|
||||
<App />
|
||||
</ApiProvider>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user