diff --git a/package.json b/package.json
index 0cc909a..549fab8 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"axios": "^1.4.0",
"bson-objectid": "^2.0.4",
"cookies-next": "^4.1.1",
+ "date-fns": "^3.3.1",
"dayjs": "^1.11.9",
"embla-carousel-react": "^8.0.0-rc10",
"idb-keyval": "^6.2.1",
@@ -39,6 +40,7 @@
"react-router-dom": "^6.14.1",
"react-scripts": "5.0.1",
"react-use-websocket": "^4.7.0",
+ "strftime": "0.10.1",
"typescript": "^4.4.2",
"validator": "^13.9.0",
"web-vitals": "^2.1.0",
@@ -70,6 +72,7 @@
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
+ "@types/strftime": "^0.9.8",
"@types/uuid": "^9.0.2",
"uuid": "^9.0.0"
}
diff --git a/src/pages/EventsPage.tsx b/src/pages/EventsPage.tsx
new file mode 100644
index 0000000..78b696f
--- /dev/null
+++ b/src/pages/EventsPage.tsx
@@ -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 (
+
+ )
+}
+
+// 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 ;
+// }
+
+// return (
+//
+//
Events
+//
+//
onToggleNamedFilter('cameras', item)}
+// onShowAll={() => onFilter('cameras', ['all'])}
+// onSelectSingle={(item) => onFilter('cameras', item)}
+// />
+// onToggleNamedFilter('labels', item)}
+// onShowAll={() => onFilter('labels', ['all'])}
+// onSelectSingle={(item) => onFilter('labels', item)}
+// />
+// onToggleNamedFilter('zones', item)}
+// onShowAll={() => onFilter('zones', ['all'])}
+// onSelectSingle={(item) => onFilter('zones', item)}
+// />
+// {filterValues.sub_labels.length > 0 && (
+// onToggleNamedFilter('sub_labels', item)}
+// onShowAll={() => onFilter('sub_labels', ['all'])}
+// onSelectSingle={(item) => onFilter('sub_labels', item)}
+// />
+// )}
+// {searchParams.event && (
+//
+// )}
+
+//
+// {config.plus.enabled && (
+// onClickFilterSubmitted()}
+// inner_fill={searchParams.is_submitted == 1 ? 'currentColor' : 'gray'}
+// outer_stroke={searchParams.is_submitted >= 0 ? 'currentColor' : 'gray'}
+// />
+// )}
+
+// onFilter('favorites', searchParams.favorites ? 0 : 1)}
+// fill={searchParams.favorites == 1 ? 'currentColor' : 'none'}
+// />
+//
+
+//
+// setState({ ...state, showDatePicker: true })}
+// />
+//
+//
+// {state.showDownloadMenu && (
+//
+// )}
+// {state.showDatePicker && (
+//
+// )}
+
+// {state.showCalendar && (
+//
+//
+//
+// )}
+// {state.showPlusSubmit && (
+//
+// )}
+// {deleteFavoriteState.showDeleteFavorite && (
+//
+// )}
+//
+// {ongoingEvents ? (
+//
+//
+//
+// Ongoing Events
+//
+//
+//
+//
+// {showInProgress &&
+// ongoingEvents.map((event, _) => {
+// return (
+//
{
+// this.player = null;
+// }}
+// onDownloadClick={onDownloadClick}
+// onReady={(player) => {
+// this.player = player;
+// this.player.on('playing', () => {
+// setEventOverlay(undefined);
+// });
+// }}
+// onSave={onSave}
+// showSubmitToPlus={showSubmitToPlus}
+// />
+// );
+// })}
+//
+// ) : null}
+//
+// Past Events
+//
+// {eventPages ? (
+// eventPages.map((page, i) => {
+// const lastPage = eventPages.length === i + 1;
+// return page.map((event, j) => {
+// const lastEvent = lastPage && page.length === j + 1;
+// return (
+//
{
+// this.player = null;
+// }}
+// onDownloadClick={onDownloadClick}
+// onReady={(player) => {
+// this.player = player;
+// this.player.on('playing', () => {
+// setEventOverlay(undefined);
+// });
+// }}
+// onSave={onSave}
+// showSubmitToPlus={showSubmitToPlus}
+// />
+// );
+// });
+// })
+// ) : (
+//
+// )}
+//
+//
+//
+// );
+// }
+
+// function Event({
+// className = '',
+// config,
+// event,
+// eventDetailType,
+// eventOverlay,
+// viewEvent,
+// setViewEvent,
+// lastEvent,
+// lastEventRef,
+// uploading,
+// handleEventDetailTabChange,
+// onEventFrameSelected,
+// onDelete,
+// onDispose,
+// onDownloadClick,
+// onReady,
+// onSave,
+// showSubmitToPlus,
+// }) {
+// const apiHost = useApiHost();
+
+// return (
+//
+//
(viewEvent === event.id ? setViewEvent(null) : setViewEvent(event.id))}
+// >
+//
+//
onSave(e, event.id, !event.retain_indefinitely)}
+// fill={event.retain_indefinitely ? 'currentColor' : 'none'}
+// />
+// {event.end_time ? null : (
+//
+// In progress
+//
+// )}
+//
+//
+//
+//
+// {event.label.replaceAll('_', ' ')}
+// {event.sub_label ? `: ${event.sub_label.replaceAll('_', ' ')}` : null}
+//
+
+//
+//
+// {formatUnixTimestampToDateTime(event.start_time, { ...config.ui })}
+//
+// -
+//
+//
+//
+// ( {getDurationFromTimestamps(event.start_time, event.end_time)} )
+//
+//
+//
+//
+// {event.camera.replaceAll('_', ' ')}
+//
+// {event.zones.length ? (
+//
+//
+// {event.zones.join(', ').replaceAll('_', ' ')}
+//
+// ) : null}
+//
+//
+// {(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)}%`}
+//
+//
+//
+// {event.end_time && event.has_snapshot && (event?.data?.type || 'object') == 'object' && (
+//
+// {event.plus_id ? (
+//
+//
+// Edit in Frigate+
+//
+//
+// ) : (
+//
+// )}
+//
+// )}
+//
+//
+// onDelete(e, event.id, event.retain_indefinitely)}
+// />
+
+// onDownloadClick(e, event)}
+// />
+//
+//
+//
+// {viewEvent !== event.id ? null : (
+//
+//
+//
+//
+//
+//
+//
+//
+
+//
+// {eventDetailType == 'clip' && event.has_clip ? (
+//
+//
onEventFrameSelected(event, frame, seekSeconds)}
+// />
+//
+//
+// {eventOverlay ? (
+//
+// ) : null}
+//
+//
+//
+// ) : null}
+
+// {eventDetailType == 'image' || !event.has_clip ? (
+//
+//

+//
+// ) : null}
+//
+//
+//
+// )}
+//
+// );
+// }
diff --git a/src/pages/HostConfigPage.tsx b/src/pages/HostConfigPage.tsx
index f8590a9..153ad9f 100644
--- a/src/pages/HostConfigPage.tsx
+++ b/src/pages/HostConfigPage.tsx
@@ -2,7 +2,7 @@ import React, { useCallback, useContext, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { Context } from '..';
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 { useClipboard } from '@mantine/hooks';
import { configureMonacoYaml } from "monaco-yaml";
@@ -21,7 +21,7 @@ const HostConfigPage = () => {
queryFn: async () => {
const host = await frigateApi.getHost(id || '')
const hostName = mapHostToHostname(host)
- return frigateApi.getHostConfigRaw(hostName)
+ return proxyApi.getHostConfigRaw(hostName)
},
})
diff --git a/src/pages/LiveCameraPage.tsx b/src/pages/LiveCameraPage.tsx
index 35f2bfb..5aaa799 100644
--- a/src/pages/LiveCameraPage.tsx
+++ b/src/pages/LiveCameraPage.tsx
@@ -1,4 +1,4 @@
-import React, { Fragment, useContext, useEffect } from 'react';
+import React, { useContext, useEffect } from 'react';
import { Context } from '..';
import { observer } from 'mobx-react-lite';
import { useParams } from 'react-router-dom';
@@ -8,7 +8,6 @@ import CenterLoader from '../shared/components/CenterLoader';
import RetryError from './RetryError';
import Player from '../shared/components/frigate/Player';
import { Flex } from '@mantine/core';
-import JSMpegPlayer from '../shared/components/frigate/JSMpegPlayer';
const LiveCameraPage = observer(() => {
let { id: cameraId } = useParams<'id'>()
@@ -31,15 +30,9 @@ const LiveCameraPage = observer(() => {
if (isError) return
- // const hostNameWPort = camera.frigateHost ? new URL(camera.frigateHost.host).host : ''
- // const wsUrl = frigateApi.cameraWsURL(hostNameWPort, camera.name)
-
return (
- {/* */}
- {/* {JSON.stringify(camera)} */}
- {/* {cameraWsURL} */}
);
})
diff --git a/src/pages/MainBody.tsx b/src/pages/MainBody.tsx
index ecd8dbc..ae3ead3 100644
--- a/src/pages/MainBody.tsx
+++ b/src/pages/MainBody.tsx
@@ -7,10 +7,8 @@ import { Context } from '..';
import { observer } from 'mobx-react-lite'
import CenterLoader from '../shared/components/CenterLoader';
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 { FrigateConfig } from '../types/frigateConfig';
-import { GetFrigateHostWConfig } from '../services/frigate.proxy/frigate.schema';
import CameraCard from '../shared/components/CameraCard';
const MainBody = observer(() => {
@@ -41,7 +39,8 @@ const MainBody = observer(() => {
))
+ />)
+ )
}
return (
@@ -67,7 +66,6 @@ const MainBody = observer(() => {
{cards()}
- ))
);
diff --git a/src/pages/RetryError.tsx b/src/pages/RetryError.tsx
index 7ddaa6b..9a081c3 100644
--- a/src/pages/RetryError.tsx
+++ b/src/pages/RetryError.tsx
@@ -10,7 +10,7 @@ interface RetryErrorProps {
onRetry?: () => void
}
-const RetryError = ( {onRetry} : RetryErrorProps) => {
+const RetryError = ({ onRetry }: RetryErrorProps) => {
const navigate = useNavigate()
const { sideBarsStore } = useContext(Context)
@@ -25,7 +25,11 @@ const RetryError = ( {onRetry} : RetryErrorProps) => {
navigate(routesPath.MAIN_PATH)
}
- function handleRetry(event: React.MouseEvent): void {
+ const handleGoBack = () => {
+ navigate(-1)
+ }
+
+ const handleRetry = (event: React.MouseEvent): void => {
if (onRetry) onRetry()
else window.location.reload()
}
@@ -35,8 +39,11 @@ const RetryError = ( {onRetry} : RetryErrorProps) => {
{strings.errors.somthengGoesWrong}
{ExclamationCogWheel}
{strings.youCanRetryOrGoToMain}
-
-
+
+
+
+
+
);
};
diff --git a/src/services/frigate.proxy/frigate.api.ts b/src/services/frigate.proxy/frigate.api.ts
index 5ae67e5..fabd53f 100644
--- a/src/services/frigate.proxy/frigate.api.ts
+++ b/src/services/frigate.proxy/frigate.api.ts
@@ -3,35 +3,46 @@ import { proxyURL } from "../../shared/env.const"
import { z } from "zod"
import { GetConfig, DeleteFrigateHost, GetFrigateHost, PutConfig, PutFrigateHost, GetFrigateHostWithCameras, GetCameraWHost, GetCameraWHostWConfig } from "./frigate.schema";
import { FrigateConfig } from "../../types/frigateConfig";
+import { url } from "inspector";
-const instance = axios.create({
+const instanceApi = axios.create({
baseURL: proxyURL.toString(),
timeout: 30000,
});
export const frigateApi = {
- getConfig: () => instance.get('apiv1/config').then(res => res.data),
- putConfig: (config: PutConfig[]) => instance.put('apiv1/config', config).then(res => res.data),
- getHosts: () => instance.get('apiv1/frigate-hosts').then(res => {
+ getConfig: () => instanceApi.get('apiv1/config').then(res => res.data),
+ putConfig: (config: PutConfig[]) => instanceApi.put('apiv1/config', config).then(res => res.data),
+ getHosts: () => instanceApi.get('apiv1/frigate-hosts').then(res => {
return res.data
}),
- getHostWithCameras: () => instance.get('apiv1/frigate-hosts', { params: { include: 'cameras'}}).then(res => {
+ getHostWithCameras: () => instanceApi.get('apiv1/frigate-hosts', { params: { include: 'cameras' } }).then(res => {
return res.data
}),
- getHost: (id: string) => instance.get(`apiv1/frigate-hosts/${id}`).then(res => {
+ getHost: (id: string) => instanceApi.get(`apiv1/frigate-hosts/${id}`).then(res => {
return res.data
}),
- getCamerasWHost: () => instance.get(`apiv1/cameras`).then(res => {return res.data}),
- getCameraWHost: (id: string) => instance.get(`apiv1/cameras/${id}`).then(res => {return res.data}),
- putHosts: (hosts: PutFrigateHost[]) => instance.put('apiv1/frigate-hosts', hosts).then(res => {
+ getCamerasWHost: () => instanceApi.get(`apiv1/cameras`).then(res => { return res.data }),
+ getCameraWHost: (id: string) => instanceApi.get(`apiv1/cameras/${id}`).then(res => { return res.data }),
+ putHosts: (hosts: PutFrigateHost[]) => instanceApi.put('apiv1/frigate-hosts', hosts).then(res => {
return res.data
}),
- deleteHosts: (hosts: DeleteFrigateHost[]) => instance.delete('apiv1/frigate-hosts', { data: hosts }).then(res => {
+ deleteHosts: (hosts: DeleteFrigateHost[]) => instanceApi.delete('apiv1/frigate-hosts', { data: hosts }).then(res => {
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) => {
return `ws://${proxyURL.host}/proxy-ws/live/jsmpeg/${cameraName}?hostName=${hostName}`
},
diff --git a/src/shared/components/CameraCard.tsx b/src/shared/components/CameraCard.tsx
index 921f412..fc047c2 100644
--- a/src/shared/components/CameraCard.tsx
+++ b/src/shared/components/CameraCard.tsx
@@ -5,7 +5,8 @@ import AutoUpdatingCameraImage from './frigate/AutoUpdatingCameraImage';
import { useNavigate } from 'react-router-dom';
import { routesPath } from '../../router/routes.path';
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) => ({
@@ -15,9 +16,15 @@ const useStyles = createStyles((theme) => ({
flexDirection: 'column',
backgroundColor: theme.colorScheme === 'dark' ? theme.fn.darken(theme.colors.gray[7], 0.5) : theme.colors.gray[2],
'&:hover': {
- 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: {
marginTop: 'auto',
},
@@ -36,8 +43,7 @@ const CameraCard = ({
}: CameraCardProps) => {
const { classes } = useStyles();
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 url = routesPath.LIVE_PATH.replace(':id', camera.id)
@@ -52,14 +58,10 @@ const CameraCard = ({
return (
- {/* */}
{camera.name} / {camera.frigateHost?.name}
-
+
+
+
diff --git a/src/shared/components/CenterLoader.tsx b/src/shared/components/CenterLoader.tsx
index 397be5f..42732c0 100644
--- a/src/shared/components/CenterLoader.tsx
+++ b/src/shared/components/CenterLoader.tsx
@@ -1,6 +1,6 @@
import { DEFAULT_THEME, Loader, LoadingOverlay } from '@mantine/core';
import React from 'react';
-import СogwheelSVG from './svg/СogwheelSVG';
+import СogwheelSVG from './svg/CogwheelSVG';
const CenterLoader = () => {
return ;
diff --git a/src/shared/components/СogwheelLoader.tsx b/src/shared/components/CogwheelLoader.tsx
similarity index 51%
rename from src/shared/components/СogwheelLoader.tsx
rename to src/shared/components/CogwheelLoader.tsx
index e47081b..fe2ea4f 100644
--- a/src/shared/components/СogwheelLoader.tsx
+++ b/src/shared/components/CogwheelLoader.tsx
@@ -1,14 +1,14 @@
import React from 'react';
import { Center, DEFAULT_THEME } from '@mantine/core';
-import СogwheelSVG from './svg/СogwheelSVG';
+import CogwheelSVG from './svg/CogwheelSVG';
-const СogwheelLoader = () => {
+const CogwheelLoader = () => {
return (
- {СogwheelSVG}
+ {CogwheelSVG}
);
};
-export default СogwheelLoader;
\ No newline at end of file
+export default CogwheelLoader;
\ No newline at end of file
diff --git a/src/shared/components/FullProductModal.tsx b/src/shared/components/FullProductModal.tsx
index 338cd0f..9851c66 100644
--- a/src/shared/components/FullProductModal.tsx
+++ b/src/shared/components/FullProductModal.tsx
@@ -7,7 +7,7 @@ import { Modal, createStyles, getStylesRef, rem, Text, Box, Flex, Grid, Divider,
import { useMediaQuery } from '@mantine/hooks';
import { dimensions } from '../dimensions/dimensions';
import { observer } from 'mobx-react-lite';
-import KomponentLoader from './СogwheelLoader';
+import KomponentLoader from './CogwheelLoader';
import ProductParameter from './ProductParameter';
import { productString } from '../strings/product.strings';
import { IconArrowBadgeLeft, IconArrowBadgeRight } from '@tabler/icons-react';
diff --git a/src/shared/components/SideBarLoader.tsx b/src/shared/components/SideBarLoader.tsx
index 14ecaba..ce99c11 100644
--- a/src/shared/components/SideBarLoader.tsx
+++ b/src/shared/components/SideBarLoader.tsx
@@ -1,6 +1,6 @@
import { Box, LoadingOverlay } from '@mantine/core';
import React from 'react';
-import СogwheelSVG from './svg/СogwheelSVG';
+import СogwheelSVG from './svg/CogwheelSVG';
const SideBarLoader = () => {
return (
diff --git a/src/shared/components/frigate/AutoUpdatingCameraImage.tsx b/src/shared/components/frigate/AutoUpdatingCameraImage.tsx
index 94e1d09..5630c54 100644
--- a/src/shared/components/frigate/AutoUpdatingCameraImage.tsx
+++ b/src/shared/components/frigate/AutoUpdatingCameraImage.tsx
@@ -12,6 +12,7 @@ interface AutoUpdatingCameraImageProps extends React.ImgHTMLAttributes
-
+ /> */}
{showFps ? Displaying at {fps}fps : null}
//
diff --git a/src/shared/components/frigate/Button.jsx b/src/shared/components/frigate/Button.jsx
new file mode 100644
index 0000000..f422e40
--- /dev/null
+++ b/src/shared/components/frigate/Button.jsx
@@ -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 (
+
+
+ {children}
+
+ {hovered && attrs['aria-label'] ? : null}
+
+ );
+}
diff --git a/src/shared/components/frigate/CameraImage.tsx b/src/shared/components/frigate/CameraImage.tsx
index 77de137..956d426 100644
--- a/src/shared/components/frigate/CameraImage.tsx
+++ b/src/shared/components/frigate/CameraImage.tsx
@@ -1,51 +1,63 @@
import { useEffect, useRef } from "react";
import { CameraConfig } from "../../../types/frigateConfig";
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 {
className?: string;
cameraConfig?: CameraConfig;
onload?: () => void;
- url: string,
- enabled?: boolean
-};
+ imageUrl: string;
+ enabled?: boolean;
+}
-const useStyles = createStyles((theme) => ({
-
- }))
-
-
-export default function CameraImage({
- className,
- cameraConfig,
- onload,
+const AutoUpdatedImage = ({
+ imageUrl,
enabled,
- url, ...rest }: CameraImageProps) {
- const imgRef = useRef(null);
- const { classes } = useStyles();
-
+ ...rest
+}: CameraImageProps) => {
+ const { data: imageBlob, refetch, isPending, isError } = useQuery({
+ queryKey: ['image', imageUrl],
+ queryFn: () => proxyApi.getImageFrigate(imageUrl),
+ staleTime: 60 * 1000,
+ gcTime: Infinity,
+ refetchInterval: 60 * 1000,
+ });
useEffect(() => {
- if (!cameraConfig || !imgRef.current) {
- return;
- }
- imgRef.current.src = url
- }, [imgRef]);
+ const intervalId = setInterval(() => {
+ refetch();
+ }, 60 * 1000);
+
+ return () => clearInterval(intervalId);
+ }, [refetch]);
+
+
+
+ if (isPending) return
+
+ if (isError) return (
+
+ Error loading!
+
+ )
+
+ if (!imageBlob || !(imageBlob instanceof Blob)) console.error('imageBlob not Blob object:', imageBlob)
+
+ const image = URL.createObjectURL(imageBlob!)
return (
-
- {enabled ? (
-
-
-
- ) : (
-
- Camera is disabled in config, no stream or snapshot available!
-
- )}
-
- );
-}
+ <>
+ {enabled ?
+ :
+
+ Camera is disabled in config, no stream or snapshot available!
+
+ }
+ >)
+};
+
+export default AutoUpdatedImage
\ No newline at end of file
diff --git a/src/shared/components/frigate/Dialog.jsx b/src/shared/components/frigate/Dialog.jsx
new file mode 100644
index 0000000..6bf9e31
--- /dev/null
+++ b/src/shared/components/frigate/Dialog.jsx
@@ -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 = (
+
+
+
+ );
+
+ return portalRoot ? createPortal(dialog, portalRoot) : dialog;
+}
diff --git a/src/shared/components/frigate/Link.jsx b/src/shared/components/frigate/Link.jsx
new file mode 100644
index 0000000..3547996
--- /dev/null
+++ b/src/shared/components/frigate/Link.jsx
@@ -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 (
+
+ {children}
+
+ );
+}
diff --git a/src/shared/components/frigate/Menu.jsx b/src/shared/components/frigate/Menu.jsx
new file mode 100644
index 0000000..34ff203
--- /dev/null
+++ b/src/shared/components/frigate/Menu.jsx
@@ -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 ? (
+
+ ) : 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 (
+
+ {Icon ? (
+
+
+
+ ) : null}
+ {label}
+
+ );
+}
+
+export function MenuSeparator() {
+ return ;
+}
diff --git a/src/shared/components/frigate/MultiSelect.jsx b/src/shared/components/frigate/MultiSelect.jsx
new file mode 100644
index 0000000..5c706fd
--- /dev/null
+++ b/src/shared/components/frigate/MultiSelect.jsx
@@ -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 (
+
+
setState({ showMenu: true })}>
+
+
+
+ {state.showMenu ? (
+
+ ) : null}
+
+ );
+}
diff --git a/src/shared/components/frigate/Player.tsx b/src/shared/components/frigate/Player.tsx
index 1d4fb0d..f187883 100644
--- a/src/shared/components/frigate/Player.tsx
+++ b/src/shared/components/frigate/Player.tsx
@@ -7,7 +7,7 @@ import useCameraActivity from '../../../hooks/use-camera-activity';
import useCameraLiveMode from '../../../hooks/use-camera-live-mode';
import WebRtcPlayer from './WebRTCPlayer';
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';
type LivePlayerProps = {
@@ -24,7 +24,7 @@ const Player = ({
}: LivePlayerProps) => {
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 { activeMotion, activeAudio, activeTracking } =
diff --git a/src/shared/components/frigate/Tabs.jsx b/src/shared/components/frigate/Tabs.jsx
new file mode 100644
index 0000000..2e14227
--- /dev/null
+++ b/src/shared/components/frigate/Tabs.jsx
@@ -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 (
+
+
+
+ );
+}
+
+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 (
+
+ );
+}
diff --git a/src/shared/components/frigate/TimeAgo.tsx b/src/shared/components/frigate/TimeAgo.tsx
new file mode 100644
index 0000000..38c80c7
--- /dev/null
+++ b/src/shared/components/frigate/TimeAgo.tsx
@@ -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 = ({ refreshInterval = 1000, ...rest }): JSX.Element => {
+ const [currentTime, setCurrentTime] = useState(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 {timeAgoValue};
+};
+export default TimeAgo;
diff --git a/src/shared/components/frigate/TimelineEventOverlay.jsx b/src/shared/components/frigate/TimelineEventOverlay.jsx
new file mode 100644
index 0000000..1cff550
--- /dev/null
+++ b/src/shared/components/frigate/TimelineEventOverlay.jsx
@@ -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 (
+
+ 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' ? (
+
+ ) : null}
+
+ {isHovering && (
+
+
{`Area: ${getObjectArea()} px`}
+
{`Ratio: ${getObjectRatio()}`}
+
+ )}
+
+ );
+}
diff --git a/src/shared/components/frigate/TimelineSummary.jsx b/src/shared/components/frigate/TimelineSummary.jsx
new file mode 100644
index 0000000..62e3b4c
--- /dev/null
+++ b/src/shared/components/frigate/TimelineSummary.jsx
@@ -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 ;
+ }
+
+ if (eventTimeline.length == 0) {
+ return ;
+ }
+
+ return (
+
+
+
+ {eventTimeline.map((item, index) => (
+
+ ))}
+
+
+ {timeIndex >= 0 ? (
+
+
+
Bounding boxes may not align
+
+
+
+ ) : null}
+
+ );
+}
+
+function getTimelineIcon(timelineItem) {
+ switch (timelineItem.class_type) {
+ case 'visible':
+ return ;
+ case 'gone':
+ return ;
+ case 'active':
+ return ;
+ case 'stationary':
+ return ;
+ case 'entered_zone':
+ return ;
+ case 'attribute':
+ switch (timelineItem.data.attribute) {
+ case 'face':
+ return ;
+ case 'license_plate':
+ return ;
+ default:
+ return ;
+ }
+ case 'sub_label':
+ switch (timelineItem.data.label) {
+ case 'person':
+ return ;
+ case 'car':
+ return ;
+ }
+ }
+}
+
+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,
+ })}`;
+ }
+}
diff --git a/src/shared/components/frigate/Tooltip.jsx b/src/shared/components/frigate/Tooltip.jsx
new file mode 100644
index 0000000..626239b
--- /dev/null
+++ b/src/shared/components/frigate/Tooltip.jsx
@@ -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 = (
+ = 0 ? 'opacity-100 scale-100' : ''}`}
+ ref={ref}
+ style={position}
+ >
+ {text}
+
+ );
+
+ return portalRoot ? createPortal(tooltip, portalRoot) : tooltip;
+}
diff --git a/src/shared/components/frigate/VideoPlayer.jsx b/src/shared/components/frigate/VideoPlayer.jsx
new file mode 100644
index 0000000..638cba4
--- /dev/null
+++ b/src/shared/components/frigate/VideoPlayer.jsx
@@ -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 (
+
+ {/* Setting an empty data-setup is required to override the default values and allow video to be fit the size of its parent */}
+
+ {children}
+
+ );
+}
\ No newline at end of file
diff --git a/src/shared/components/frigate/dateUtil.ts b/src/shared/components/frigate/dateUtil.ts
new file mode 100644
index 0000000..d2f5cb4
--- /dev/null
+++ b/src/shared/components/frigate/dateUtil.ts
@@ -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;
+};
diff --git a/src/shared/components/frigate/icons/About.jsx b/src/shared/components/frigate/icons/About.jsx
new file mode 100644
index 0000000..6271b2d
--- /dev/null
+++ b/src/shared/components/frigate/icons/About.jsx
@@ -0,0 +1,19 @@
+import { h } from 'preact';
+import { memo } from 'preact/compat';
+
+export function About({ className = '' }) {
+ return (
+
+ );
+}
+
+export default memo(About);
diff --git a/src/shared/components/frigate/icons/CalendarIcon.jsx b/src/shared/components/frigate/icons/CalendarIcon.jsx
new file mode 100644
index 0000000..6558f1a
--- /dev/null
+++ b/src/shared/components/frigate/icons/CalendarIcon.jsx
@@ -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 (
+
+ );
+}
+
+export default memo(CalendarIcon);
diff --git a/src/shared/components/frigate/icons/Camera.jsx b/src/shared/components/frigate/icons/Camera.jsx
new file mode 100644
index 0000000..fd0fcd1
--- /dev/null
+++ b/src/shared/components/frigate/icons/Camera.jsx
@@ -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 (
+
+ );
+}
+
+export default memo(Camera);
diff --git a/src/shared/components/frigate/icons/Clip.jsx b/src/shared/components/frigate/icons/Clip.jsx
new file mode 100644
index 0000000..a7fd815
--- /dev/null
+++ b/src/shared/components/frigate/icons/Clip.jsx
@@ -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 (
+
+ );
+}
+
+export default memo(Clip);
diff --git a/src/shared/components/frigate/icons/Clock.jsx b/src/shared/components/frigate/icons/Clock.jsx
new file mode 100644
index 0000000..e813e00
--- /dev/null
+++ b/src/shared/components/frigate/icons/Clock.jsx
@@ -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 (
+
+ );
+}
+
+export default memo(Clock);
diff --git a/src/shared/components/frigate/icons/Delete.jsx b/src/shared/components/frigate/icons/Delete.jsx
new file mode 100644
index 0000000..f00da15
--- /dev/null
+++ b/src/shared/components/frigate/icons/Delete.jsx
@@ -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 (
+
+ );
+}
+
+export default memo(Delete);
diff --git a/src/shared/components/frigate/icons/Download.jsx b/src/shared/components/frigate/icons/Download.jsx
new file mode 100644
index 0000000..cd6227b
--- /dev/null
+++ b/src/shared/components/frigate/icons/Download.jsx
@@ -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 (
+
+ );
+}
+
+export default memo(Download);
diff --git a/src/shared/components/frigate/icons/Menu.jsx b/src/shared/components/frigate/icons/Menu.jsx
new file mode 100644
index 0000000..76ea9dd
--- /dev/null
+++ b/src/shared/components/frigate/icons/Menu.jsx
@@ -0,0 +1,13 @@
+import { h } from 'preact';
+import { memo } from 'preact/compat';
+
+export function Menu({ className = '' }) {
+ return (
+
+ );
+}
+
+export default memo(Menu);
diff --git a/src/shared/components/frigate/icons/MenuOpen.jsx b/src/shared/components/frigate/icons/MenuOpen.jsx
new file mode 100644
index 0000000..c3b2830
--- /dev/null
+++ b/src/shared/components/frigate/icons/MenuOpen.jsx
@@ -0,0 +1,13 @@
+import { h } from 'preact';
+import { memo } from 'preact/compat';
+
+export function MenuOpen({ className = '' }) {
+ return (
+
+ );
+}
+
+export default memo(MenuOpen);
diff --git a/src/shared/components/frigate/icons/Score.jsx b/src/shared/components/frigate/icons/Score.jsx
new file mode 100644
index 0000000..2abed4b
--- /dev/null
+++ b/src/shared/components/frigate/icons/Score.jsx
@@ -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 (
+
+ );
+}
+
+export default memo(Score);
diff --git a/src/shared/components/frigate/icons/Snapshot.jsx b/src/shared/components/frigate/icons/Snapshot.jsx
new file mode 100644
index 0000000..696b080
--- /dev/null
+++ b/src/shared/components/frigate/icons/Snapshot.jsx
@@ -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 (
+
+ );
+}
+
+export default memo(Snapshot);
diff --git a/src/shared/components/frigate/icons/StarRecording.jsx b/src/shared/components/frigate/icons/StarRecording.jsx
new file mode 100644
index 0000000..e4923b6
--- /dev/null
+++ b/src/shared/components/frigate/icons/StarRecording.jsx
@@ -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 (
+
+ );
+}
+
+export default memo(StarRecording);
diff --git a/src/shared/components/frigate/icons/Submitted.jsx b/src/shared/components/frigate/icons/Submitted.jsx
new file mode 100644
index 0000000..e7612a7
--- /dev/null
+++ b/src/shared/components/frigate/icons/Submitted.jsx
@@ -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 (
+
+ );
+}
+
+export default memo(Submitted);
diff --git a/src/shared/components/frigate/icons/UploadPlus.jsx b/src/shared/components/frigate/icons/UploadPlus.jsx
new file mode 100644
index 0000000..7fd2f88
--- /dev/null
+++ b/src/shared/components/frigate/icons/UploadPlus.jsx
@@ -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 (
+
+ );
+}
+
+export default memo(UploadPlus);
diff --git a/src/shared/components/frigate/icons/Zone.jsx b/src/shared/components/frigate/icons/Zone.jsx
new file mode 100644
index 0000000..b19205d
--- /dev/null
+++ b/src/shared/components/frigate/icons/Zone.jsx
@@ -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 (
+
+ );
+}
+
+export default memo(Zone);
diff --git a/src/shared/components/svg/СogwheelSVG.tsx b/src/shared/components/svg/CogwheelSVG.tsx
similarity index 96%
rename from src/shared/components/svg/СogwheelSVG.tsx
rename to src/shared/components/svg/CogwheelSVG.tsx
index 7f75f98..830e08e 100644
--- a/src/shared/components/svg/СogwheelSVG.tsx
+++ b/src/shared/components/svg/CogwheelSVG.tsx
@@ -1,7 +1,7 @@
import { DEFAULT_THEME } from '@mantine/core';
import React from 'react';
-const СogwheelSVG = (
+const CogwheelSVG = (