From b4a380aba78a07549d6868744884984943e0f200 Mon Sep 17 00:00:00 2001 From: NlightN22 Date: Fri, 23 Feb 2024 03:01:12 +0700 Subject: [PATCH] fix loader start implementing events redisign retryerror change image thumbs to cached and useQuery --- package.json | 3 + src/pages/EventsPage.tsx | 934 ++++++++++++++++++ src/pages/HostConfigPage.tsx | 4 +- src/pages/LiveCameraPage.tsx | 9 +- src/pages/MainBody.tsx | 8 +- src/pages/RetryError.tsx | 15 +- src/services/frigate.proxy/frigate.api.ts | 35 +- src/shared/components/CameraCard.tsx | 26 +- src/shared/components/CenterLoader.tsx | 2 +- ...{СogwheelLoader.tsx => CogwheelLoader.tsx} | 8 +- src/shared/components/FullProductModal.tsx | 2 +- src/shared/components/SideBarLoader.tsx | 2 +- .../frigate/AutoUpdatingCameraImage.tsx | 5 +- src/shared/components/frigate/Button.jsx | 115 +++ src/shared/components/frigate/CameraImage.tsx | 86 +- src/shared/components/frigate/Dialog.jsx | 35 + src/shared/components/frigate/Link.jsx | 16 + src/shared/components/frigate/Menu.jsx | 48 + src/shared/components/frigate/MultiSelect.jsx | 70 ++ src/shared/components/frigate/Player.tsx | 4 +- src/shared/components/frigate/Tabs.jsx | 41 + src/shared/components/frigate/TimeAgo.tsx | 83 ++ .../frigate/TimelineEventOverlay.jsx | 65 ++ .../components/frigate/TimelineSummary.jsx | 218 ++++ src/shared/components/frigate/Tooltip.jsx | 62 ++ src/shared/components/frigate/VideoPlayer.jsx | 100 ++ src/shared/components/frigate/dateUtil.ts | 237 +++++ src/shared/components/frigate/icons/About.jsx | 19 + .../components/frigate/icons/CalendarIcon.jsx | 24 + .../components/frigate/icons/Camera.jsx | 24 + src/shared/components/frigate/icons/Clip.jsx | 24 + src/shared/components/frigate/icons/Clock.jsx | 24 + .../components/frigate/icons/Delete.jsx | 24 + .../components/frigate/icons/Download.jsx | 24 + src/shared/components/frigate/icons/Menu.jsx | 13 + .../components/frigate/icons/MenuOpen.jsx | 13 + src/shared/components/frigate/icons/Score.jsx | 20 + .../components/frigate/icons/Snapshot.jsx | 24 + .../frigate/icons/StarRecording.jsx | 24 + .../components/frigate/icons/Submitted.jsx | 19 + .../components/frigate/icons/UploadPlus.jsx | 23 + src/shared/components/frigate/icons/Zone.jsx | 25 + .../svg/{СogwheelSVG.tsx => CogwheelSVG.tsx} | 4 +- yarn.lock | 15 + 44 files changed, 2483 insertions(+), 93 deletions(-) create mode 100644 src/pages/EventsPage.tsx rename src/shared/components/{СogwheelLoader.tsx => CogwheelLoader.tsx} (51%) create mode 100644 src/shared/components/frigate/Button.jsx create mode 100644 src/shared/components/frigate/Dialog.jsx create mode 100644 src/shared/components/frigate/Link.jsx create mode 100644 src/shared/components/frigate/Menu.jsx create mode 100644 src/shared/components/frigate/MultiSelect.jsx create mode 100644 src/shared/components/frigate/Tabs.jsx create mode 100644 src/shared/components/frigate/TimeAgo.tsx create mode 100644 src/shared/components/frigate/TimelineEventOverlay.jsx create mode 100644 src/shared/components/frigate/TimelineSummary.jsx create mode 100644 src/shared/components/frigate/Tooltip.jsx create mode 100644 src/shared/components/frigate/VideoPlayer.jsx create mode 100644 src/shared/components/frigate/dateUtil.ts create mode 100644 src/shared/components/frigate/icons/About.jsx create mode 100644 src/shared/components/frigate/icons/CalendarIcon.jsx create mode 100644 src/shared/components/frigate/icons/Camera.jsx create mode 100644 src/shared/components/frigate/icons/Clip.jsx create mode 100644 src/shared/components/frigate/icons/Clock.jsx create mode 100644 src/shared/components/frigate/icons/Delete.jsx create mode 100644 src/shared/components/frigate/icons/Download.jsx create mode 100644 src/shared/components/frigate/icons/Menu.jsx create mode 100644 src/shared/components/frigate/icons/MenuOpen.jsx create mode 100644 src/shared/components/frigate/icons/Score.jsx create mode 100644 src/shared/components/frigate/icons/Snapshot.jsx create mode 100644 src/shared/components/frigate/icons/StarRecording.jsx create mode 100644 src/shared/components/frigate/icons/Submitted.jsx create mode 100644 src/shared/components/frigate/icons/UploadPlus.jsx create mode 100644 src/shared/components/frigate/icons/Zone.jsx rename src/shared/components/svg/{СogwheelSVG.tsx => CogwheelSVG.tsx} (96%) 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 && ( +// setState({ ...state, showDownloadMenu: false })} relativeTo={downloadButton}> +// {downloadEvent.has_snapshot && ( +// +// )} +// {downloadEvent.has_clip && ( +// +// )} +// {(event?.data?.type || 'object') == 'object' && +// downloadEvent.end_time && +// downloadEvent.has_snapshot && +// !downloadEvent.plus_id && ( +// showSubmitToPlus(downloadEvent.id, downloadEvent.label, downloadEvent.box)} +// /> +// )} +// {downloadEvent.plus_id && ( +// setState({ ...state, showDownloadMenu: false })} +// /> +// )} +// +// )} +// {state.showDatePicker && ( +// setState({ ...state, setShowDatePicker: false })} +// relativeTo={datePicker} +// > +// +// +// +// +// +// +// { +// setState({ ...state, showCalendar: true, showDatePicker: false }); +// }} +// /> +// +// )} + +// {state.showCalendar && ( +// +// setState({ ...state, showCalendar: false })} +// relativeTo={datePicker} +// > +// setState({ ...state, showCalendar: false })} +// > +// +// +// +// +// )} +// {state.showPlusSubmit && ( +// +// {config.plus.enabled ? ( +// <> +//
+// Submit to Frigate+ + +// {`${plusSubmitEvent.label}`} + +// {plusSubmitEvent.validBox ? ( +//

+// Objects in locations you want to avoid are not false positives. Submitting them as false positives +// will confuse the model. +//

+// ) : ( +//

+// Events prior to version 0.13 can only be submitted to Frigate+ without annotations. +//

+// )} +//
+// {plusSubmitEvent.validBox ? ( +//
+// +// +// +//
+// ) : ( +//
+// +// +//
+// )} +// +// ) : ( +// <> +//
+// Setup a Frigate+ Account +//

In order to submit images to Frigate+, you first need to setup an account.

+// +// https://plus.frigate.video +// +//
+//
+// +//
+// +// )} +//
+// )} +// {deleteFavoriteState.showDeleteFavorite && ( +// +//
+// Delete Saved Event? +//

Confirm deletion of saved event.

+//
+//
+// +// +//
+//
+// )} +//
+// {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} +// /> +// ); +// }); +// }) +// ) : ( +// +// )} +//
+//
{isDone ? null : }
+//
+// ); +// } + +// 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)}%`} +//
+//
+// +//
+// 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 ? ( +//
+// {`${event.label} +//
+// ) : 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 ? Dynamic Content + : + + 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 = ( + +
+
+ {children} +
+
+
+ ); + + 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 ? ( + setState({ showMenu: false })} + > +
+ + {title} + + +
+ {options.map((item) => ( +
+ +
+ +
+
+ ))} +
+ ) : 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 */} +
+ ); +} \ 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 ( + + percent + + + ); +} + +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 = ( ); -export default СogwheelSVG; \ No newline at end of file +export default CogwheelSVG; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 3f58e27..8f0ac22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2572,6 +2572,11 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" 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": version "5.14.7" 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-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: version "1.11.9" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.9.tgz#9ca491933fadd0a60a2c19f6c237c03517d71d1a" @@ -8939,6 +8949,11 @@ stop-iteration-iterator@^1.0.0: dependencies: 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: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"