fix loader

start implementing events
redisign retryerror
change image thumbs to cached and useQuery
This commit is contained in:
NlightN22 2024-02-23 03:01:12 +07:00
parent 717e5e633b
commit b4a380aba7
44 changed files with 2483 additions and 93 deletions

View File

@ -23,6 +23,7 @@
"axios": "^1.4.0", "axios": "^1.4.0",
"bson-objectid": "^2.0.4", "bson-objectid": "^2.0.4",
"cookies-next": "^4.1.1", "cookies-next": "^4.1.1",
"date-fns": "^3.3.1",
"dayjs": "^1.11.9", "dayjs": "^1.11.9",
"embla-carousel-react": "^8.0.0-rc10", "embla-carousel-react": "^8.0.0-rc10",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
@ -39,6 +40,7 @@
"react-router-dom": "^6.14.1", "react-router-dom": "^6.14.1",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"react-use-websocket": "^4.7.0", "react-use-websocket": "^4.7.0",
"strftime": "0.10.1",
"typescript": "^4.4.2", "typescript": "^4.4.2",
"validator": "^13.9.0", "validator": "^13.9.0",
"web-vitals": "^2.1.0", "web-vitals": "^2.1.0",
@ -70,6 +72,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@types/strftime": "^0.9.8",
"@types/uuid": "^9.0.2", "@types/uuid": "^9.0.2",
"uuid": "^9.0.0" "uuid": "^9.0.0"
} }

934
src/pages/EventsPage.tsx Normal file
View File

@ -0,0 +1,934 @@
// // import { route } from 'preact-router';
// import { Fragment, useState, useRef, useCallback, useMemo } from 'react';
// import VideoPlayer from '../shared/components/frigate/VideoPlayer';
// import CogwheelLoader from '../shared/components/CogwheelLoader';
// import { Grid, Text } from '@mantine/core';
// import MultiSelect from '../shared/components/frigate/MultiSelect';
// import Button from '../shared/components/frigate/Button';
// import StarRecording from '../shared/components/frigate/icons/StarRecording';
// import Submitted from '../shared/components/frigate/icons/Submitted';
// import CalendarIcon from '../shared/components/frigate/icons/CalendarIcon';
// import Menu, { MenuItem } from '../shared/components/frigate/Menu';
// import Dialog from '../shared/components/frigate/Dialog';
// import TimelineSummary from '../shared/components/frigate/TimelineSummary';
// import TimelineEventOverlay from '../shared/components/frigate/TimelineEventOverlay';
// import { Tabs, TextTab } from '../shared/components/frigate/Tabs';
// import Clock from '../shared/components/frigate/icons/Clock';
// import TimeAgo from '../shared/components/frigate/TimeAgo';
// import Camera from '../shared/components/frigate/icons/Camera';
// import Zone from '../shared/components/frigate/icons/Zone';
// import Score from '../shared/components/frigate/icons/Score';
// import Link from '../shared/components/frigate/Link';
// import Delete from '../shared/components/frigate/icons/Delete';
// import Download from '../shared/components/frigate/icons/Download';
export default function EventsPage() {
return (
<div />
)
}
// const API_LIMIT = 25;
// const daysAgo = (num: number) => {
// let date = new Date();
// date.setDate(date.getDate() - num);
// return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000;
// };
// const monthsAgo = (num: number) => {
// let date = new Date();
// date.setMonth(date.getMonth() - num);
// return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000;
// };
// export default function Events({ path, ...props }) {
// // const apiHost = useApiHost();
// // const { data: config } = useSWR('config');
// const timezone = useMemo(() => config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [config]);
// const [searchParams, setSearchParams] = useState({
// before: null,
// after: null,
// cameras: props.cameras ?? 'all',
// labels: props.labels ?? 'all',
// zones: props.zones ?? 'all',
// sub_labels: props.sub_labels ?? 'all',
// time_range: '00:00,24:00',
// timezone,
// favorites: props.favorites ?? 0,
// is_submitted: props.is_submitted ?? -1,
// event: props.event,
// });
// const [state, setState] = useState({
// showDownloadMenu: false,
// showDatePicker: false,
// showCalendar: false,
// showPlusSubmit: false,
// });
// const [plusSubmitEvent, setPlusSubmitEvent] = useState({
// id: null,
// label: null,
// validBox: null,
// });
// const [uploading, setUploading] = useState([]);
// const [viewEvent, setViewEvent] = useState(props.event);
// const [eventOverlay, setEventOverlay] = useState();
// const [eventDetailType, setEventDetailType] = useState('clip');
// const [downloadEvent, setDownloadEvent] = useState({
// id: null,
// label: null,
// box: null,
// has_clip: false,
// has_snapshot: false,
// plus_id: undefined,
// end_time: null,
// });
// const [deleteFavoriteState, setDeleteFavoriteState] = useState({
// deletingFavoriteEventId: null,
// showDeleteFavorite: false,
// });
// const [showInProgress, setShowInProgress] = useState((props.event || props.cameras || props.labels) == null);
// const eventsFetcher = useCallback(
// (path, params) => {
// if (searchParams.event) {
// path = `${path}/${searchParams.event}`;
// return axios.get(path).then((res) => [res.data]);
// }
// params = { ...params, in_progress: 0, include_thumbnails: 0, limit: API_LIMIT };
// return axios.get(path, { params }).then((res) => res.data);
// },
// [searchParams]
// );
// const getKey = useCallback(
// (index, prevData) => {
// if (index > 0) {
// const lastDate = prevData[prevData.length - 1].start_time;
// const pagedParams = { ...searchParams, before: lastDate };
// return ['events', pagedParams];
// }
// return ['events', searchParams];
// },
// [searchParams]
// );
// const { data: ongoingEvents, mutate: refreshOngoingEvents } = useSWR([
// 'events',
// { in_progress: 1, include_thumbnails: 0 },
// ]);
// // const {
// // data: eventPages,
// // mutate: refreshEvents,
// // size,
// // setSize,
// // isValidating,
// // } = useSWRInfinite(getKey, eventsFetcher);
// // const mutate = () => {
// // refreshEvents();
// // refreshOngoingEvents();
// // };
// // const { data: allLabels } = useSWR(['labels']);
// // const { data: allSubLabels } = useSWR(['sub_labels', { split_joined: 1 }]);
// const filterValues = useMemo(
// () => ({
// cameras: Object.keys(config?.cameras || {}),
// zones: [
// ...Object.values(config?.cameras || {})
// .reduce((memo, camera) => {
// memo = memo.concat(Object.keys(camera?.zones || {}));
// return memo;
// }, [])
// .filter((value, i, self) => self.indexOf(value) === i),
// 'None',
// ],
// labels: Object.values(allLabels || {}),
// sub_labels: (allSubLabels || []).length > 0 ? [...Object.values(allSubLabels), 'None'] : [],
// }),
// [config, allLabels, allSubLabels]
// );
// const onSave = async (e, eventId, save) => {
// e.stopPropagation();
// let response;
// if (save) {
// response = await axios.post(`events/${eventId}/retain`);
// } else {
// response = await axios.delete(`events/${eventId}/retain`);
// }
// if (response.status === 200) {
// mutate();
// }
// };
// const onDelete = async (e, eventId, saved) => {
// e.stopPropagation();
// if (saved) {
// setDeleteFavoriteState({ deletingFavoriteEventId: eventId, showDeleteFavorite: true });
// } else {
// const response = await axios.delete(`events/${eventId}`);
// if (response.status === 200) {
// mutate();
// }
// }
// };
// const onToggleNamedFilter = (name, item) => {
// let items;
// if (searchParams[name] == 'all') {
// const currentItems = Array.from(filterValues[name]);
// // don't remove all if only one option
// if (currentItems.length > 1) {
// currentItems.splice(currentItems.indexOf(item), 1);
// items = currentItems.join(',');
// } else {
// items = ['all'];
// }
// } else {
// let currentItems = searchParams[name].length > 0 ? searchParams[name].split(',') : [];
// if (currentItems.includes(item)) {
// // don't remove the last item in the filter list
// if (currentItems.length > 1) {
// currentItems.splice(currentItems.indexOf(item), 1);
// }
// items = currentItems.join(',');
// } else if (currentItems.length + 1 == filterValues[name].length) {
// items = ['all'];
// } else {
// currentItems.push(item);
// items = currentItems.join(',');
// }
// }
// onFilter(name, items);
// };
// const onEventFrameSelected = (event, frame, seekSeconds) => {
// if (this.player) {
// this.player.pause();
// this.player.currentTime(seekSeconds);
// setEventOverlay(frame);
// }
// };
// const datePicker = useRef();
// const downloadButton = useRef();
// const onDownloadClick = (e, event) => {
// e.stopPropagation();
// setDownloadEvent((_prev) => ({
// id: event.id,
// box: event?.data?.box || event.box,
// label: event.label,
// has_clip: event.has_clip,
// has_snapshot: event.has_snapshot,
// plus_id: event.plus_id,
// end_time: event.end_time,
// }));
// downloadButton.current = e.target;
// setState({ ...state, showDownloadMenu: true });
// };
// const showSubmitToPlus = (event_id, label, box, e) => {
// if (e) {
// e.stopPropagation();
// }
// // if any of the box coordinates are > 1, then the box data is from an older version
// // and not valid to submit to plus with the snapshot image
// setPlusSubmitEvent({ id: event_id, label, validBox: !box.some((d) => d > 1) });
// setState({ ...state, showDownloadMenu: false, showPlusSubmit: true });
// };
// const handleSelectDateRange = useCallback(
// (dates) => {
// setShowInProgress(false);
// setSearchParams({ ...searchParams, before: dates.before, after: dates.after });
// setState({ ...state, showDatePicker: false });
// },
// [searchParams, setSearchParams, state, setState]
// );
// const handleSelectTimeRange = useCallback(
// (timeRange) => {
// setSearchParams({ ...searchParams, time_range: timeRange });
// },
// [searchParams]
// );
// const onFilter = useCallback(
// (name, value) => {
// setShowInProgress(false);
// const updatedParams = { ...searchParams, [name]: value };
// setSearchParams(updatedParams);
// const queryString = Object.keys(updatedParams)
// .map((key) => {
// if (updatedParams[key] && updatedParams[key] != 'all') {
// return `${key}=${updatedParams[key]}`;
// }
// return null;
// })
// .filter((val) => val)
// .join('&');
// route(`${path}?${queryString}`);
// },
// [path, searchParams, setSearchParams]
// );
// const onClickFilterSubmitted = useCallback(() => {
// if (++searchParams.is_submitted > 1) {
// searchParams.is_submitted = -1;
// }
// onFilter('is_submitted', searchParams.is_submitted);
// }, [searchParams, onFilter]);
// const isDone = (eventPages?.[eventPages.length - 1]?.length ?? 0) < API_LIMIT;
// // hooks for infinite scroll
// const observer = useRef();
// const lastEventRef = useCallback(
// (node) => {
// if (isValidating) return;
// if (observer.current) observer.current.disconnect();
// try {
// observer.current = new IntersectionObserver((entries) => {
// if (entries[0].isIntersecting && !isDone) {
// setSize(size + 1);
// }
// });
// if (node) observer.current.observe(node);
// } catch (e) {
// // no op
// }
// },
// [size, setSize, isValidating, isDone]
// );
// const onSendToPlus = async (id, false_positive, validBox) => {
// if (uploading.includes(id)) {
// return;
// }
// setUploading((prev) => [...prev, id]);
// const response = false_positive
// ? await axios.put(`events/${id}/false_positive`)
// : await axios.post(`events/${id}/plus`, validBox ? { include_annotation: 1 } : {});
// if (response.status === 200) {
// mutate(
// (pages) =>
// pages.map((page) =>
// page.map((event) => {
// if (event.id === id) {
// return { ...event, plus_id: response.data.plus_id };
// }
// return event;
// })
// ),
// false
// );
// }
// setUploading((prev) => prev.filter((i) => i !== id));
// if (state.showDownloadMenu && downloadEvent.id === id) {
// setState({ ...state, showDownloadMenu: false });
// }
// setState({ ...state, showPlusSubmit: false });
// };
// const handleEventDetailTabChange = (index) => {
// setEventDetailType(index == 0 ? 'clip' : 'image');
// };
// if (!config) {
// return <CogwheelLoader />;
// }
// return (
// <div className="space-y-4 p-2 px-4 w-full">
// <Text>Events</Text>
// <div className="flex flex-wrap gap-2 items-center">
// <MultiSelect
// className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
// title="Cameras"
// options={filterValues.cameras}
// selection={searchParams.cameras}
// onToggle={(item) => onToggleNamedFilter('cameras', item)}
// onShowAll={() => onFilter('cameras', ['all'])}
// onSelectSingle={(item) => onFilter('cameras', item)}
// />
// <MultiSelect
// className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
// title="Labels"
// options={filterValues.labels}
// selection={searchParams.labels}
// onToggle={(item) => onToggleNamedFilter('labels', item)}
// onShowAll={() => onFilter('labels', ['all'])}
// onSelectSingle={(item) => onFilter('labels', item)}
// />
// <MultiSelect
// className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
// title="Zones"
// options={filterValues.zones}
// selection={searchParams.zones}
// onToggle={(item) => onToggleNamedFilter('zones', item)}
// onShowAll={() => onFilter('zones', ['all'])}
// onSelectSingle={(item) => onFilter('zones', item)}
// />
// {filterValues.sub_labels.length > 0 && (
// <MultiSelect
// className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
// title="Sub Labels"
// options={filterValues.sub_labels}
// selection={searchParams.sub_labels}
// onToggle={(item) => onToggleNamedFilter('sub_labels', item)}
// onShowAll={() => onFilter('sub_labels', ['all'])}
// onSelectSingle={(item) => onFilter('sub_labels', item)}
// />
// )}
// {searchParams.event && (
// <Button className="ml-2" onClick={() => onFilter('event', null)} type="text">
// View All
// </Button>
// )}
// <div className="ml-auto flex">
// {config.plus.enabled && (
// <Submitted
// className="h-10 w-10 text-yellow-300 cursor-pointer ml-auto"
// onClick={() => onClickFilterSubmitted()}
// inner_fill={searchParams.is_submitted == 1 ? 'currentColor' : 'gray'}
// outer_stroke={searchParams.is_submitted >= 0 ? 'currentColor' : 'gray'}
// />
// )}
// <StarRecording
// className="h-10 w-10 text-yellow-300 cursor-pointer ml-auto"
// onClick={() => onFilter('favorites', searchParams.favorites ? 0 : 1)}
// fill={searchParams.favorites == 1 ? 'currentColor' : 'none'}
// />
// </div>
// <div ref={datePicker} className="ml-right">
// <CalendarIcon
// className="h-8 w-8 cursor-pointer"
// onClick={() => setState({ ...state, showDatePicker: true })}
// />
// </div>
// </div>
// {state.showDownloadMenu && (
// <Menu onDismiss={() => setState({ ...state, showDownloadMenu: false })} relativeTo={downloadButton}>
// {downloadEvent.has_snapshot && (
// <MenuItem
// icon={Snapshot}
// label="Download Snapshot"
// value="snapshot"
// href={`${apiHost}api/events/${downloadEvent.id}/snapshot.jpg?download=true`}
// download
// />
// )}
// {downloadEvent.has_clip && (
// <MenuItem
// icon={Clip}
// label="Download Clip"
// value="clip"
// href={`${apiHost}api/events/${downloadEvent.id}/clip.mp4?download=true`}
// download
// />
// )}
// {(event?.data?.type || 'object') == 'object' &&
// downloadEvent.end_time &&
// downloadEvent.has_snapshot &&
// !downloadEvent.plus_id && (
// <MenuItem
// icon={UploadPlus}
// label={uploading.includes(downloadEvent.id) ? 'Uploading...' : 'Send to Frigate+'}
// value="plus"
// onSelect={() => showSubmitToPlus(downloadEvent.id, downloadEvent.label, downloadEvent.box)}
// />
// )}
// {downloadEvent.plus_id && (
// <MenuItem
// icon={UploadPlus}
// label={'Sent to Frigate+'}
// value="plus"
// onSelect={() => setState({ ...state, showDownloadMenu: false })}
// />
// )}
// </Menu>
// )}
// {state.showDatePicker && (
// <Menu
// className="rounded-t-none"
// onDismiss={() => setState({ ...state, setShowDatePicker: false })}
// relativeTo={datePicker}
// >
// <MenuItem label="All" value={{ before: null, after: null }} onSelect={handleSelectDateRange} />
// <MenuItem label="Today" value={{ before: null, after: daysAgo(0) }} onSelect={handleSelectDateRange} />
// <MenuItem
// label="Yesterday"
// value={{ before: daysAgo(0), after: daysAgo(1) }}
// onSelect={handleSelectDateRange}
// />
// <MenuItem label="Last 7 Days" value={{ before: null, after: daysAgo(7) }} onSelect={handleSelectDateRange} />
// <MenuItem label="This Month" value={{ before: null, after: monthsAgo(0) }} onSelect={handleSelectDateRange} />
// <MenuItem
// label="Last Month"
// value={{ before: monthsAgo(0), after: monthsAgo(1) }}
// onSelect={handleSelectDateRange}
// />
// <MenuItem
// label="Custom Range"
// value="custom"
// onSelect={() => {
// setState({ ...state, showCalendar: true, showDatePicker: false });
// }}
// />
// </Menu>
// )}
// {state.showCalendar && (
// <span>
// <Menu
// className="rounded-t-none"
// onDismiss={() => setState({ ...state, showCalendar: false })}
// relativeTo={datePicker}
// >
// <Calendar
// onChange={handleSelectDateRange}
// dateRange={{ before: searchParams.before * 1000 || null, after: searchParams.after * 1000 || null }}
// close={() => setState({ ...state, showCalendar: false })}
// >
// <Timepicker timeRange={searchParams.time_range} onChange={handleSelectTimeRange} />
// </Calendar>
// </Menu>
// </span>
// )}
// {state.showPlusSubmit && (
// <Dialog>
// {config.plus.enabled ? (
// <>
// <div className="p-4">
// <Heading size="lg">Submit to Frigate+</Heading>
// <img
// className="flex-grow-0"
// src={`${apiHost}api/events/${plusSubmitEvent.id}/snapshot.jpg`}
// alt={`${plusSubmitEvent.label}`}
// />
// {plusSubmitEvent.validBox ? (
// <p className="mb-2">
// Objects in locations you want to avoid are not false positives. Submitting them as false positives
// will confuse the model.
// </p>
// ) : (
// <p className="mb-2">
// Events prior to version 0.13 can only be submitted to Frigate+ without annotations.
// </p>
// )}
// </div>
// {plusSubmitEvent.validBox ? (
// <div className="p-2 flex justify-start flex-row-reverse space-x-2">
// <Button className="ml-2" onClick={() => setState({ ...state, showPlusSubmit: false })} type="text">
// {uploading.includes(plusSubmitEvent.id) ? 'Close' : 'Cancel'}
// </Button>
// <Button
// className="ml-2"
// color="red"
// onClick={() => onSendToPlus(plusSubmitEvent.id, true, plusSubmitEvent.validBox)}
// disabled={uploading.includes(plusSubmitEvent.id)}
// type="text"
// >
// This is not a {plusSubmitEvent.label}
// </Button>
// <Button
// className="ml-2"
// color="green"
// onClick={() => onSendToPlus(plusSubmitEvent.id, false, plusSubmitEvent.validBox)}
// disabled={uploading.includes(plusSubmitEvent.id)}
// type="text"
// >
// This is a {plusSubmitEvent.label}
// </Button>
// </div>
// ) : (
// <div className="p-2 flex justify-start flex-row-reverse space-x-2">
// <Button
// className="ml-2"
// onClick={() => setState({ ...state, showPlusSubmit: false })}
// disabled={uploading.includes(plusSubmitEvent.id)}
// type="text"
// >
// {uploading.includes(plusSubmitEvent.id) ? 'Close' : 'Cancel'}
// </Button>
// <Button
// className="ml-2"
// onClick={() => onSendToPlus(plusSubmitEvent.id, false, plusSubmitEvent.validBox)}
// disabled={uploading.includes(plusSubmitEvent.id)}
// type="text"
// >
// Submit to Frigate+
// </Button>
// </div>
// )}
// </>
// ) : (
// <>
// <div className="p-4">
// <Heading size="lg">Setup a Frigate+ Account</Heading>
// <p className="mb-2">In order to submit images to Frigate+, you first need to setup an account.</p>
// <a
// className="text-blue-500 hover:underline"
// href="https://plus.frigate.video"
// target="_blank"
// rel="noopener noreferrer"
// >
// https://plus.frigate.video
// </a>
// </div>
// <div className="p-2 flex justify-start flex-row-reverse space-x-2">
// <Button className="ml-2" onClick={() => setState({ ...state, showPlusSubmit: false })} type="text">
// Close
// </Button>
// </div>
// </>
// )}
// </Dialog>
// )}
// {deleteFavoriteState.showDeleteFavorite && (
// <Dialog>
// <div className="p-4">
// <Heading size="lg">Delete Saved Event?</Heading>
// <p className="mb-2">Confirm deletion of saved event.</p>
// </div>
// <div className="p-2 flex justify-start flex-row-reverse space-x-2">
// <Button
// className="ml-2"
// onClick={() => setDeleteFavoriteState({ ...state, showDeleteFavorite: false })}
// type="text"
// >
// Cancel
// </Button>
// <Button
// className="ml-2"
// color="red"
// onClick={(e) => {
// setDeleteFavoriteState({ ...state, showDeleteFavorite: false });
// onDelete(e, deleteFavoriteState.deletingFavoriteEventId, false);
// }}
// type="text"
// >
// Delete
// </Button>
// </div>
// </Dialog>
// )}
// <div className="space-y-2">
// {ongoingEvents ? (
// <div>
// <div className="flex">
// <Heading className="py-4" size="sm">
// Ongoing Events
// </Heading>
// <Button
// className="rounded-full"
// type="text"
// color="gray"
// aria-label="Events for currently tracked objects. Recordings are only saved based on your retain settings. See the recording docs for more info."
// >
// <About className="w-5" />
// </Button>
// <Button
// className="rounded-full ml-auto"
// type="iconOnly"
// color="blue"
// onClick={() => setShowInProgress(!showInProgress)}
// >
// {showInProgress ? <MenuOpen className="w-6" /> : <MenuIcon className="w-6" />}
// </Button>
// </div>
// {showInProgress &&
// ongoingEvents.map((event, _) => {
// return (
// <Event
// className="my-2"
// key={event.id}
// config={config}
// event={event}
// eventDetailType={eventDetailType}
// eventOverlay={eventOverlay}
// viewEvent={viewEvent}
// setViewEvent={setViewEvent}
// uploading={uploading}
// handleEventDetailTabChange={handleEventDetailTabChange}
// onEventFrameSelected={onEventFrameSelected}
// onDelete={onDelete}
// onDispose={() => {
// this.player = null;
// }}
// onDownloadClick={onDownloadClick}
// onReady={(player) => {
// this.player = player;
// this.player.on('playing', () => {
// setEventOverlay(undefined);
// });
// }}
// onSave={onSave}
// showSubmitToPlus={showSubmitToPlus}
// />
// );
// })}
// </div>
// ) : null}
// <Heading className="py-4" size="sm">
// Past Events
// </Heading>
// {eventPages ? (
// eventPages.map((page, i) => {
// const lastPage = eventPages.length === i + 1;
// return page.map((event, j) => {
// const lastEvent = lastPage && page.length === j + 1;
// return (
// <Event
// key={event.id}
// config={config}
// event={event}
// eventDetailType={eventDetailType}
// eventOverlay={eventOverlay}
// viewEvent={viewEvent}
// setViewEvent={setViewEvent}
// lastEvent={lastEvent}
// lastEventRef={lastEventRef}
// uploading={uploading}
// handleEventDetailTabChange={handleEventDetailTabChange}
// onEventFrameSelected={onEventFrameSelected}
// onDelete={onDelete}
// onDispose={() => {
// this.player = null;
// }}
// onDownloadClick={onDownloadClick}
// onReady={(player) => {
// this.player = player;
// this.player.on('playing', () => {
// setEventOverlay(undefined);
// });
// }}
// onSave={onSave}
// showSubmitToPlus={showSubmitToPlus}
// />
// );
// });
// })
// ) : (
// <ActivityIndicator />
// )}
// </div>
// <div>{isDone ? null : <ActivityIndicator />}</div>
// </div>
// );
// }
// function Event({
// className = '',
// config,
// event,
// eventDetailType,
// eventOverlay,
// viewEvent,
// setViewEvent,
// lastEvent,
// lastEventRef,
// uploading,
// handleEventDetailTabChange,
// onEventFrameSelected,
// onDelete,
// onDispose,
// onDownloadClick,
// onReady,
// onSave,
// showSubmitToPlus,
// }) {
// const apiHost = useApiHost();
// return (
// <div className={className}>
// <div
// ref={lastEvent ? lastEventRef : false}
// className="flex bg-slate-100 dark:bg-slate-800 rounded cursor-pointer min-w-[330px]"
// onClick={() => (viewEvent === event.id ? setViewEvent(null) : setViewEvent(event.id))}
// >
// <div
// className="relative rounded-l flex-initial min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center"
// style={{
// 'background-image': `url(${apiHost}api/events/${event.id}/thumbnail.jpg)`,
// }}
// >
// <StarRecording
// className="h-6 w-6 text-yellow-300 absolute top-1 right-1 cursor-pointer"
// onClick={(e) => onSave(e, event.id, !event.retain_indefinitely)}
// fill={event.retain_indefinitely ? 'currentColor' : 'none'}
// />
// {event.end_time ? null : (
// <div className="bg-slate-300 dark:bg-slate-700 absolute bottom-0 text-center w-full uppercase text-sm rounded-bl">
// In progress
// </div>
// )}
// </div>
// <div className="m-2 flex grow">
// <div className="flex flex-col grow">
// <div className="capitalize text-lg font-bold">
// {event.label.replaceAll('_', ' ')}
// {event.sub_label ? `: ${event.sub_label.replaceAll('_', ' ')}` : null}
// </div>
// <div className="text-sm flex">
// <Clock className="h-5 w-5 mr-2 inline" />
// {formatUnixTimestampToDateTime(event.start_time, { ...config.ui })}
// <div className="hidden sm:inline">
// <span className="m-1">-</span>
// <TimeAgo time={event.start_time * 1000} dense />
// </div>
// <div className="hidden sm:inline">
// <span className="m-1" />( {getDurationFromTimestamps(event.start_time, event.end_time)} )
// </div>
// </div>
// <div className="capitalize text-sm flex align-center mt-1">
// <Camera className="h-5 w-5 mr-2 inline" />
// {event.camera.replaceAll('_', ' ')}
// </div>
// {event.zones.length ? (
// <div className="capitalize text-sm flex align-center">
// <Zone className="w-5 h-5 mr-2 inline" />
// {event.zones.join(', ').replaceAll('_', ' ')}
// </div>
// ) : null}
// <div className="capitalize text-sm flex align-center">
// <Score className="w-5 h-5 mr-2 inline" />
// {(event?.data?.top_score || event.top_score || 0) == 0
// ? null
// : `${event.label}: ${((event?.data?.top_score || event.top_score) * 100).toFixed(0)}%`}
// {(event?.data?.sub_label_score || 0) == 0
// ? null
// : `, ${event.sub_label}: ${(event?.data?.sub_label_score * 100).toFixed(0)}%`}
// </div>
// </div>
// <div class="hidden sm:flex flex-col justify-end mr-2">
// {event.end_time && event.has_snapshot && (event?.data?.type || 'object') == 'object' && (
// <Fragment>
// {event.plus_id ? (
// <div className="uppercase text-xs underline">
// <Link
// href={`https://plus.frigate.video/dashboard/edit-image/?id=${event.plus_id}`}
// target="_blank"
// rel="nofollow"
// >
// Edit in Frigate+
// </Link>
// </div>
// ) : (
// <Button
// color="gray"
// disabled={uploading.includes(event.id)}
// onClick={(e) => showSubmitToPlus(event.id, event.label, event?.data?.box || event.box, e)}
// >
// {uploading.includes(event.id) ? 'Uploading...' : 'Send to Frigate+'}
// </Button>
// )}
// </Fragment>
// )}
// </div>
// <div class="flex flex-col">
// <Delete
// className="h-6 w-6 cursor-pointer"
// stroke="#f87171"
// onClick={(e) => onDelete(e, event.id, event.retain_indefinitely)}
// />
// <Download
// className="h-6 w-6 mt-auto"
// stroke={event.has_clip || event.has_snapshot ? '#3b82f6' : '#cbd5e1'}
// onClick={(e) => onDownloadClick(e, event)}
// />
// </div>
// </div>
// </div>
// {viewEvent !== event.id ? null : (
// <div className="space-y-4">
// <div className="mx-auto max-w-7xl">
// <div className="flex justify-center w-full py-2">
// <Tabs
// selectedIndex={event.has_clip && eventDetailType == 'clip' ? 0 : 1}
// onChange={handleEventDetailTabChange}
// className="justify"
// >
// <TextTab text="Clip" disabled={!event.has_clip} />
// <TextTab text={event.has_snapshot ? 'Snapshot' : 'Thumbnail'} />
// </Tabs>
// </div>
// <div>
// {eventDetailType == 'clip' && event.has_clip ? (
// <div>
// <TimelineSummary
// event={event}
// onFrameSelected={(frame, seekSeconds) => onEventFrameSelected(event, frame, seekSeconds)}
// />
// <div>
// <VideoPlayer
// options={{
// preload: 'auto',
// autoplay: true,
// sources: [
// {
// src: `${apiHost}vod/event/${event.id}/master.m3u8`,
// type: 'application/vnd.apple.mpegurl',
// },
// ],
// }}
// seekOptions={{ forward: 10, backward: 5 }}
// onReady={onReady}
// onDispose={onDispose}
// >
// {eventOverlay ? (
// <TimelineEventOverlay eventOverlay={eventOverlay} cameraConfig={config.cameras[event.camera]} />
// ) : null}
// </VideoPlayer>
// </div>
// </div>
// ) : null}
// {eventDetailType == 'image' || !event.has_clip ? (
// <div className="flex justify-center">
// <img
// className="flex-grow-0"
// src={
// event.has_snapshot
// ? `${apiHost}api/events/${event.id}/snapshot.jpg?bbox=1`
// : `${apiHost}api/events/${event.id}/thumbnail.jpg`
// }
// alt={`${event.label} at ${((event?.data?.top_score || event.top_score) * 100).toFixed(
// 0
// )}% confidence`}
// />
// </div>
// ) : null}
// </div>
// </div>
// </div>
// )}
// </div>
// );
// }

View File

@ -2,7 +2,7 @@ import React, { useCallback, useContext, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Context } from '..'; import { Context } from '..';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { frigateApi, frigateQueryKeys, mapHostToHostname } from '../services/frigate.proxy/frigate.api'; import { frigateApi, frigateQueryKeys, mapHostToHostname, proxyApi } from '../services/frigate.proxy/frigate.api';
import { Button, Flex, Text, useMantineTheme } from '@mantine/core'; import { Button, Flex, Text, useMantineTheme } from '@mantine/core';
import { useClipboard } from '@mantine/hooks'; import { useClipboard } from '@mantine/hooks';
import { configureMonacoYaml } from "monaco-yaml"; import { configureMonacoYaml } from "monaco-yaml";
@ -21,7 +21,7 @@ const HostConfigPage = () => {
queryFn: async () => { queryFn: async () => {
const host = await frigateApi.getHost(id || '') const host = await frigateApi.getHost(id || '')
const hostName = mapHostToHostname(host) const hostName = mapHostToHostname(host)
return frigateApi.getHostConfigRaw(hostName) return proxyApi.getHostConfigRaw(hostName)
}, },
}) })

View File

@ -1,4 +1,4 @@
import React, { Fragment, useContext, useEffect } from 'react'; import React, { useContext, useEffect } from 'react';
import { Context } from '..'; import { Context } from '..';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
@ -8,7 +8,6 @@ import CenterLoader from '../shared/components/CenterLoader';
import RetryError from './RetryError'; import RetryError from './RetryError';
import Player from '../shared/components/frigate/Player'; import Player from '../shared/components/frigate/Player';
import { Flex } from '@mantine/core'; import { Flex } from '@mantine/core';
import JSMpegPlayer from '../shared/components/frigate/JSMpegPlayer';
const LiveCameraPage = observer(() => { const LiveCameraPage = observer(() => {
let { id: cameraId } = useParams<'id'>() let { id: cameraId } = useParams<'id'>()
@ -31,15 +30,9 @@ const LiveCameraPage = observer(() => {
if (isError) return <RetryError onRetry={refetch} /> if (isError) return <RetryError onRetry={refetch} />
// const hostNameWPort = camera.frigateHost ? new URL(camera.frigateHost.host).host : ''
// const wsUrl = frigateApi.cameraWsURL(hostNameWPort, camera.name)
return ( return (
<Flex w='100%' h='100%' justify='center'> <Flex w='100%' h='100%' justify='center'>
<Player camera={camera} /> <Player camera={camera} />
{/* <JSMpegPlayer key={wsUrl} wsUrl={wsUrl}/> */}
{/* {JSON.stringify(camera)} */}
{/* {cameraWsURL} */}
</Flex> </Flex>
); );
}) })

View File

@ -7,10 +7,8 @@ import { Context } from '..';
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import CenterLoader from '../shared/components/CenterLoader'; import CenterLoader from '../shared/components/CenterLoader';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { frigateApi, frigateQueryKeys, mapHostToHostname } from '../services/frigate.proxy/frigate.api'; import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
import RetryError from './RetryError'; import RetryError from './RetryError';
import { FrigateConfig } from '../types/frigateConfig';
import { GetFrigateHostWConfig } from '../services/frigate.proxy/frigate.schema';
import CameraCard from '../shared/components/CameraCard'; import CameraCard from '../shared/components/CameraCard';
const MainBody = observer(() => { const MainBody = observer(() => {
@ -41,7 +39,8 @@ const MainBody = observer(() => {
<CameraCard <CameraCard
key={camera.id} key={camera.id}
camera={camera} camera={camera}
/>)) />)
)
} }
return ( return (
@ -67,7 +66,6 @@ const MainBody = observer(() => {
<Grid mt='sm' justify="center" mb='sm' align='stretch'> <Grid mt='sm' justify="center" mb='sm' align='stretch'>
{cards()} {cards()}
</Grid> </Grid>
))
</Flex> </Flex>
</Flex> </Flex>
); );

View File

@ -10,7 +10,7 @@ interface RetryErrorProps {
onRetry?: () => void onRetry?: () => void
} }
const RetryError = ( {onRetry} : RetryErrorProps) => { const RetryError = ({ onRetry }: RetryErrorProps) => {
const navigate = useNavigate() const navigate = useNavigate()
const { sideBarsStore } = useContext(Context) const { sideBarsStore } = useContext(Context)
@ -25,7 +25,11 @@ const RetryError = ( {onRetry} : RetryErrorProps) => {
navigate(routesPath.MAIN_PATH) navigate(routesPath.MAIN_PATH)
} }
function handleRetry(event: React.MouseEvent<HTMLButtonElement, MouseEvent>): void { const handleGoBack = () => {
navigate(-1)
}
const handleRetry = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
if (onRetry) onRetry() if (onRetry) onRetry()
else window.location.reload() else window.location.reload()
} }
@ -35,8 +39,11 @@ const RetryError = ( {onRetry} : RetryErrorProps) => {
<Text fz='lg' fw={700}>{strings.errors.somthengGoesWrong}</Text> <Text fz='lg' fw={700}>{strings.errors.somthengGoesWrong}</Text>
{ExclamationCogWheel} {ExclamationCogWheel}
<Text fz='lg' fw={700}>{strings.youCanRetryOrGoToMain}</Text> <Text fz='lg' fw={700}>{strings.youCanRetryOrGoToMain}</Text>
<Button onClick={handleRetry}>{strings.retry}</Button> <Flex>
<Button onClick={handleGoToMain}>{strings.goToMainPage}</Button> <Button ml='1rem' onClick={handleRetry}>{strings.retry}</Button>
<Button ml='1rem' onClick={handleGoBack}>{strings.back}</Button>
<Button ml='1rem' onClick={handleGoToMain}>{strings.goToMainPage}</Button>
</Flex>
</Flex> </Flex>
); );
}; };

View File

@ -3,35 +3,46 @@ import { proxyURL } from "../../shared/env.const"
import { z } from "zod" import { z } from "zod"
import { GetConfig, DeleteFrigateHost, GetFrigateHost, PutConfig, PutFrigateHost, GetFrigateHostWithCameras, GetCameraWHost, GetCameraWHostWConfig } from "./frigate.schema"; import { GetConfig, DeleteFrigateHost, GetFrigateHost, PutConfig, PutFrigateHost, GetFrigateHostWithCameras, GetCameraWHost, GetCameraWHostWConfig } from "./frigate.schema";
import { FrigateConfig } from "../../types/frigateConfig"; import { FrigateConfig } from "../../types/frigateConfig";
import { url } from "inspector";
const instance = axios.create({ const instanceApi = axios.create({
baseURL: proxyURL.toString(), baseURL: proxyURL.toString(),
timeout: 30000, timeout: 30000,
}); });
export const frigateApi = { export const frigateApi = {
getConfig: () => instance.get<GetConfig[]>('apiv1/config').then(res => res.data), getConfig: () => instanceApi.get<GetConfig[]>('apiv1/config').then(res => res.data),
putConfig: (config: PutConfig[]) => instance.put('apiv1/config', config).then(res => res.data), putConfig: (config: PutConfig[]) => instanceApi.put('apiv1/config', config).then(res => res.data),
getHosts: () => instance.get<GetFrigateHost[]>('apiv1/frigate-hosts').then(res => { getHosts: () => instanceApi.get<GetFrigateHost[]>('apiv1/frigate-hosts').then(res => {
return res.data return res.data
}), }),
getHostWithCameras: () => instance.get<GetFrigateHostWithCameras[]>('apiv1/frigate-hosts', { params: { include: 'cameras'}}).then(res => { getHostWithCameras: () => instanceApi.get<GetFrigateHostWithCameras[]>('apiv1/frigate-hosts', { params: { include: 'cameras' } }).then(res => {
return res.data return res.data
}), }),
getHost: (id: string) => instance.get<GetFrigateHostWithCameras>(`apiv1/frigate-hosts/${id}`).then(res => { getHost: (id: string) => instanceApi.get<GetFrigateHostWithCameras>(`apiv1/frigate-hosts/${id}`).then(res => {
return res.data return res.data
}), }),
getCamerasWHost: () => instance.get<GetCameraWHostWConfig[]>(`apiv1/cameras`).then(res => {return res.data}), getCamerasWHost: () => instanceApi.get<GetCameraWHostWConfig[]>(`apiv1/cameras`).then(res => { return res.data }),
getCameraWHost: (id: string) => instance.get<GetCameraWHostWConfig>(`apiv1/cameras/${id}`).then(res => {return res.data}), getCameraWHost: (id: string) => instanceApi.get<GetCameraWHostWConfig>(`apiv1/cameras/${id}`).then(res => { return res.data }),
putHosts: (hosts: PutFrigateHost[]) => instance.put<GetFrigateHost[]>('apiv1/frigate-hosts', hosts).then(res => { putHosts: (hosts: PutFrigateHost[]) => instanceApi.put<GetFrigateHost[]>('apiv1/frigate-hosts', hosts).then(res => {
return res.data return res.data
}), }),
deleteHosts: (hosts: DeleteFrigateHost[]) => instance.delete<GetFrigateHost[]>('apiv1/frigate-hosts', { data: hosts }).then(res => { deleteHosts: (hosts: DeleteFrigateHost[]) => instanceApi.delete<GetFrigateHost[]>('apiv1/frigate-hosts', { data: hosts }).then(res => {
return res.data return res.data
}), }),
getHostConfigRaw: (hostName: string) => instance.get('proxy/api/config/raw', { params: { hostName: hostName } }).then(res => res.data), }
getHostConfig: (hostName: string) => instance.get('proxy/api/config', { params: { hostName: hostName } }).then(res => res.data),
export const proxyApi = {
getHostConfigRaw: (hostName: string) => instanceApi.get('proxy/api/config/raw', { params: { hostName: hostName } }).then(res => res.data),
getHostConfig: (hostName: string) => instanceApi.get('proxy/api/config', { params: { hostName: hostName } }).then(res => res.data),
getImageFrigate: async (imageUrl: string) => {
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.blob();
},
cameraWsURL: (hostName: string, cameraName: string) => { cameraWsURL: (hostName: string, cameraName: string) => {
return `ws://${proxyURL.host}/proxy-ws/live/jsmpeg/${cameraName}?hostName=${hostName}` return `ws://${proxyURL.host}/proxy-ws/live/jsmpeg/${cameraName}?hostName=${hostName}`
}, },

View File

@ -5,7 +5,8 @@ import AutoUpdatingCameraImage from './frigate/AutoUpdatingCameraImage';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { routesPath } from '../../router/routes.path'; import { routesPath } from '../../router/routes.path';
import { GetCameraWHostWConfig, GetFrigateHost } from '../../services/frigate.proxy/frigate.schema'; import { GetCameraWHostWConfig, GetFrigateHost } from '../../services/frigate.proxy/frigate.schema';
import { frigateApi, mapHostToHostname } from '../../services/frigate.proxy/frigate.api'; import { frigateApi, mapHostToHostname, proxyApi } from '../../services/frigate.proxy/frigate.api';
import AutoUpdatedImage from './frigate/CameraImage';
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
@ -18,6 +19,12 @@ const useStyles = createStyles((theme) => ({
backgroundColor: theme.colorScheme === 'dark' ? theme.fn.darken(theme.colors.cyan[9], 0.5) : theme.colors.cyan[1], backgroundColor: theme.colorScheme === 'dark' ? theme.fn.darken(theme.colors.cyan[9], 0.5) : theme.colors.cyan[1],
}, },
}, },
cameraImage: {
width: '100%',
height: '100%',
alignSelf: 'center',
justifyContent: 'center'
},
bottomGroup: { bottomGroup: {
marginTop: 'auto', marginTop: 'auto',
}, },
@ -36,8 +43,7 @@ const CameraCard = ({
}: CameraCardProps) => { }: CameraCardProps) => {
const { classes } = useStyles(); const { classes } = useStyles();
const navigate = useNavigate() const navigate = useNavigate()
const imageUrl = camera.frigateHost ? frigateApi.cameraImageURL(mapHostToHostname(camera.frigateHost), camera.name) : '' //todo implement get URL from live cameras const imageUrl = camera.frigateHost ? proxyApi.cameraImageURL(mapHostToHostname(camera.frigateHost), camera.name) : '' //todo implement get URL from live cameras
const handleOpenLiveView = () => { const handleOpenLiveView = () => {
const url = routesPath.LIVE_PATH.replace(':id', camera.id) const url = routesPath.LIVE_PATH.replace(':id', camera.id)
@ -52,14 +58,10 @@ const CameraCard = ({
return ( return (
<Grid.Col md={6} lg={3} p='0.2rem'> <Grid.Col md={6} lg={3} p='0.2rem'>
<Card h='100%' radius="lg" padding='0.5rem' className={classes.mainCard}> <Card h='100%' radius="lg" padding='0.5rem' className={classes.mainCard}>
{/* <Card maw='25rem' mah='25rem' mih='15rem' miw='15rem'> */}
<Text align='center' size='md' className={classes.headText} >{camera.name} / {camera.frigateHost?.name}</Text> <Text align='center' size='md' className={classes.headText} >{camera.name} / {camera.frigateHost?.name}</Text>
<AutoUpdatingCameraImage <Flex direction='column' className={classes.cameraImage}>
onClick={handleOpenLiveView} <AutoUpdatedImage onClick={handleOpenLiveView} enabled={camera.config?.enabled} imageUrl={imageUrl} />
cameraConfig={camera.config} </Flex>
url={imageUrl}
showFps={false}
/>
<Group <Group
className={classes.bottomGroup}> className={classes.bottomGroup}>
<Flex justify='space-evenly' mt='0.5rem' w='100%'> <Flex justify='space-evenly' mt='0.5rem' w='100%'>

View File

@ -1,6 +1,6 @@
import { DEFAULT_THEME, Loader, LoadingOverlay } from '@mantine/core'; import { DEFAULT_THEME, Loader, LoadingOverlay } from '@mantine/core';
import React from 'react'; import React from 'react';
import СogwheelSVG from './svg/СogwheelSVG'; import СogwheelSVG from './svg/CogwheelSVG';
const CenterLoader = () => { const CenterLoader = () => {
return <LoadingOverlay loader={СogwheelSVG} visible />; return <LoadingOverlay loader={СogwheelSVG} visible />;

View File

@ -1,14 +1,14 @@
import React from 'react'; import React from 'react';
import { Center, DEFAULT_THEME } from '@mantine/core'; import { Center, DEFAULT_THEME } from '@mantine/core';
import СogwheelSVG from './svg/СogwheelSVG'; import CogwheelSVG from './svg/CogwheelSVG';
const СogwheelLoader = () => { const CogwheelLoader = () => {
return ( return (
<Center> <Center>
{СogwheelSVG} {CogwheelSVG}
</Center> </Center>
); );
}; };
export default СogwheelLoader; export default CogwheelLoader;

View File

@ -7,7 +7,7 @@ import { Modal, createStyles, getStylesRef, rem, Text, Box, Flex, Grid, Divider,
import { useMediaQuery } from '@mantine/hooks'; import { useMediaQuery } from '@mantine/hooks';
import { dimensions } from '../dimensions/dimensions'; import { dimensions } from '../dimensions/dimensions';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import KomponentLoader from './СogwheelLoader'; import KomponentLoader from './CogwheelLoader';
import ProductParameter from './ProductParameter'; import ProductParameter from './ProductParameter';
import { productString } from '../strings/product.strings'; import { productString } from '../strings/product.strings';
import { IconArrowBadgeLeft, IconArrowBadgeRight } from '@tabler/icons-react'; import { IconArrowBadgeLeft, IconArrowBadgeRight } from '@tabler/icons-react';

View File

@ -1,6 +1,6 @@
import { Box, LoadingOverlay } from '@mantine/core'; import { Box, LoadingOverlay } from '@mantine/core';
import React from 'react'; import React from 'react';
import СogwheelSVG from './svg/СogwheelSVG'; import СogwheelSVG from './svg/CogwheelSVG';
const SideBarLoader = () => { const SideBarLoader = () => {
return ( return (

View File

@ -12,6 +12,7 @@ interface AutoUpdatingCameraImageProps extends React.ImgHTMLAttributes<HTMLImage
url: string url: string
}; };
// TODO Delete
export default function AutoUpdatingCameraImage({ export default function AutoUpdatingCameraImage({
cameraConfig, cameraConfig,
searchParams = "", searchParams = "",
@ -82,13 +83,13 @@ export default function AutoUpdatingCameraImage({
return ( return (
// <AspectRatio ratio={1}> // <AspectRatio ratio={1}>
<Flex direction='column' h='100%'> <Flex direction='column' h='100%'>
<CameraImage {/* <CameraImage
cameraConfig={cameraConfig} cameraConfig={cameraConfig}
onload={handleLoad} onload={handleLoad}
enabled={cameraConfig?.enabled} enabled={cameraConfig?.enabled}
url={url} url={url}
{...rest} {...rest}
/> /> */}
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null} {showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
</Flex> </Flex>
// </AspectRatio > // </AspectRatio >

View File

@ -0,0 +1,115 @@
import Tooltip from './Tooltip';
import { Fragment, useCallback, useRef, useState } from 'react';
const ButtonColors = {
blue: {
contained: 'bg-blue-500 focus:bg-blue-400 active:bg-blue-600 ring-blue-300',
outlined:
'text-blue-500 border-2 border-blue-500 hover:bg-blue-500 hover:bg-opacity-20 focus:bg-blue-500 focus:bg-opacity-40 active:bg-blue-500 active:bg-opacity-40',
text: 'text-blue-500 hover:bg-blue-500 hover:bg-opacity-20 focus:bg-blue-500 focus:bg-opacity-40 active:bg-blue-500 active:bg-opacity-40',
iconOnly: 'text-blue-500 hover:text-blue-200',
},
red: {
contained: 'bg-red-500 focus:bg-red-400 active:bg-red-600 ring-red-300',
outlined:
'text-red-500 border-2 border-red-500 hover:bg-red-500 hover:bg-opacity-20 focus:bg-red-500 focus:bg-opacity-40 active:bg-red-500 active:bg-opacity-40',
text: 'text-red-500 hover:bg-red-500 hover:bg-opacity-20 focus:bg-red-500 focus:bg-opacity-40 active:bg-red-500 active:bg-opacity-40',
iconOnly: 'text-red-500 hover:text-red-200',
},
yellow: {
contained: 'bg-yellow-500 focus:bg-yellow-400 active:bg-yellow-600 ring-yellow-300',
outlined:
'text-yellow-500 border-2 border-yellow-500 hover:bg-yellow-500 hover:bg-opacity-20 focus:bg-yellow-500 focus:bg-opacity-40 active:bg-yellow-500 active:bg-opacity-40',
text: 'text-yellow-500 hover:bg-yellow-500 hover:bg-opacity-20 focus:bg-yellow-500 focus:bg-opacity-40 active:bg-yellow-500 active:bg-opacity-40',
iconOnly: 'text-yellow-500 hover:text-yellow-200',
},
green: {
contained: 'bg-green-500 focus:bg-green-400 active:bg-green-600 ring-green-300',
outlined:
'text-green-500 border-2 border-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40',
text: 'text-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40',
iconOnly: 'text-green-500 hover:text-green-200',
},
gray: {
contained: 'bg-gray-500 focus:bg-gray-400 active:bg-gray-600 ring-gray-300',
outlined:
'text-gray-500 border-2 border-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
text: 'text-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
iconOnly: 'text-gray-500 hover:text-gray-200',
},
disabled: {
contained: 'bg-gray-400',
outlined:
'text-gray-500 border-2 border-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
text: 'text-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
iconOnly: 'text-gray-500 hover:text-gray-200',
},
black: {
contained: '',
outlined: '',
text: 'text-black dark:text-white',
iconOnly: '',
},
};
const ButtonTypes = {
contained: 'text-white shadow focus:shadow-xl hover:shadow-md',
outlined: '',
text: 'transition-opacity',
iconOnly: 'transition-opacity',
};
export default function Button({
children,
className = '',
color = 'blue',
disabled = false,
ariaCapitalize = false,
href,
target,
type = 'contained',
...attrs
}) {
const [hovered, setHovered] = useState(false);
const ref = useRef();
let classes = `whitespace-nowrap flex items-center space-x-1 ${className} ${ButtonTypes[type]} ${
ButtonColors[disabled ? 'disabled' : color][type]
} font-sans inline-flex font-bold uppercase text-xs px-1.5 md:px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${
disabled ? 'cursor-not-allowed' : `${type == 'iconOnly' ? '' : 'focus:ring-2'} cursor-pointer`
}`;
if (disabled) {
classes = classes.replace(/(?:focus|active|hover):[^ ]+/g, '');
}
const handleMousenter = useCallback(() => {
setHovered(true);
}, []);
const handleMouseleave = useCallback(() => {
setHovered(false);
}, []);
const Element = href ? 'a' : 'div';
return (
<Fragment>
<Element
role="button"
aria-disabled={disabled ? 'true' : 'false'}
tabindex="0"
className={classes}
href={href}
target={target}
ref={ref}
onmouseenter={handleMousenter}
onmouseleave={handleMouseleave}
{...attrs}
>
{children}
</Element>
{hovered && attrs['aria-label'] ? <Tooltip text={attrs['aria-label']} relativeTo={ref} capitalize={ariaCapitalize} /> : null}
</Fragment>
);
}

View File

@ -1,51 +1,63 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { CameraConfig } from "../../../types/frigateConfig"; import { CameraConfig } from "../../../types/frigateConfig";
import { AspectRatio, Flex, createStyles, Text } from "@mantine/core"; import { AspectRatio, Flex, createStyles, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import CenterLoader from "../CenterLoader";
import axios from "axios";
import { frigateApi, proxyApi } from "../../../services/frigate.proxy/frigate.api";
interface CameraImageProps extends React.ImgHTMLAttributes<HTMLImageElement> { interface CameraImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
className?: string; className?: string;
cameraConfig?: CameraConfig; cameraConfig?: CameraConfig;
onload?: () => void; onload?: () => void;
url: string, imageUrl: string;
enabled?: boolean enabled?: boolean;
}; }
const useStyles = createStyles((theme) => ({ const AutoUpdatedImage = ({
imageUrl,
}))
export default function CameraImage({
className,
cameraConfig,
onload,
enabled, enabled,
url, ...rest }: CameraImageProps) { ...rest
const imgRef = useRef<HTMLImageElement | null>(null); }: CameraImageProps) => {
const { classes } = useStyles(); const { data: imageBlob, refetch, isPending, isError } = useQuery({
queryKey: ['image', imageUrl],
queryFn: () => proxyApi.getImageFrigate(imageUrl),
staleTime: 60 * 1000,
gcTime: Infinity,
refetchInterval: 60 * 1000,
});
useEffect(() => { useEffect(() => {
if (!cameraConfig || !imgRef.current) { const intervalId = setInterval(() => {
return; refetch();
} }, 60 * 1000);
imgRef.current.src = url
}, [imgRef]); return () => clearInterval(intervalId);
}, [refetch]);
if (isPending) return <CenterLoader />
if (isError) return (
<Flex direction="column" justify="center" h="100%">
<Text align="center">Error loading!</Text>
</Flex>
)
if (!imageBlob || !(imageBlob instanceof Blob)) console.error('imageBlob not Blob object:', imageBlob)
const image = URL.createObjectURL(imageBlob!)
return ( return (
<Flex direction='column' justify='center' h='100%'> <>
{enabled ? ( {enabled ? <img src={image} alt="Dynamic Content" {...rest}/>
<AspectRatio ratio={1.5}> :
<img <Flex direction="column" justify="center" h="100%">
ref={imgRef} <Text align="center">Camera is disabled in config, no stream or snapshot available!</Text>
{...rest}
/>
</AspectRatio>
) : (
<Text align='center'>
Camera is disabled in config, no stream or snapshot available!
</Text>
)}
</Flex> </Flex>
); }
} </>)
};
export default AutoUpdatedImage

View File

@ -0,0 +1,35 @@
import { h, Fragment } from 'preact';
import { createPortal } from 'preact/compat';
import { useState, useEffect } from 'preact/hooks';
export default function Dialog({ children, portalRootID = 'dialogs' }) {
const portalRoot = portalRootID && document.getElementById(portalRootID);
const [show, setShow] = useState(false);
useEffect(() => {
window.requestAnimationFrame(() => {
setShow(true);
});
}, []);
const dialog = (
<Fragment>
<div
data-testid="scrim"
key="scrim"
className="fixed bg-fixed inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
>
<div
role="modal"
className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 sm:max-w-sm md:max-w-md lg:max-w-lg text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
show ? 'scale-100 opacity-100' : ''
}`}
>
{children}
</div>
</div>
</Fragment>
);
return portalRoot ? createPortal(dialog, portalRoot) : dialog;
}

View File

@ -0,0 +1,16 @@
import { h } from 'preact';
import { Link as RouterLink } from 'preact-router/match';
export default function Link({
activeClassName = '',
className = 'text-blue-500 hover:underline',
children,
href,
...props
}) {
return (
<RouterLink activeClassName={activeClassName} className={className} href={href} {...props}>
{children}
</RouterLink>
);
}

View File

@ -0,0 +1,48 @@
import { h } from 'preact';
import RelativeModal from './RelativeModal';
import { useCallback } from 'preact/hooks';
export default function Menu({ className, children, onDismiss, relativeTo, widthRelative }) {
return relativeTo ? (
<RelativeModal
children={children}
className={`${className || ''} py-2`}
role="listbox"
onDismiss={onDismiss}
portalRootID="menus"
relativeTo={relativeTo}
widthRelative={widthRelative}
/>
) : null;
}
export function MenuItem({ focus, icon: Icon, label, href, onSelect, value, ...attrs }) {
const handleClick = useCallback(() => {
onSelect && onSelect(value, label);
}, [onSelect, value, label]);
const Element = href ? 'a' : 'div';
return (
<Element
className={`flex space-x-2 p-2 px-5 hover:bg-gray-200 dark:hover:bg-gray-800 dark:hover:text-white cursor-pointer ${
focus ? 'bg-gray-200 dark:bg-gray-800 dark:text-white' : ''
}`}
href={href}
onClick={handleClick}
role="option"
{...attrs}
>
{Icon ? (
<div className="w-6 h-6 self-center mr-4 text-gray-500 flex-shrink-0">
<Icon />
</div>
) : null}
<div className="whitespace-nowrap">{label}</div>
</Element>
);
}
export function MenuSeparator() {
return <div className="border-b border-gray-200 dark:border-gray-800 my-2" />;
}

View File

@ -0,0 +1,70 @@
import { h } from 'preact';
import { useRef, useState } from 'preact/hooks';
import Menu from './Menu';
import { ArrowDropdown } from '../icons/ArrowDropdown';
import Heading from './Heading';
import Button from './Button';
import SelectOnlyIcon from '../icons/SelectOnly';
export default function MultiSelect({ className, title, options, selection, onToggle, onShowAll, onSelectSingle }) {
const popupRef = useRef(null);
const [state, setState] = useState({
showMenu: false,
});
const isOptionSelected = (item) => {
return selection == 'all' || selection.split(',').indexOf(item) > -1;
};
const menuHeight = Math.round(window.innerHeight * 0.55);
return (
<div className={`${className} p-2`} ref={popupRef}>
<div className="flex justify-between min-w-[120px]" onClick={() => setState({ showMenu: true })}>
<label>{title}</label>
<ArrowDropdown className="w-6" />
</div>
{state.showMenu ? (
<Menu
className={`max-h-[${menuHeight}px] overflow-auto`}
relativeTo={popupRef}
onDismiss={() => setState({ showMenu: false })}
>
<div className="flex flex-wrap justify-between items-center">
<Heading className="p-4 justify-center" size="md">
{title}
</Heading>
<Button tabindex="false" className="mx-4" onClick={() => onShowAll()}>
Show All
</Button>
</div>
{options.map((item) => (
<div className="flex flex-grow" key={item}>
<label
className={`flex flex-shrink space-x-2 p-1 my-1 min-w-[176px] hover:bg-gray-200 dark:hover:bg-gray-800 dark:hover:text-white cursor-pointer capitalize text-sm`}
>
<input
className="mx-4 m-0 align-middle"
type="checkbox"
checked={isOptionSelected(item)}
onChange={() => onToggle(item)}
/>
{item.replaceAll('_', ' ')}
</label>
<div className="justify-right">
<Button
color={isOptionSelected(item) ? 'blue' : 'black'}
type="text"
className="max-h-[35px] mx-2"
onClick={() => onSelectSingle(item)}
>
{ ( <SelectOnlyIcon /> ) }
</Button>
</div>
</div>
))}
</Menu>
) : null}
</div>
);
}

View File

@ -7,7 +7,7 @@ import useCameraActivity from '../../../hooks/use-camera-activity';
import useCameraLiveMode from '../../../hooks/use-camera-live-mode'; import useCameraLiveMode from '../../../hooks/use-camera-live-mode';
import WebRtcPlayer from './WebRTCPlayer'; import WebRtcPlayer from './WebRTCPlayer';
import { Flex } from '@mantine/core'; import { Flex } from '@mantine/core';
import { frigateApi } from '../../../services/frigate.proxy/frigate.api'; import { frigateApi, proxyApi } from '../../../services/frigate.proxy/frigate.api';
import { GetCameraWHostWConfig } from '../../../services/frigate.proxy/frigate.schema'; import { GetCameraWHostWConfig } from '../../../services/frigate.proxy/frigate.schema';
type LivePlayerProps = { type LivePlayerProps = {
@ -24,7 +24,7 @@ const Player = ({
}: LivePlayerProps) => { }: LivePlayerProps) => {
const hostNameWPort = camera.frigateHost ? new URL(camera.frigateHost.host).host : '' const hostNameWPort = camera.frigateHost ? new URL(camera.frigateHost.host).host : ''
const wsUrl = frigateApi.cameraWsURL(hostNameWPort, camera.name) const wsUrl = proxyApi.cameraWsURL(hostNameWPort, camera.name)
const cameraConfig = camera.config! const cameraConfig = camera.config!
const { activeMotion, activeAudio, activeTracking } = const { activeMotion, activeAudio, activeTracking } =

View File

@ -0,0 +1,41 @@
import { h } from 'preact';
import { useCallback, useState } from 'preact/hooks';
export function Tabs({ children, selectedIndex: selectedIndexProp, onChange, className }) {
const [selectedIndex, setSelectedIndex] = useState(selectedIndexProp);
const handleSelected = useCallback(
(index) => () => {
setSelectedIndex(index);
onChange && onChange(index);
},
[onChange]
);
const RenderChildren = useCallback(() => {
return children.map((child, i) => {
child.props.selected = i === selectedIndex;
child.props.onClick = handleSelected(i);
return child;
});
}, [selectedIndex, children, handleSelected]);
return (
<div className={`flex ${className}`}>
<RenderChildren />
</div>
);
}
export function TextTab({ selected, text, onClick, disabled }) {
const selectedStyle = disabled
? 'text-gray-400 dark:text-gray-600 bg-transparent'
: selected
? 'text-white bg-blue-500 dark:text-black dark:bg-white'
: 'text-black dark:text-white bg-transparent';
return (
<button onClick={onClick} disabled={disabled} className={`rounded-full px-4 py-2 ${selectedStyle}`}>
<span>{text}</span>
</button>
);
}

View File

@ -0,0 +1,83 @@
import { FunctionComponent, useEffect, useMemo, useState } from 'react';
interface IProp {
/** The time to calculate time-ago from */
time: Date;
/** OPTIONAL: overwrite current time */
currentTime?: Date;
/** OPTIONAL: boolean that determines whether to show the time-ago text in dense format */
dense?: boolean;
/** OPTIONAL: set custom refresh interval in milliseconds, default 1000 (1 sec) */
refreshInterval?: number;
}
type TimeUnit = {
unit: string;
full: string;
value: number;
};
const timeAgo = ({ time, currentTime = new Date(), dense = false }: IProp): string => {
if (typeof time !== 'number' || time < 0) return 'Invalid Time Provided';
const pastTime: Date = new Date(time);
const elapsedTime: number = currentTime.getTime() - pastTime.getTime();
const timeUnits: TimeUnit[] = [
{ unit: 'yr', full: 'year', value: 31536000 },
{ unit: 'mo', full: 'month', value: 0 },
{ unit: 'd', full: 'day', value: 86400 },
{ unit: 'h', full: 'hour', value: 3600 },
{ unit: 'm', full: 'minute', value: 60 },
{ unit: 's', full: 'second', value: 1 },
];
const elapsed: number = elapsedTime / 1000;
if (elapsed < 10) {
return 'just now';
}
for (let i = 0; i < timeUnits.length; i++) {
// if months
if (i === 1) {
// Get the month and year for the time provided
const pastMonth = pastTime.getUTCMonth();
const pastYear = pastTime.getUTCFullYear();
// get current month and year
const currentMonth = currentTime.getUTCMonth();
const currentYear = currentTime.getUTCFullYear();
let monthDiff = (currentYear - pastYear) * 12 + (currentMonth - pastMonth);
// check if the time provided is the previous month but not exceeded 1 month ago.
if (currentTime.getUTCDate() < pastTime.getUTCDate()) {
monthDiff--;
}
if (monthDiff > 0) {
const unitAmount = monthDiff;
return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`;
}
} else if (elapsed >= timeUnits[i].value) {
const unitAmount: number = Math.floor(elapsed / timeUnits[i].value);
return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`;
}
}
return 'Invalid Time';
};
const TimeAgo: FunctionComponent<IProp> = ({ refreshInterval = 1000, ...rest }): JSX.Element => {
const [currentTime, setCurrentTime] = useState<Date>(new Date());
useEffect(() => {
const intervalId: NodeJS.Timeout = setInterval(() => {
setCurrentTime(new Date());
}, refreshInterval);
return () => clearInterval(intervalId);
}, [refreshInterval]);
const timeAgoValue = useMemo(() => timeAgo({ currentTime, ...rest }), [currentTime, rest]);
return <span>{timeAgoValue}</span>;
};
export default TimeAgo;

View File

@ -0,0 +1,65 @@
import { Fragment, h } from 'preact';
import { useState } from 'preact/hooks';
export default function TimelineEventOverlay({ eventOverlay, cameraConfig }) {
const boxLeftEdge = Math.round(eventOverlay.data.box[0] * 100);
const boxTopEdge = Math.round(eventOverlay.data.box[1] * 100);
const boxRightEdge = Math.round((1 - eventOverlay.data.box[2] - eventOverlay.data.box[0]) * 100);
const boxBottomEdge = Math.round((1 - eventOverlay.data.box[3] - eventOverlay.data.box[1]) * 100);
const [isHovering, setIsHovering] = useState(false);
const getHoverStyle = () => {
if (boxLeftEdge < 15) {
// show object stats on right side
return {
left: `${boxLeftEdge + eventOverlay.data.box[2] * 100 + 1}%`,
top: `${boxTopEdge}%`,
};
}
return {
right: `${boxRightEdge + eventOverlay.data.box[2] * 100 + 1}%`,
top: `${boxTopEdge}%`,
};
};
const getObjectArea = () => {
const width = eventOverlay.data.box[2] * cameraConfig.detect.width;
const height = eventOverlay.data.box[3] * cameraConfig.detect.height;
return Math.round(width * height);
};
const getObjectRatio = () => {
const width = eventOverlay.data.box[2] * cameraConfig.detect.width;
const height = eventOverlay.data.box[3] * cameraConfig.detect.height;
return Math.round(100 * (width / height)) / 100;
};
return (
<Fragment>
<div
className="absolute border-4 border-red-600"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onTouchStart={() => setIsHovering(true)}
onTouchEnd={() => setIsHovering(false)}
style={{
left: `${boxLeftEdge}%`,
top: `${boxTopEdge}%`,
right: `${boxRightEdge}%`,
bottom: `${boxBottomEdge}%`,
}}
>
{eventOverlay.class_type == 'entered_zone' ? (
<div className="absolute w-2 h-2 bg-yellow-500 left-[50%] -translate-x-1/2 translate-y-3/4 bottom-0" />
) : null}
</div>
{isHovering && (
<div className="absolute bg-white dark:bg-slate-800 p-4 block text-black dark:text-white text-lg" style={getHoverStyle()}>
<div>{`Area: ${getObjectArea()} px`}</div>
<div>{`Ratio: ${getObjectRatio()}`}</div>
</div>
)}
</Fragment>
);
}

View File

@ -0,0 +1,218 @@
import { h } from 'preact';
import useSWR from 'swr';
import ActivityIndicator from './ActivityIndicator';
import { formatUnixTimestampToDateTime } from '../utils/dateUtil';
import About from '../icons/About';
import ActiveObjectIcon from '../icons/ActiveObject';
import PlayIcon from '../icons/Play';
import ExitIcon from '../icons/Exit';
import StationaryObjectIcon from '../icons/StationaryObject';
import FaceIcon from '../icons/Face';
import LicensePlateIcon from '../icons/LicensePlate';
import DeliveryTruckIcon from '../icons/DeliveryTruck';
import ZoneIcon from '../icons/Zone';
import { useMemo, useState } from 'preact/hooks';
import Button from './Button';
export default function TimelineSummary({ event, onFrameSelected }) {
const { data: eventTimeline } = useSWR([
'timeline',
{
source_id: event.id,
},
]);
const { data: config } = useSWR('config');
const annotationOffset = useMemo(() => {
if (!config) {
return 0;
}
return (config.cameras[event.camera]?.detect?.annotation_offset || 0) / 1000;
}, [config, event]);
const [timeIndex, setTimeIndex] = useState(-1);
const recordingParams = useMemo(() => {
if (!event.end_time) {
return {
after: event.start_time,
};
}
return {
before: event.end_time,
after: event.start_time,
};
}, [event]);
const { data: recordings } = useSWR([`${event.camera}/recordings`, recordingParams], { revalidateOnFocus: false });
// calculates the seek seconds by adding up all the seconds in the segments prior to the playback time
const getSeekSeconds = (seekUnix) => {
if (!recordings) {
return 0;
}
let seekSeconds = 0;
recordings.every((segment) => {
// if the next segment is past the desired time, stop calculating
if (segment.start_time > seekUnix) {
return false;
}
if (segment.end_time < seekUnix) {
seekSeconds += segment.end_time - segment.start_time;
return true;
}
seekSeconds += segment.end_time - segment.start_time - (segment.end_time - seekUnix);
return true;
});
return seekSeconds;
};
const onSelectMoment = async (index) => {
setTimeIndex(index);
onFrameSelected(eventTimeline[index], getSeekSeconds(eventTimeline[index].timestamp + annotationOffset));
};
if (!eventTimeline || !config) {
return <ActivityIndicator />;
}
if (eventTimeline.length == 0) {
return <div />;
}
return (
<div className="flex flex-col">
<div className="h-14 flex justify-center">
<div className="flex flex-row flex-nowrap justify-between overflow-auto">
{eventTimeline.map((item, index) => (
<Button
key={index}
className="rounded-full"
type="iconOnly"
color={index == timeIndex ? 'blue' : 'gray'}
aria-label={window.innerWidth > 640 ? getTimelineItemDescription(config, item, event) : ''}
onClick={() => onSelectMoment(index)}
>
{getTimelineIcon(item)}
</Button>
))}
</div>
</div>
{timeIndex >= 0 ? (
<div className="m-2 max-w-md self-center">
<div className="flex justify-start">
<div className="text-lg flex justify-between py-4">Bounding boxes may not align</div>
<Button
className="rounded-full"
type="text"
color="gray"
aria-label=" Disclaimer: This data comes from the detect feed but is shown on the recordings, it is unlikely that the
streams are perfectly in sync so the bounding box and the footage will not line up perfectly. The annotation_offset field can be used to adjust this."
>
<About className="w-4" />
</Button>
</div>
</div>
) : null}
</div>
);
}
function getTimelineIcon(timelineItem) {
switch (timelineItem.class_type) {
case 'visible':
return <PlayIcon className="w-8" />;
case 'gone':
return <ExitIcon className="w-8" />;
case 'active':
return <ActiveObjectIcon className="w-8" />;
case 'stationary':
return <StationaryObjectIcon className="w-8" />;
case 'entered_zone':
return <ZoneIcon className="w-8" />;
case 'attribute':
switch (timelineItem.data.attribute) {
case 'face':
return <FaceIcon className="w-8" />;
case 'license_plate':
return <LicensePlateIcon className="w-8" />;
default:
return <DeliveryTruckIcon className="w-8" />;
}
case 'sub_label':
switch (timelineItem.data.label) {
case 'person':
return <FaceIcon className="w-8" />;
case 'car':
return <LicensePlateIcon className="w-8" />;
}
}
}
function getTimelineItemDescription(config, timelineItem, event) {
switch (timelineItem.class_type) {
case 'visible':
return `${event.label} detected at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
date_style: 'short',
time_style: 'medium',
time_format: config.ui.time_format,
})}`;
case 'entered_zone':
return `${event.label.replaceAll('_', ' ')} entered ${timelineItem.data.zones
.join(' and ')
.replaceAll('_', ' ')} at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
date_style: 'short',
time_style: 'medium',
time_format: config.ui.time_format,
})}`;
case 'active':
return `${event.label} became active at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
date_style: 'short',
time_style: 'medium',
time_format: config.ui.time_format,
})}`;
case 'stationary':
return `${event.label} became stationary at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
date_style: 'short',
time_style: 'medium',
time_format: config.ui.time_format,
})}`;
case 'attribute': {
let title = "";
if (timelineItem.data.attribute == 'face' || timelineItem.data.attribute == 'license_plate') {
title = `${timelineItem.data.attribute.replaceAll("_", " ")} detected for ${event.label}`;
} else {
title = `${event.label} recognized as ${timelineItem.data.attribute.replaceAll("_", " ")}`
}
return `${title} at ${formatUnixTimestampToDateTime(
timelineItem.timestamp,
{
date_style: 'short',
time_style: 'medium',
time_format: config.ui.time_format,
}
)}`;
}
case 'sub_label':
return `${event.label} recognized as ${timelineItem.data.sub_label} at ${formatUnixTimestampToDateTime(
timelineItem.timestamp,
{
date_style: 'short',
time_style: 'medium',
time_format: config.ui.time_format,
}
)}`;
case 'gone':
return `${event.label} left at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
date_style: 'short',
time_style: 'medium',
time_format: config.ui.time_format,
})}`;
}
}

View File

@ -0,0 +1,62 @@
import { createPortal } from 'react'; // TODO implement
import { useLayoutEffect, useRef, useState } from 'react';
const TIP_SPACE = 20;
export default function Tooltip({ relativeTo, text, capitalize }) {
const [position, setPosition] = useState({ top: -9999, left: -9999 });
const portalRoot = document.getElementById('tooltips');
const ref = useRef();
useLayoutEffect(() => {
if (ref && ref.current && relativeTo && relativeTo.current) {
const windowWidth = window.innerWidth;
const {
x: relativeToX,
y: relativeToY,
width: relativeToWidth,
height: relativeToHeight,
} = relativeTo.current.getBoundingClientRect();
const { width: _tipWidth, height: _tipHeight } = ref.current.getBoundingClientRect();
const tipWidth = _tipWidth * 1.1;
const tipHeight = _tipHeight * 1.1;
const left = relativeToX + Math.round(relativeToWidth / 2) + window.scrollX;
const top = relativeToY + Math.round(relativeToHeight / 2) + window.scrollY;
let newTop = top - TIP_SPACE - tipHeight;
let newLeft = left - Math.round(tipWidth / 2);
// too far right
if (newLeft + tipWidth + TIP_SPACE > windowWidth - window.scrollX) {
newLeft = Math.max(0, left - tipWidth - TIP_SPACE);
newTop = top - Math.round(tipHeight / 2);
}
// too far left
else if (newLeft < TIP_SPACE + window.scrollX) {
newLeft = left + TIP_SPACE;
newTop = top - Math.round(tipHeight / 2);
}
// too close to top
else if (newTop <= TIP_SPACE + window.scrollY) {
newTop = top + tipHeight + TIP_SPACE;
}
setPosition({ left: newLeft, top: newTop });
}
}, [relativeTo, ref]);
const tooltip = (
<div
role="tooltip"
className={`shadow max-w-lg absolute pointer-events-none bg-gray-900 dark:bg-gray-200 bg-opacity-80 rounded px-2 py-1 transition-transform transition-opacity duration-75 transform scale-90 opacity-0 text-gray-100 dark:text-gray-900 text-sm ${
capitalize ? 'capitalize' : ''
} ${position.top >= 0 ? 'opacity-100 scale-100' : ''}`}
ref={ref}
style={position}
>
{text}
</div>
);
return portalRoot ? createPortal(tooltip, portalRoot) : tooltip;
}

View File

@ -0,0 +1,100 @@
import { useRef, useEffect } from 'react';
import videojs from 'video.js';
import 'videojs-playlist';
import 'video.js/dist/video-js.css';
export default function VideoPlayer({
children,
options,
seekOptions = { forward: 30, backward: 10 }, onReady = () => { }, onDispose = () => { }
}) {
const playerRef = useRef();
useEffect(() => {
const defaultOptions = {
controls: true,
controlBar: {
skipButtons: seekOptions,
},
playbackRates: [0.5, 1, 2, 4, 8],
fluid: true,
};
if (!videojs.browser.IS_FIREFOX) {
defaultOptions.playbackRates.push(16);
}
const player = videojs(playerRef.current, { ...defaultOptions, ...options }, () => {
onReady(player);
});
// Allows player to continue on error
player.reloadSourceOnError();
// Disable fullscreen on iOS if we have children
if (
children &&
videojs.browser.IS_IOS &&
videojs.browser.IOS_VERSION > 9 &&
!player.el_.ownerDocument.querySelector('.bc-iframe')
) {
player.tech_.el_.setAttribute('playsinline', 'playsinline');
player.tech_.supportsFullScreen = function () {
return false;
};
}
const screen = window.screen;
const angle = () => {
// iOS
if (typeof window.orientation === 'number') {
return window.orientation;
}
// Android
if (screen && screen.orientation && screen.orientation.angle) {
return window.orientation;
}
videojs.log('angle unknown');
return 0;
};
const rotationHandler = () => {
const currentAngle = angle();
if (currentAngle === 90 || currentAngle === 270 || currentAngle === -90) {
if (player.paused() === false) {
player.requestFullscreen();
}
}
if ((currentAngle === 0 || currentAngle === 180) && player.isFullscreen()) {
player.exitFullscreen();
}
};
if (videojs.browser.IS_IOS) {
window.addEventListener('orientationchange', rotationHandler);
} else if (videojs.browser.IS_ANDROID && screen.orientation) {
// addEventListener('orientationchange') is not a user interaction on Android
screen.orientation.onchange = rotationHandler;
}
return () => {
if (videojs.browser.IS_IOS) {
window.removeEventListener('orientationchange', rotationHandler);
}
player.dispose();
onDispose();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div data-vjs-player>
{/* Setting an empty data-setup is required to override the default values and allow video to be fit the size of its parent */}
<video ref={playerRef} className="small-player video-js vjs-default-skin" data-setup="{}" controls playsinline />
{children}
</div>
);
}

View File

@ -0,0 +1,237 @@
import strftime from 'strftime';
import { fromUnixTime, intervalToDuration, formatDuration } from 'date-fns';
export const longToDate = (long: number): Date => new Date(long * 1000);
export const epochToLong = (date: number): number => date / 1000;
export const dateToLong = (date: Date): number => epochToLong(date.getTime());
const getDateTimeYesterday = (dateTime: Date): Date => {
const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000;
return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds);
};
const getNowYesterday = (): Date => {
return getDateTimeYesterday(new Date());
};
export const getNowYesterdayInLong = (): number => {
return dateToLong(getNowYesterday());
};
/**
* This function takes in a Unix timestamp, configuration options for date/time display, and an optional strftime format string,
* and returns a formatted date/time string.
*
* If the Unix timestamp is not provided, it returns "Invalid time".
*
* The configuration options determine how the date and time are formatted.
* The `timezone` option allows you to specify a specific timezone for the output, otherwise the user's browser timezone will be used.
* The `use12hour` option allows you to display time in a 12-hour format if true, and 24-hour format if false.
* The `dateStyle` and `timeStyle` options allow you to specify pre-defined formats for displaying the date and time.
* The `strftime_fmt` option allows you to specify a custom format using the strftime syntax.
*
* If both `strftime_fmt` and `dateStyle`/`timeStyle` are provided, `strftime_fmt` takes precedence.
*
* @param unixTimestamp The Unix timestamp to format
* @param config An object containing the configuration options for date/time display
* @returns The formatted date/time string, or "Invalid time" if the Unix timestamp is not provided or invalid.
*/
interface DateTimeStyle {
timezone: string;
time_format: 'browser' | '12hour' | '24hour';
date_style: 'full' | 'long' | 'medium' | 'short';
time_style: 'full' | 'long' | 'medium' | 'short';
strftime_fmt: string;
}
// only used as a fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
const formatMap: {
[k: string]: {
date: { year: 'numeric' | '2-digit'; month: 'long' | 'short' | '2-digit'; day: 'numeric' | '2-digit' };
time: { hour: 'numeric'; minute: 'numeric'; second?: 'numeric'; timeZoneName?: 'short' | 'long' };
};
} = {
full: {
date: { year: 'numeric', month: 'long', day: 'numeric' },
time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' },
},
long: {
date: { year: 'numeric', month: 'long', day: 'numeric' },
time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' },
},
medium: {
date: { year: 'numeric', month: 'short', day: 'numeric' },
time: { hour: 'numeric', minute: 'numeric', second: 'numeric' },
},
short: { date: { year: '2-digit', month: '2-digit', day: '2-digit' }, time: { hour: 'numeric', minute: 'numeric' } },
};
/**
* Attempts to get the system's time zone using Intl.DateTimeFormat. If that fails (for instance, in environments
* where Intl is not fully supported), it calculates the UTC offset for the current system time and returns
* it in a string format.
*
* Keeping the Intl.DateTimeFormat for now, as this is the recommended way to get the time zone.
* https://stackoverflow.com/a/34602679
*
* Intl.DateTimeFormat function as of April 2023, works in 95.03% of the browsers used globally
* https://caniuse.com/mdn-javascript_builtins_intl_datetimeformat_resolvedoptions_computed_timezone
*
* @returns {string} The resolved time zone or a calculated UTC offset.
* The returned string will either be a named time zone (e.g., "America/Los_Angeles"), or it will follow
* the format "UTC±HH:MM".
*/
const getResolvedTimeZone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch (error) {
const offsetMinutes = new Date().getTimezoneOffset();
return `UTC${offsetMinutes < 0 ? '+' : '-'}${Math.abs(offsetMinutes / 60)
.toString()
.padStart(2, '0')}:${Math.abs(offsetMinutes % 60)
.toString()
.padStart(2, '0')}`;
}
};
/**
* Formats a Unix timestamp into a human-readable date/time string.
*
* The format of the output string is determined by a configuration object passed as an argument, which
* may specify a time zone, 12- or 24-hour time, and various stylistic options for the date and time.
* If these options are not specified, the function will use system defaults or sensible fallbacks.
*
* The function is robust to environments where the Intl API is not fully supported, and includes a
* fallback method to create a formatted date/time string in such cases.
*
* @param {number} unixTimestamp - The Unix timestamp to be formatted.
* @param {DateTimeStyle} config - User configuration object.
* @returns {string} A formatted date/time string.
*
* @throws {Error} If the given unixTimestamp is not a valid number, the function will return 'Invalid time'.
*/
export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: DateTimeStyle): string => {
const { timezone, time_format, date_style, time_style, strftime_fmt } = config;
const locale = window.navigator?.language || 'en-us';
if (isNaN(unixTimestamp)) {
return 'Invalid time';
}
try {
const date = new Date(unixTimestamp * 1000);
const resolvedTimeZone = getResolvedTimeZone();
// use strftime_fmt if defined in config
if (strftime_fmt) {
const offset = getUTCOffset(date, timezone || resolvedTimeZone);
// @ts-ignore
const strftime_locale = strftime.timezone(offset).localizeByIdentifier(locale);
return strftime_locale(strftime_fmt, date);
}
// DateTime format options
const options: Intl.DateTimeFormatOptions = {
dateStyle: date_style,
timeStyle: time_style,
hour12: time_format !== 'browser' ? time_format == '12hour' : undefined,
};
// Only set timeZone option when resolvedTimeZone does not match UTC±HH:MM format, or when timezone is set in config
const isUTCOffsetFormat = /^UTC[+-]\d{2}:\d{2}$/.test(resolvedTimeZone);
if (timezone || !isUTCOffsetFormat) {
options.timeZone = timezone || resolvedTimeZone;
}
const formatter = new Intl.DateTimeFormat(locale, options);
const formattedDateTime = formatter.format(date);
// Regex to check for existence of time. This is needed because dateStyle/timeStyle is not always supported.
const containsTime = /\d{1,2}:\d{1,2}/.test(formattedDateTime);
// fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
// This works even tough the timezone is undefined, it will use the runtime's default time zone
if (!containsTime) {
const dateOptions = { ...formatMap[date_style]?.date, timeZone: options.timeZone, hour12: options.hour12 };
const timeOptions = { ...formatMap[time_style]?.time, timeZone: options.timeZone, hour12: options.hour12 };
return `${date.toLocaleDateString(locale, dateOptions)} ${date.toLocaleTimeString(locale, timeOptions)}`;
}
return formattedDateTime;
} catch (error) {
return 'Invalid time';
}
};
interface DurationToken {
xSeconds: string;
xMinutes: string;
xHours: string;
}
/**
* This function takes in start and end time in unix timestamp,
* and returns the duration between start and end time in hours, minutes and seconds.
* If end time is not provided, it returns 'In Progress'
* @param start_time: number - Unix timestamp for start time
* @param end_time: number|null - Unix timestamp for end time
* @returns string - duration or 'In Progress' if end time is not provided
*/
export const getDurationFromTimestamps = (start_time: number, end_time: number | null): string => {
if (isNaN(start_time)) {
return 'Invalid start time';
}
let duration = 'In Progress';
if (end_time !== null) {
if (isNaN(end_time)) {
return 'Invalid end time';
}
const start = fromUnixTime(start_time);
const end = fromUnixTime(end_time);
const formatDistanceLocale: DurationToken = {
xSeconds: '{{count}}s',
xMinutes: '{{count}}m',
xHours: '{{count}}h',
};
const shortEnLocale = {
formatDistance: (token: keyof DurationToken, count: number) =>
formatDistanceLocale[token].replace('{{count}}', count.toString()),
};
duration = formatDuration(intervalToDuration({ start, end }), {
format: ['hours', 'minutes', 'seconds'],
// @ts-ignore
locale: shortEnLocale,
});
}
return duration;
};
/**
* Adapted from https://stackoverflow.com/a/29268535 this takes a timezone string and
* returns the offset of that timezone from UTC in minutes.
* @param timezone string representation of the timezone the user is requesting
* @returns number of minutes offset from UTC
*/
const getUTCOffset = (date: Date, timezone: string): number => {
// If timezone is in UTC±HH:MM format, parse it to get offset
const utcOffsetMatch = timezone.match(/^UTC([+-])(\d{2}):(\d{2})$/);
if (utcOffsetMatch) {
const hours = parseInt(utcOffsetMatch[2], 10);
const minutes = parseInt(utcOffsetMatch[3], 10);
return (utcOffsetMatch[1] === '+' ? 1 : -1) * (hours * 60 + minutes);
}
// Otherwise, calculate offset using provided timezone
const utcDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
// locale of en-CA is required for proper locale format
let iso = utcDate.toLocaleString('en-CA', { timeZone: timezone, hour12: false }).replace(', ', 'T');
iso += `.${utcDate.getMilliseconds().toString().padStart(3, '0')}`;
let target = new Date(`${iso}Z`);
// safari doesn't like the default format
if (isNaN(target.getTime())) {
iso = iso.replace("T", " ").split(".")[0];
target = new Date(`${iso}+000`);
}
return (target.getTime() - utcDate.getTime()) / 60 / 1000;
};

View File

@ -0,0 +1,19 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function About({ className = '' }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`${className}`}
>
<path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" />
</svg>
);
}
export default memo(About);

View File

@ -0,0 +1,24 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function CalendarIcon({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
);
}
export default memo(CalendarIcon);

View File

@ -0,0 +1,24 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Camera({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
);
}
export default memo(Camera);

View File

@ -0,0 +1,24 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Clip({ className = 'h-6 w-6', stroke = 'currentColor', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill="none"
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"
/>
</svg>
);
}
export default memo(Clip);

View File

@ -0,0 +1,24 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Clock({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
}
export default memo(Clock);

View File

@ -0,0 +1,24 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Delete({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
);
}
export default memo(Delete);

View File

@ -0,0 +1,24 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Download({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
);
}
export default memo(Download);

View File

@ -0,0 +1,13 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Menu({ className = '' }) {
return (
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" />
</svg>
);
}
export default memo(Menu);

View File

@ -0,0 +1,13 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function MenuOpen({ className = '' }) {
return (
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M3 18h13v-2H3v2zm0-5h10v-2H3v2zm0-7v2h13V6H3zm18 9.59L17.42 12 21 8.41 19.59 7l-5 5 5 5L21 15.59z" />
</svg>
);
}
export default memo(MenuOpen);

View File

@ -0,0 +1,20 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Score({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'currentColor', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<title>percent</title>
<path d="M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M19,19H15V21H19A2,2 0 0,0 21,19V15H19M19,3H15V5H19V9H21V5A2,2 0 0,0 19,3M5,5H9V3H5A2,2 0 0,0 3,5V9H5M5,15H3V19A2,2 0 0,0 5,21H9V19H5V15Z" />
</svg>
);
}
export default memo(Score);

View File

@ -0,0 +1,24 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Snapshot({ className = 'h-6 w-6', stroke = 'currentColor', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill="none"
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
);
}
export default memo(Snapshot);

View File

@ -0,0 +1,24 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function StarRecording({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
/>
</svg>
);
}
export default memo(StarRecording);

View File

@ -0,0 +1,19 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Submitted({ className = 'h-6 w-6', inner_fill = 'none', outer_stroke = 'currentColor', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
viewBox="0 0 32 32"
onClick={onClick}
>
<rect x="10" y="15" fill={inner_fill} width="12" height="2"/>
<rect x="15" y="10" fill={inner_fill} width="2" height="12"/>
<circle fill="none" stroke={outer_stroke} stroke-width="2" stroke-miterlimit="10" cx="16" cy="16" r="12"/>
</svg>
);
}
export default memo(Submitted);

View File

@ -0,0 +1,23 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function UploadPlus({ className = 'h-6 w-6', stroke = 'currentColor', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill="none"
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
);
}
export default memo(UploadPlus);

View File

@ -0,0 +1,25 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Zone({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
}
export default memo(Zone);

View File

@ -1,7 +1,7 @@
import { DEFAULT_THEME } from '@mantine/core'; import { DEFAULT_THEME } from '@mantine/core';
import React from 'react'; import React from 'react';
const СogwheelSVG = ( const CogwheelSVG = (
<svg <svg
width="86" width="86"
height="86" height="86"
@ -35,4 +35,4 @@ const СogwheelSVG = (
</svg> </svg>
); );
export default СogwheelSVG; export default CogwheelSVG;

View File

@ -2572,6 +2572,11 @@
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==
"@types/strftime@^0.9.8":
version "0.9.8"
resolved "https://registry.yarnpkg.com/@types/strftime/-/strftime-0.9.8.tgz#1473a08514841faff9c7ea0805afcf66a162b9b6"
integrity sha512-QIvDlGAKyF3YJbT3QZnfC+RIvV5noyDbi+ZJ5rkaSRqxCGrYJefgXm3leZAjtoQOutZe1hCXbAg+p89/Vj4HlQ==
"@types/testing-library__jest-dom@^5.9.1": "@types/testing-library__jest-dom@^5.9.1":
version "5.14.7" version "5.14.7"
resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.7.tgz#fff92bed2a32c58a9224a85603e731519c0a9037" resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.7.tgz#fff92bed2a32c58a9224a85603e731519c0a9037"
@ -4046,6 +4051,11 @@ data-urls@^2.0.0:
whatwg-mimetype "^2.3.0" whatwg-mimetype "^2.3.0"
whatwg-url "^8.0.0" whatwg-url "^8.0.0"
date-fns@^3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.3.1.tgz#7581daca0892d139736697717a168afbb908cfed"
integrity sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==
dayjs@^1.11.9: dayjs@^1.11.9:
version "1.11.9" version "1.11.9"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.9.tgz#9ca491933fadd0a60a2c19f6c237c03517d71d1a" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.9.tgz#9ca491933fadd0a60a2c19f6c237c03517d71d1a"
@ -8939,6 +8949,11 @@ stop-iteration-iterator@^1.0.0:
dependencies: dependencies:
internal-slot "^1.0.4" internal-slot "^1.0.4"
strftime@0.10.1:
version "0.10.1"
resolved "https://registry.yarnpkg.com/strftime/-/strftime-0.10.1.tgz#108af1176a7d5252cfbddbdb2af044dfae538389"
integrity sha512-nVvH6JG8KlXFPC0f8lojLgEsPA18lRpLZ+RrJh/NkQV2tqOgZfbas8gcU8SFgnnqR3rWzZPYu6N2A3xzs/8rQg==
string-length@^4.0.1: string-length@^4.0.1:
version "4.0.2" version "4.0.2"
resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"