fix loader
start implementing events redisign retryerror change image thumbs to cached and useQuery
This commit is contained in:
parent
717e5e633b
commit
b4a380aba7
@ -23,6 +23,7 @@
|
|||||||
"axios": "^1.4.0",
|
"axios": "^1.4.0",
|
||||||
"bson-objectid": "^2.0.4",
|
"bson-objectid": "^2.0.4",
|
||||||
"cookies-next": "^4.1.1",
|
"cookies-next": "^4.1.1",
|
||||||
|
"date-fns": "^3.3.1",
|
||||||
"dayjs": "^1.11.9",
|
"dayjs": "^1.11.9",
|
||||||
"embla-carousel-react": "^8.0.0-rc10",
|
"embla-carousel-react": "^8.0.0-rc10",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
@ -39,6 +40,7 @@
|
|||||||
"react-router-dom": "^6.14.1",
|
"react-router-dom": "^6.14.1",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"react-use-websocket": "^4.7.0",
|
"react-use-websocket": "^4.7.0",
|
||||||
|
"strftime": "0.10.1",
|
||||||
"typescript": "^4.4.2",
|
"typescript": "^4.4.2",
|
||||||
"validator": "^13.9.0",
|
"validator": "^13.9.0",
|
||||||
"web-vitals": "^2.1.0",
|
"web-vitals": "^2.1.0",
|
||||||
@ -70,6 +72,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||||
|
"@types/strftime": "^0.9.8",
|
||||||
"@types/uuid": "^9.0.2",
|
"@types/uuid": "^9.0.2",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
934
src/pages/EventsPage.tsx
Normal file
934
src/pages/EventsPage.tsx
Normal file
@ -0,0 +1,934 @@
|
|||||||
|
// // import { route } from 'preact-router';
|
||||||
|
// import { Fragment, useState, useRef, useCallback, useMemo } from 'react';
|
||||||
|
// import VideoPlayer from '../shared/components/frigate/VideoPlayer';
|
||||||
|
// import CogwheelLoader from '../shared/components/CogwheelLoader';
|
||||||
|
// import { Grid, Text } from '@mantine/core';
|
||||||
|
// import MultiSelect from '../shared/components/frigate/MultiSelect';
|
||||||
|
// import Button from '../shared/components/frigate/Button';
|
||||||
|
// import StarRecording from '../shared/components/frigate/icons/StarRecording';
|
||||||
|
// import Submitted from '../shared/components/frigate/icons/Submitted';
|
||||||
|
// import CalendarIcon from '../shared/components/frigate/icons/CalendarIcon';
|
||||||
|
// import Menu, { MenuItem } from '../shared/components/frigate/Menu';
|
||||||
|
// import Dialog from '../shared/components/frigate/Dialog';
|
||||||
|
// import TimelineSummary from '../shared/components/frigate/TimelineSummary';
|
||||||
|
// import TimelineEventOverlay from '../shared/components/frigate/TimelineEventOverlay';
|
||||||
|
// import { Tabs, TextTab } from '../shared/components/frigate/Tabs';
|
||||||
|
// import Clock from '../shared/components/frigate/icons/Clock';
|
||||||
|
// import TimeAgo from '../shared/components/frigate/TimeAgo';
|
||||||
|
// import Camera from '../shared/components/frigate/icons/Camera';
|
||||||
|
// import Zone from '../shared/components/frigate/icons/Zone';
|
||||||
|
// import Score from '../shared/components/frigate/icons/Score';
|
||||||
|
// import Link from '../shared/components/frigate/Link';
|
||||||
|
// import Delete from '../shared/components/frigate/icons/Delete';
|
||||||
|
// import Download from '../shared/components/frigate/icons/Download';
|
||||||
|
|
||||||
|
export default function EventsPage() {
|
||||||
|
return (
|
||||||
|
<div />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// const API_LIMIT = 25;
|
||||||
|
|
||||||
|
// const daysAgo = (num: number) => {
|
||||||
|
// let date = new Date();
|
||||||
|
// date.setDate(date.getDate() - num);
|
||||||
|
// return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const monthsAgo = (num: number) => {
|
||||||
|
// let date = new Date();
|
||||||
|
// date.setMonth(date.getMonth() - num);
|
||||||
|
// return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// export default function Events({ path, ...props }) {
|
||||||
|
// // const apiHost = useApiHost();
|
||||||
|
// // const { data: config } = useSWR('config');
|
||||||
|
// const timezone = useMemo(() => config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [config]);
|
||||||
|
// const [searchParams, setSearchParams] = useState({
|
||||||
|
// before: null,
|
||||||
|
// after: null,
|
||||||
|
// cameras: props.cameras ?? 'all',
|
||||||
|
// labels: props.labels ?? 'all',
|
||||||
|
// zones: props.zones ?? 'all',
|
||||||
|
// sub_labels: props.sub_labels ?? 'all',
|
||||||
|
// time_range: '00:00,24:00',
|
||||||
|
// timezone,
|
||||||
|
// favorites: props.favorites ?? 0,
|
||||||
|
// is_submitted: props.is_submitted ?? -1,
|
||||||
|
// event: props.event,
|
||||||
|
// });
|
||||||
|
// const [state, setState] = useState({
|
||||||
|
// showDownloadMenu: false,
|
||||||
|
// showDatePicker: false,
|
||||||
|
// showCalendar: false,
|
||||||
|
// showPlusSubmit: false,
|
||||||
|
// });
|
||||||
|
// const [plusSubmitEvent, setPlusSubmitEvent] = useState({
|
||||||
|
// id: null,
|
||||||
|
// label: null,
|
||||||
|
// validBox: null,
|
||||||
|
// });
|
||||||
|
// const [uploading, setUploading] = useState([]);
|
||||||
|
// const [viewEvent, setViewEvent] = useState(props.event);
|
||||||
|
// const [eventOverlay, setEventOverlay] = useState();
|
||||||
|
// const [eventDetailType, setEventDetailType] = useState('clip');
|
||||||
|
// const [downloadEvent, setDownloadEvent] = useState({
|
||||||
|
// id: null,
|
||||||
|
// label: null,
|
||||||
|
// box: null,
|
||||||
|
// has_clip: false,
|
||||||
|
// has_snapshot: false,
|
||||||
|
// plus_id: undefined,
|
||||||
|
// end_time: null,
|
||||||
|
// });
|
||||||
|
// const [deleteFavoriteState, setDeleteFavoriteState] = useState({
|
||||||
|
// deletingFavoriteEventId: null,
|
||||||
|
// showDeleteFavorite: false,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const [showInProgress, setShowInProgress] = useState((props.event || props.cameras || props.labels) == null);
|
||||||
|
|
||||||
|
// const eventsFetcher = useCallback(
|
||||||
|
// (path, params) => {
|
||||||
|
// if (searchParams.event) {
|
||||||
|
// path = `${path}/${searchParams.event}`;
|
||||||
|
// return axios.get(path).then((res) => [res.data]);
|
||||||
|
// }
|
||||||
|
// params = { ...params, in_progress: 0, include_thumbnails: 0, limit: API_LIMIT };
|
||||||
|
// return axios.get(path, { params }).then((res) => res.data);
|
||||||
|
// },
|
||||||
|
// [searchParams]
|
||||||
|
// );
|
||||||
|
|
||||||
|
// const getKey = useCallback(
|
||||||
|
// (index, prevData) => {
|
||||||
|
// if (index > 0) {
|
||||||
|
// const lastDate = prevData[prevData.length - 1].start_time;
|
||||||
|
// const pagedParams = { ...searchParams, before: lastDate };
|
||||||
|
// return ['events', pagedParams];
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return ['events', searchParams];
|
||||||
|
// },
|
||||||
|
// [searchParams]
|
||||||
|
// );
|
||||||
|
|
||||||
|
// const { data: ongoingEvents, mutate: refreshOngoingEvents } = useSWR([
|
||||||
|
// 'events',
|
||||||
|
// { in_progress: 1, include_thumbnails: 0 },
|
||||||
|
// ]);
|
||||||
|
// // const {
|
||||||
|
// // data: eventPages,
|
||||||
|
// // mutate: refreshEvents,
|
||||||
|
// // size,
|
||||||
|
// // setSize,
|
||||||
|
// // isValidating,
|
||||||
|
// // } = useSWRInfinite(getKey, eventsFetcher);
|
||||||
|
// // const mutate = () => {
|
||||||
|
// // refreshEvents();
|
||||||
|
// // refreshOngoingEvents();
|
||||||
|
// // };
|
||||||
|
|
||||||
|
// // const { data: allLabels } = useSWR(['labels']);
|
||||||
|
// // const { data: allSubLabels } = useSWR(['sub_labels', { split_joined: 1 }]);
|
||||||
|
|
||||||
|
// const filterValues = useMemo(
|
||||||
|
// () => ({
|
||||||
|
// cameras: Object.keys(config?.cameras || {}),
|
||||||
|
// zones: [
|
||||||
|
// ...Object.values(config?.cameras || {})
|
||||||
|
// .reduce((memo, camera) => {
|
||||||
|
// memo = memo.concat(Object.keys(camera?.zones || {}));
|
||||||
|
// return memo;
|
||||||
|
// }, [])
|
||||||
|
// .filter((value, i, self) => self.indexOf(value) === i),
|
||||||
|
// 'None',
|
||||||
|
// ],
|
||||||
|
// labels: Object.values(allLabels || {}),
|
||||||
|
// sub_labels: (allSubLabels || []).length > 0 ? [...Object.values(allSubLabels), 'None'] : [],
|
||||||
|
// }),
|
||||||
|
// [config, allLabels, allSubLabels]
|
||||||
|
// );
|
||||||
|
|
||||||
|
// const onSave = async (e, eventId, save) => {
|
||||||
|
// e.stopPropagation();
|
||||||
|
// let response;
|
||||||
|
// if (save) {
|
||||||
|
// response = await axios.post(`events/${eventId}/retain`);
|
||||||
|
// } else {
|
||||||
|
// response = await axios.delete(`events/${eventId}/retain`);
|
||||||
|
// }
|
||||||
|
// if (response.status === 200) {
|
||||||
|
// mutate();
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const onDelete = async (e, eventId, saved) => {
|
||||||
|
// e.stopPropagation();
|
||||||
|
|
||||||
|
// if (saved) {
|
||||||
|
// setDeleteFavoriteState({ deletingFavoriteEventId: eventId, showDeleteFavorite: true });
|
||||||
|
// } else {
|
||||||
|
// const response = await axios.delete(`events/${eventId}`);
|
||||||
|
// if (response.status === 200) {
|
||||||
|
// mutate();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const onToggleNamedFilter = (name, item) => {
|
||||||
|
// let items;
|
||||||
|
|
||||||
|
// if (searchParams[name] == 'all') {
|
||||||
|
// const currentItems = Array.from(filterValues[name]);
|
||||||
|
|
||||||
|
// // don't remove all if only one option
|
||||||
|
// if (currentItems.length > 1) {
|
||||||
|
// currentItems.splice(currentItems.indexOf(item), 1);
|
||||||
|
// items = currentItems.join(',');
|
||||||
|
// } else {
|
||||||
|
// items = ['all'];
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// let currentItems = searchParams[name].length > 0 ? searchParams[name].split(',') : [];
|
||||||
|
|
||||||
|
// if (currentItems.includes(item)) {
|
||||||
|
// // don't remove the last item in the filter list
|
||||||
|
// if (currentItems.length > 1) {
|
||||||
|
// currentItems.splice(currentItems.indexOf(item), 1);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// items = currentItems.join(',');
|
||||||
|
// } else if (currentItems.length + 1 == filterValues[name].length) {
|
||||||
|
// items = ['all'];
|
||||||
|
// } else {
|
||||||
|
// currentItems.push(item);
|
||||||
|
// items = currentItems.join(',');
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// onFilter(name, items);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const onEventFrameSelected = (event, frame, seekSeconds) => {
|
||||||
|
// if (this.player) {
|
||||||
|
// this.player.pause();
|
||||||
|
// this.player.currentTime(seekSeconds);
|
||||||
|
// setEventOverlay(frame);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const datePicker = useRef();
|
||||||
|
|
||||||
|
// const downloadButton = useRef();
|
||||||
|
|
||||||
|
// const onDownloadClick = (e, event) => {
|
||||||
|
// e.stopPropagation();
|
||||||
|
// setDownloadEvent((_prev) => ({
|
||||||
|
// id: event.id,
|
||||||
|
// box: event?.data?.box || event.box,
|
||||||
|
// label: event.label,
|
||||||
|
// has_clip: event.has_clip,
|
||||||
|
// has_snapshot: event.has_snapshot,
|
||||||
|
// plus_id: event.plus_id,
|
||||||
|
// end_time: event.end_time,
|
||||||
|
// }));
|
||||||
|
// downloadButton.current = e.target;
|
||||||
|
// setState({ ...state, showDownloadMenu: true });
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const showSubmitToPlus = (event_id, label, box, e) => {
|
||||||
|
// if (e) {
|
||||||
|
// e.stopPropagation();
|
||||||
|
// }
|
||||||
|
// // if any of the box coordinates are > 1, then the box data is from an older version
|
||||||
|
// // and not valid to submit to plus with the snapshot image
|
||||||
|
// setPlusSubmitEvent({ id: event_id, label, validBox: !box.some((d) => d > 1) });
|
||||||
|
// setState({ ...state, showDownloadMenu: false, showPlusSubmit: true });
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const handleSelectDateRange = useCallback(
|
||||||
|
// (dates) => {
|
||||||
|
// setShowInProgress(false);
|
||||||
|
// setSearchParams({ ...searchParams, before: dates.before, after: dates.after });
|
||||||
|
// setState({ ...state, showDatePicker: false });
|
||||||
|
// },
|
||||||
|
// [searchParams, setSearchParams, state, setState]
|
||||||
|
// );
|
||||||
|
|
||||||
|
// const handleSelectTimeRange = useCallback(
|
||||||
|
// (timeRange) => {
|
||||||
|
// setSearchParams({ ...searchParams, time_range: timeRange });
|
||||||
|
// },
|
||||||
|
// [searchParams]
|
||||||
|
// );
|
||||||
|
|
||||||
|
// const onFilter = useCallback(
|
||||||
|
// (name, value) => {
|
||||||
|
// setShowInProgress(false);
|
||||||
|
// const updatedParams = { ...searchParams, [name]: value };
|
||||||
|
// setSearchParams(updatedParams);
|
||||||
|
// const queryString = Object.keys(updatedParams)
|
||||||
|
// .map((key) => {
|
||||||
|
// if (updatedParams[key] && updatedParams[key] != 'all') {
|
||||||
|
// return `${key}=${updatedParams[key]}`;
|
||||||
|
// }
|
||||||
|
// return null;
|
||||||
|
// })
|
||||||
|
// .filter((val) => val)
|
||||||
|
// .join('&');
|
||||||
|
// route(`${path}?${queryString}`);
|
||||||
|
// },
|
||||||
|
// [path, searchParams, setSearchParams]
|
||||||
|
// );
|
||||||
|
|
||||||
|
// const onClickFilterSubmitted = useCallback(() => {
|
||||||
|
// if (++searchParams.is_submitted > 1) {
|
||||||
|
// searchParams.is_submitted = -1;
|
||||||
|
// }
|
||||||
|
// onFilter('is_submitted', searchParams.is_submitted);
|
||||||
|
// }, [searchParams, onFilter]);
|
||||||
|
|
||||||
|
// const isDone = (eventPages?.[eventPages.length - 1]?.length ?? 0) < API_LIMIT;
|
||||||
|
|
||||||
|
// // hooks for infinite scroll
|
||||||
|
// const observer = useRef();
|
||||||
|
// const lastEventRef = useCallback(
|
||||||
|
// (node) => {
|
||||||
|
// if (isValidating) return;
|
||||||
|
// if (observer.current) observer.current.disconnect();
|
||||||
|
// try {
|
||||||
|
// observer.current = new IntersectionObserver((entries) => {
|
||||||
|
// if (entries[0].isIntersecting && !isDone) {
|
||||||
|
// setSize(size + 1);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// if (node) observer.current.observe(node);
|
||||||
|
// } catch (e) {
|
||||||
|
// // no op
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// [size, setSize, isValidating, isDone]
|
||||||
|
// );
|
||||||
|
|
||||||
|
// const onSendToPlus = async (id, false_positive, validBox) => {
|
||||||
|
// if (uploading.includes(id)) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// setUploading((prev) => [...prev, id]);
|
||||||
|
|
||||||
|
// const response = false_positive
|
||||||
|
// ? await axios.put(`events/${id}/false_positive`)
|
||||||
|
// : await axios.post(`events/${id}/plus`, validBox ? { include_annotation: 1 } : {});
|
||||||
|
|
||||||
|
// if (response.status === 200) {
|
||||||
|
// mutate(
|
||||||
|
// (pages) =>
|
||||||
|
// pages.map((page) =>
|
||||||
|
// page.map((event) => {
|
||||||
|
// if (event.id === id) {
|
||||||
|
// return { ...event, plus_id: response.data.plus_id };
|
||||||
|
// }
|
||||||
|
// return event;
|
||||||
|
// })
|
||||||
|
// ),
|
||||||
|
// false
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// setUploading((prev) => prev.filter((i) => i !== id));
|
||||||
|
|
||||||
|
// if (state.showDownloadMenu && downloadEvent.id === id) {
|
||||||
|
// setState({ ...state, showDownloadMenu: false });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// setState({ ...state, showPlusSubmit: false });
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const handleEventDetailTabChange = (index) => {
|
||||||
|
// setEventDetailType(index == 0 ? 'clip' : 'image');
|
||||||
|
// };
|
||||||
|
|
||||||
|
// if (!config) {
|
||||||
|
// return <CogwheelLoader />;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div className="space-y-4 p-2 px-4 w-full">
|
||||||
|
// <Text>Events</Text>
|
||||||
|
// <div className="flex flex-wrap gap-2 items-center">
|
||||||
|
// <MultiSelect
|
||||||
|
// className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
|
||||||
|
// title="Cameras"
|
||||||
|
// options={filterValues.cameras}
|
||||||
|
// selection={searchParams.cameras}
|
||||||
|
// onToggle={(item) => onToggleNamedFilter('cameras', item)}
|
||||||
|
// onShowAll={() => onFilter('cameras', ['all'])}
|
||||||
|
// onSelectSingle={(item) => onFilter('cameras', item)}
|
||||||
|
// />
|
||||||
|
// <MultiSelect
|
||||||
|
// className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
|
||||||
|
// title="Labels"
|
||||||
|
// options={filterValues.labels}
|
||||||
|
// selection={searchParams.labels}
|
||||||
|
// onToggle={(item) => onToggleNamedFilter('labels', item)}
|
||||||
|
// onShowAll={() => onFilter('labels', ['all'])}
|
||||||
|
// onSelectSingle={(item) => onFilter('labels', item)}
|
||||||
|
// />
|
||||||
|
// <MultiSelect
|
||||||
|
// className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
|
||||||
|
// title="Zones"
|
||||||
|
// options={filterValues.zones}
|
||||||
|
// selection={searchParams.zones}
|
||||||
|
// onToggle={(item) => onToggleNamedFilter('zones', item)}
|
||||||
|
// onShowAll={() => onFilter('zones', ['all'])}
|
||||||
|
// onSelectSingle={(item) => onFilter('zones', item)}
|
||||||
|
// />
|
||||||
|
// {filterValues.sub_labels.length > 0 && (
|
||||||
|
// <MultiSelect
|
||||||
|
// className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
|
||||||
|
// title="Sub Labels"
|
||||||
|
// options={filterValues.sub_labels}
|
||||||
|
// selection={searchParams.sub_labels}
|
||||||
|
// onToggle={(item) => onToggleNamedFilter('sub_labels', item)}
|
||||||
|
// onShowAll={() => onFilter('sub_labels', ['all'])}
|
||||||
|
// onSelectSingle={(item) => onFilter('sub_labels', item)}
|
||||||
|
// />
|
||||||
|
// )}
|
||||||
|
// {searchParams.event && (
|
||||||
|
// <Button className="ml-2" onClick={() => onFilter('event', null)} type="text">
|
||||||
|
// View All
|
||||||
|
// </Button>
|
||||||
|
// )}
|
||||||
|
|
||||||
|
// <div className="ml-auto flex">
|
||||||
|
// {config.plus.enabled && (
|
||||||
|
// <Submitted
|
||||||
|
// className="h-10 w-10 text-yellow-300 cursor-pointer ml-auto"
|
||||||
|
// onClick={() => onClickFilterSubmitted()}
|
||||||
|
// inner_fill={searchParams.is_submitted == 1 ? 'currentColor' : 'gray'}
|
||||||
|
// outer_stroke={searchParams.is_submitted >= 0 ? 'currentColor' : 'gray'}
|
||||||
|
// />
|
||||||
|
// )}
|
||||||
|
|
||||||
|
// <StarRecording
|
||||||
|
// className="h-10 w-10 text-yellow-300 cursor-pointer ml-auto"
|
||||||
|
// onClick={() => onFilter('favorites', searchParams.favorites ? 0 : 1)}
|
||||||
|
// fill={searchParams.favorites == 1 ? 'currentColor' : 'none'}
|
||||||
|
// />
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// <div ref={datePicker} className="ml-right">
|
||||||
|
// <CalendarIcon
|
||||||
|
// className="h-8 w-8 cursor-pointer"
|
||||||
|
// onClick={() => setState({ ...state, showDatePicker: true })}
|
||||||
|
// />
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// {state.showDownloadMenu && (
|
||||||
|
// <Menu onDismiss={() => setState({ ...state, showDownloadMenu: false })} relativeTo={downloadButton}>
|
||||||
|
// {downloadEvent.has_snapshot && (
|
||||||
|
// <MenuItem
|
||||||
|
// icon={Snapshot}
|
||||||
|
// label="Download Snapshot"
|
||||||
|
// value="snapshot"
|
||||||
|
// href={`${apiHost}api/events/${downloadEvent.id}/snapshot.jpg?download=true`}
|
||||||
|
// download
|
||||||
|
// />
|
||||||
|
// )}
|
||||||
|
// {downloadEvent.has_clip && (
|
||||||
|
// <MenuItem
|
||||||
|
// icon={Clip}
|
||||||
|
// label="Download Clip"
|
||||||
|
// value="clip"
|
||||||
|
// href={`${apiHost}api/events/${downloadEvent.id}/clip.mp4?download=true`}
|
||||||
|
// download
|
||||||
|
// />
|
||||||
|
// )}
|
||||||
|
// {(event?.data?.type || 'object') == 'object' &&
|
||||||
|
// downloadEvent.end_time &&
|
||||||
|
// downloadEvent.has_snapshot &&
|
||||||
|
// !downloadEvent.plus_id && (
|
||||||
|
// <MenuItem
|
||||||
|
// icon={UploadPlus}
|
||||||
|
// label={uploading.includes(downloadEvent.id) ? 'Uploading...' : 'Send to Frigate+'}
|
||||||
|
// value="plus"
|
||||||
|
// onSelect={() => showSubmitToPlus(downloadEvent.id, downloadEvent.label, downloadEvent.box)}
|
||||||
|
// />
|
||||||
|
// )}
|
||||||
|
// {downloadEvent.plus_id && (
|
||||||
|
// <MenuItem
|
||||||
|
// icon={UploadPlus}
|
||||||
|
// label={'Sent to Frigate+'}
|
||||||
|
// value="plus"
|
||||||
|
// onSelect={() => setState({ ...state, showDownloadMenu: false })}
|
||||||
|
// />
|
||||||
|
// )}
|
||||||
|
// </Menu>
|
||||||
|
// )}
|
||||||
|
// {state.showDatePicker && (
|
||||||
|
// <Menu
|
||||||
|
// className="rounded-t-none"
|
||||||
|
// onDismiss={() => setState({ ...state, setShowDatePicker: false })}
|
||||||
|
// relativeTo={datePicker}
|
||||||
|
// >
|
||||||
|
// <MenuItem label="All" value={{ before: null, after: null }} onSelect={handleSelectDateRange} />
|
||||||
|
// <MenuItem label="Today" value={{ before: null, after: daysAgo(0) }} onSelect={handleSelectDateRange} />
|
||||||
|
// <MenuItem
|
||||||
|
// label="Yesterday"
|
||||||
|
// value={{ before: daysAgo(0), after: daysAgo(1) }}
|
||||||
|
// onSelect={handleSelectDateRange}
|
||||||
|
// />
|
||||||
|
// <MenuItem label="Last 7 Days" value={{ before: null, after: daysAgo(7) }} onSelect={handleSelectDateRange} />
|
||||||
|
// <MenuItem label="This Month" value={{ before: null, after: monthsAgo(0) }} onSelect={handleSelectDateRange} />
|
||||||
|
// <MenuItem
|
||||||
|
// label="Last Month"
|
||||||
|
// value={{ before: monthsAgo(0), after: monthsAgo(1) }}
|
||||||
|
// onSelect={handleSelectDateRange}
|
||||||
|
// />
|
||||||
|
// <MenuItem
|
||||||
|
// label="Custom Range"
|
||||||
|
// value="custom"
|
||||||
|
// onSelect={() => {
|
||||||
|
// setState({ ...state, showCalendar: true, showDatePicker: false });
|
||||||
|
// }}
|
||||||
|
// />
|
||||||
|
// </Menu>
|
||||||
|
// )}
|
||||||
|
|
||||||
|
// {state.showCalendar && (
|
||||||
|
// <span>
|
||||||
|
// <Menu
|
||||||
|
// className="rounded-t-none"
|
||||||
|
// onDismiss={() => setState({ ...state, showCalendar: false })}
|
||||||
|
// relativeTo={datePicker}
|
||||||
|
// >
|
||||||
|
// <Calendar
|
||||||
|
// onChange={handleSelectDateRange}
|
||||||
|
// dateRange={{ before: searchParams.before * 1000 || null, after: searchParams.after * 1000 || null }}
|
||||||
|
// close={() => setState({ ...state, showCalendar: false })}
|
||||||
|
// >
|
||||||
|
// <Timepicker timeRange={searchParams.time_range} onChange={handleSelectTimeRange} />
|
||||||
|
// </Calendar>
|
||||||
|
// </Menu>
|
||||||
|
// </span>
|
||||||
|
// )}
|
||||||
|
// {state.showPlusSubmit && (
|
||||||
|
// <Dialog>
|
||||||
|
// {config.plus.enabled ? (
|
||||||
|
// <>
|
||||||
|
// <div className="p-4">
|
||||||
|
// <Heading size="lg">Submit to Frigate+</Heading>
|
||||||
|
|
||||||
|
// <img
|
||||||
|
// className="flex-grow-0"
|
||||||
|
// src={`${apiHost}api/events/${plusSubmitEvent.id}/snapshot.jpg`}
|
||||||
|
// alt={`${plusSubmitEvent.label}`}
|
||||||
|
// />
|
||||||
|
|
||||||
|
// {plusSubmitEvent.validBox ? (
|
||||||
|
// <p className="mb-2">
|
||||||
|
// Objects in locations you want to avoid are not false positives. Submitting them as false positives
|
||||||
|
// will confuse the model.
|
||||||
|
// </p>
|
||||||
|
// ) : (
|
||||||
|
// <p className="mb-2">
|
||||||
|
// Events prior to version 0.13 can only be submitted to Frigate+ without annotations.
|
||||||
|
// </p>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// {plusSubmitEvent.validBox ? (
|
||||||
|
// <div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
||||||
|
// <Button className="ml-2" onClick={() => setState({ ...state, showPlusSubmit: false })} type="text">
|
||||||
|
// {uploading.includes(plusSubmitEvent.id) ? 'Close' : 'Cancel'}
|
||||||
|
// </Button>
|
||||||
|
// <Button
|
||||||
|
// className="ml-2"
|
||||||
|
// color="red"
|
||||||
|
// onClick={() => onSendToPlus(plusSubmitEvent.id, true, plusSubmitEvent.validBox)}
|
||||||
|
// disabled={uploading.includes(plusSubmitEvent.id)}
|
||||||
|
// type="text"
|
||||||
|
// >
|
||||||
|
// This is not a {plusSubmitEvent.label}
|
||||||
|
// </Button>
|
||||||
|
// <Button
|
||||||
|
// className="ml-2"
|
||||||
|
// color="green"
|
||||||
|
// onClick={() => onSendToPlus(plusSubmitEvent.id, false, plusSubmitEvent.validBox)}
|
||||||
|
// disabled={uploading.includes(plusSubmitEvent.id)}
|
||||||
|
// type="text"
|
||||||
|
// >
|
||||||
|
// This is a {plusSubmitEvent.label}
|
||||||
|
// </Button>
|
||||||
|
// </div>
|
||||||
|
// ) : (
|
||||||
|
// <div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
||||||
|
// <Button
|
||||||
|
// className="ml-2"
|
||||||
|
// onClick={() => setState({ ...state, showPlusSubmit: false })}
|
||||||
|
// disabled={uploading.includes(plusSubmitEvent.id)}
|
||||||
|
// type="text"
|
||||||
|
// >
|
||||||
|
// {uploading.includes(plusSubmitEvent.id) ? 'Close' : 'Cancel'}
|
||||||
|
// </Button>
|
||||||
|
// <Button
|
||||||
|
// className="ml-2"
|
||||||
|
// onClick={() => onSendToPlus(plusSubmitEvent.id, false, plusSubmitEvent.validBox)}
|
||||||
|
// disabled={uploading.includes(plusSubmitEvent.id)}
|
||||||
|
// type="text"
|
||||||
|
// >
|
||||||
|
// Submit to Frigate+
|
||||||
|
// </Button>
|
||||||
|
// </div>
|
||||||
|
// )}
|
||||||
|
// </>
|
||||||
|
// ) : (
|
||||||
|
// <>
|
||||||
|
// <div className="p-4">
|
||||||
|
// <Heading size="lg">Setup a Frigate+ Account</Heading>
|
||||||
|
// <p className="mb-2">In order to submit images to Frigate+, you first need to setup an account.</p>
|
||||||
|
// <a
|
||||||
|
// className="text-blue-500 hover:underline"
|
||||||
|
// href="https://plus.frigate.video"
|
||||||
|
// target="_blank"
|
||||||
|
// rel="noopener noreferrer"
|
||||||
|
// >
|
||||||
|
// https://plus.frigate.video
|
||||||
|
// </a>
|
||||||
|
// </div>
|
||||||
|
// <div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
||||||
|
// <Button className="ml-2" onClick={() => setState({ ...state, showPlusSubmit: false })} type="text">
|
||||||
|
// Close
|
||||||
|
// </Button>
|
||||||
|
// </div>
|
||||||
|
// </>
|
||||||
|
// )}
|
||||||
|
// </Dialog>
|
||||||
|
// )}
|
||||||
|
// {deleteFavoriteState.showDeleteFavorite && (
|
||||||
|
// <Dialog>
|
||||||
|
// <div className="p-4">
|
||||||
|
// <Heading size="lg">Delete Saved Event?</Heading>
|
||||||
|
// <p className="mb-2">Confirm deletion of saved event.</p>
|
||||||
|
// </div>
|
||||||
|
// <div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
||||||
|
// <Button
|
||||||
|
// className="ml-2"
|
||||||
|
// onClick={() => setDeleteFavoriteState({ ...state, showDeleteFavorite: false })}
|
||||||
|
// type="text"
|
||||||
|
// >
|
||||||
|
// Cancel
|
||||||
|
// </Button>
|
||||||
|
// <Button
|
||||||
|
// className="ml-2"
|
||||||
|
// color="red"
|
||||||
|
// onClick={(e) => {
|
||||||
|
// setDeleteFavoriteState({ ...state, showDeleteFavorite: false });
|
||||||
|
// onDelete(e, deleteFavoriteState.deletingFavoriteEventId, false);
|
||||||
|
// }}
|
||||||
|
// type="text"
|
||||||
|
// >
|
||||||
|
// Delete
|
||||||
|
// </Button>
|
||||||
|
// </div>
|
||||||
|
// </Dialog>
|
||||||
|
// )}
|
||||||
|
// <div className="space-y-2">
|
||||||
|
// {ongoingEvents ? (
|
||||||
|
// <div>
|
||||||
|
// <div className="flex">
|
||||||
|
// <Heading className="py-4" size="sm">
|
||||||
|
// Ongoing Events
|
||||||
|
// </Heading>
|
||||||
|
// <Button
|
||||||
|
// className="rounded-full"
|
||||||
|
// type="text"
|
||||||
|
// color="gray"
|
||||||
|
// aria-label="Events for currently tracked objects. Recordings are only saved based on your retain settings. See the recording docs for more info."
|
||||||
|
// >
|
||||||
|
// <About className="w-5" />
|
||||||
|
// </Button>
|
||||||
|
// <Button
|
||||||
|
// className="rounded-full ml-auto"
|
||||||
|
// type="iconOnly"
|
||||||
|
// color="blue"
|
||||||
|
// onClick={() => setShowInProgress(!showInProgress)}
|
||||||
|
// >
|
||||||
|
// {showInProgress ? <MenuOpen className="w-6" /> : <MenuIcon className="w-6" />}
|
||||||
|
// </Button>
|
||||||
|
// </div>
|
||||||
|
// {showInProgress &&
|
||||||
|
// ongoingEvents.map((event, _) => {
|
||||||
|
// return (
|
||||||
|
// <Event
|
||||||
|
// className="my-2"
|
||||||
|
// key={event.id}
|
||||||
|
// config={config}
|
||||||
|
// event={event}
|
||||||
|
// eventDetailType={eventDetailType}
|
||||||
|
// eventOverlay={eventOverlay}
|
||||||
|
// viewEvent={viewEvent}
|
||||||
|
// setViewEvent={setViewEvent}
|
||||||
|
// uploading={uploading}
|
||||||
|
// handleEventDetailTabChange={handleEventDetailTabChange}
|
||||||
|
// onEventFrameSelected={onEventFrameSelected}
|
||||||
|
// onDelete={onDelete}
|
||||||
|
// onDispose={() => {
|
||||||
|
// this.player = null;
|
||||||
|
// }}
|
||||||
|
// onDownloadClick={onDownloadClick}
|
||||||
|
// onReady={(player) => {
|
||||||
|
// this.player = player;
|
||||||
|
// this.player.on('playing', () => {
|
||||||
|
// setEventOverlay(undefined);
|
||||||
|
// });
|
||||||
|
// }}
|
||||||
|
// onSave={onSave}
|
||||||
|
// showSubmitToPlus={showSubmitToPlus}
|
||||||
|
// />
|
||||||
|
// );
|
||||||
|
// })}
|
||||||
|
// </div>
|
||||||
|
// ) : null}
|
||||||
|
// <Heading className="py-4" size="sm">
|
||||||
|
// Past Events
|
||||||
|
// </Heading>
|
||||||
|
// {eventPages ? (
|
||||||
|
// eventPages.map((page, i) => {
|
||||||
|
// const lastPage = eventPages.length === i + 1;
|
||||||
|
// return page.map((event, j) => {
|
||||||
|
// const lastEvent = lastPage && page.length === j + 1;
|
||||||
|
// return (
|
||||||
|
// <Event
|
||||||
|
// key={event.id}
|
||||||
|
// config={config}
|
||||||
|
// event={event}
|
||||||
|
// eventDetailType={eventDetailType}
|
||||||
|
// eventOverlay={eventOverlay}
|
||||||
|
// viewEvent={viewEvent}
|
||||||
|
// setViewEvent={setViewEvent}
|
||||||
|
// lastEvent={lastEvent}
|
||||||
|
// lastEventRef={lastEventRef}
|
||||||
|
// uploading={uploading}
|
||||||
|
// handleEventDetailTabChange={handleEventDetailTabChange}
|
||||||
|
// onEventFrameSelected={onEventFrameSelected}
|
||||||
|
// onDelete={onDelete}
|
||||||
|
// onDispose={() => {
|
||||||
|
// this.player = null;
|
||||||
|
// }}
|
||||||
|
// onDownloadClick={onDownloadClick}
|
||||||
|
// onReady={(player) => {
|
||||||
|
// this.player = player;
|
||||||
|
// this.player.on('playing', () => {
|
||||||
|
// setEventOverlay(undefined);
|
||||||
|
// });
|
||||||
|
// }}
|
||||||
|
// onSave={onSave}
|
||||||
|
// showSubmitToPlus={showSubmitToPlus}
|
||||||
|
// />
|
||||||
|
// );
|
||||||
|
// });
|
||||||
|
// })
|
||||||
|
// ) : (
|
||||||
|
// <ActivityIndicator />
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// <div>{isDone ? null : <ActivityIndicator />}</div>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// function Event({
|
||||||
|
// className = '',
|
||||||
|
// config,
|
||||||
|
// event,
|
||||||
|
// eventDetailType,
|
||||||
|
// eventOverlay,
|
||||||
|
// viewEvent,
|
||||||
|
// setViewEvent,
|
||||||
|
// lastEvent,
|
||||||
|
// lastEventRef,
|
||||||
|
// uploading,
|
||||||
|
// handleEventDetailTabChange,
|
||||||
|
// onEventFrameSelected,
|
||||||
|
// onDelete,
|
||||||
|
// onDispose,
|
||||||
|
// onDownloadClick,
|
||||||
|
// onReady,
|
||||||
|
// onSave,
|
||||||
|
// showSubmitToPlus,
|
||||||
|
// }) {
|
||||||
|
// const apiHost = useApiHost();
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div className={className}>
|
||||||
|
// <div
|
||||||
|
// ref={lastEvent ? lastEventRef : false}
|
||||||
|
// className="flex bg-slate-100 dark:bg-slate-800 rounded cursor-pointer min-w-[330px]"
|
||||||
|
// onClick={() => (viewEvent === event.id ? setViewEvent(null) : setViewEvent(event.id))}
|
||||||
|
// >
|
||||||
|
// <div
|
||||||
|
// className="relative rounded-l flex-initial min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center"
|
||||||
|
// style={{
|
||||||
|
// 'background-image': `url(${apiHost}api/events/${event.id}/thumbnail.jpg)`,
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <StarRecording
|
||||||
|
// className="h-6 w-6 text-yellow-300 absolute top-1 right-1 cursor-pointer"
|
||||||
|
// onClick={(e) => onSave(e, event.id, !event.retain_indefinitely)}
|
||||||
|
// fill={event.retain_indefinitely ? 'currentColor' : 'none'}
|
||||||
|
// />
|
||||||
|
// {event.end_time ? null : (
|
||||||
|
// <div className="bg-slate-300 dark:bg-slate-700 absolute bottom-0 text-center w-full uppercase text-sm rounded-bl">
|
||||||
|
// In progress
|
||||||
|
// </div>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// <div className="m-2 flex grow">
|
||||||
|
// <div className="flex flex-col grow">
|
||||||
|
// <div className="capitalize text-lg font-bold">
|
||||||
|
// {event.label.replaceAll('_', ' ')}
|
||||||
|
// {event.sub_label ? `: ${event.sub_label.replaceAll('_', ' ')}` : null}
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// <div className="text-sm flex">
|
||||||
|
// <Clock className="h-5 w-5 mr-2 inline" />
|
||||||
|
// {formatUnixTimestampToDateTime(event.start_time, { ...config.ui })}
|
||||||
|
// <div className="hidden sm:inline">
|
||||||
|
// <span className="m-1">-</span>
|
||||||
|
// <TimeAgo time={event.start_time * 1000} dense />
|
||||||
|
// </div>
|
||||||
|
// <div className="hidden sm:inline">
|
||||||
|
// <span className="m-1" />( {getDurationFromTimestamps(event.start_time, event.end_time)} )
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// <div className="capitalize text-sm flex align-center mt-1">
|
||||||
|
// <Camera className="h-5 w-5 mr-2 inline" />
|
||||||
|
// {event.camera.replaceAll('_', ' ')}
|
||||||
|
// </div>
|
||||||
|
// {event.zones.length ? (
|
||||||
|
// <div className="capitalize text-sm flex align-center">
|
||||||
|
// <Zone className="w-5 h-5 mr-2 inline" />
|
||||||
|
// {event.zones.join(', ').replaceAll('_', ' ')}
|
||||||
|
// </div>
|
||||||
|
// ) : null}
|
||||||
|
// <div className="capitalize text-sm flex align-center">
|
||||||
|
// <Score className="w-5 h-5 mr-2 inline" />
|
||||||
|
// {(event?.data?.top_score || event.top_score || 0) == 0
|
||||||
|
// ? null
|
||||||
|
// : `${event.label}: ${((event?.data?.top_score || event.top_score) * 100).toFixed(0)}%`}
|
||||||
|
// {(event?.data?.sub_label_score || 0) == 0
|
||||||
|
// ? null
|
||||||
|
// : `, ${event.sub_label}: ${(event?.data?.sub_label_score * 100).toFixed(0)}%`}
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// <div class="hidden sm:flex flex-col justify-end mr-2">
|
||||||
|
// {event.end_time && event.has_snapshot && (event?.data?.type || 'object') == 'object' && (
|
||||||
|
// <Fragment>
|
||||||
|
// {event.plus_id ? (
|
||||||
|
// <div className="uppercase text-xs underline">
|
||||||
|
// <Link
|
||||||
|
// href={`https://plus.frigate.video/dashboard/edit-image/?id=${event.plus_id}`}
|
||||||
|
// target="_blank"
|
||||||
|
// rel="nofollow"
|
||||||
|
// >
|
||||||
|
// Edit in Frigate+
|
||||||
|
// </Link>
|
||||||
|
// </div>
|
||||||
|
// ) : (
|
||||||
|
// <Button
|
||||||
|
// color="gray"
|
||||||
|
// disabled={uploading.includes(event.id)}
|
||||||
|
// onClick={(e) => showSubmitToPlus(event.id, event.label, event?.data?.box || event.box, e)}
|
||||||
|
// >
|
||||||
|
// {uploading.includes(event.id) ? 'Uploading...' : 'Send to Frigate+'}
|
||||||
|
// </Button>
|
||||||
|
// )}
|
||||||
|
// </Fragment>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// <div class="flex flex-col">
|
||||||
|
// <Delete
|
||||||
|
// className="h-6 w-6 cursor-pointer"
|
||||||
|
// stroke="#f87171"
|
||||||
|
// onClick={(e) => onDelete(e, event.id, event.retain_indefinitely)}
|
||||||
|
// />
|
||||||
|
|
||||||
|
// <Download
|
||||||
|
// className="h-6 w-6 mt-auto"
|
||||||
|
// stroke={event.has_clip || event.has_snapshot ? '#3b82f6' : '#cbd5e1'}
|
||||||
|
// onClick={(e) => onDownloadClick(e, event)}
|
||||||
|
// />
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// {viewEvent !== event.id ? null : (
|
||||||
|
// <div className="space-y-4">
|
||||||
|
// <div className="mx-auto max-w-7xl">
|
||||||
|
// <div className="flex justify-center w-full py-2">
|
||||||
|
// <Tabs
|
||||||
|
// selectedIndex={event.has_clip && eventDetailType == 'clip' ? 0 : 1}
|
||||||
|
// onChange={handleEventDetailTabChange}
|
||||||
|
// className="justify"
|
||||||
|
// >
|
||||||
|
// <TextTab text="Clip" disabled={!event.has_clip} />
|
||||||
|
// <TextTab text={event.has_snapshot ? 'Snapshot' : 'Thumbnail'} />
|
||||||
|
// </Tabs>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// <div>
|
||||||
|
// {eventDetailType == 'clip' && event.has_clip ? (
|
||||||
|
// <div>
|
||||||
|
// <TimelineSummary
|
||||||
|
// event={event}
|
||||||
|
// onFrameSelected={(frame, seekSeconds) => onEventFrameSelected(event, frame, seekSeconds)}
|
||||||
|
// />
|
||||||
|
// <div>
|
||||||
|
// <VideoPlayer
|
||||||
|
// options={{
|
||||||
|
// preload: 'auto',
|
||||||
|
// autoplay: true,
|
||||||
|
// sources: [
|
||||||
|
// {
|
||||||
|
// src: `${apiHost}vod/event/${event.id}/master.m3u8`,
|
||||||
|
// type: 'application/vnd.apple.mpegurl',
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// }}
|
||||||
|
// seekOptions={{ forward: 10, backward: 5 }}
|
||||||
|
// onReady={onReady}
|
||||||
|
// onDispose={onDispose}
|
||||||
|
// >
|
||||||
|
// {eventOverlay ? (
|
||||||
|
// <TimelineEventOverlay eventOverlay={eventOverlay} cameraConfig={config.cameras[event.camera]} />
|
||||||
|
// ) : null}
|
||||||
|
// </VideoPlayer>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// ) : null}
|
||||||
|
|
||||||
|
// {eventDetailType == 'image' || !event.has_clip ? (
|
||||||
|
// <div className="flex justify-center">
|
||||||
|
// <img
|
||||||
|
// className="flex-grow-0"
|
||||||
|
// src={
|
||||||
|
// event.has_snapshot
|
||||||
|
// ? `${apiHost}api/events/${event.id}/snapshot.jpg?bbox=1`
|
||||||
|
// : `${apiHost}api/events/${event.id}/thumbnail.jpg`
|
||||||
|
// }
|
||||||
|
// alt={`${event.label} at ${((event?.data?.top_score || event.top_score) * 100).toFixed(
|
||||||
|
// 0
|
||||||
|
// )}% confidence`}
|
||||||
|
// />
|
||||||
|
// </div>
|
||||||
|
// ) : null}
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
@ -2,7 +2,7 @@ import React, { useCallback, useContext, useEffect, useRef } from 'react';
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Context } from '..';
|
import { Context } from '..';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { frigateApi, frigateQueryKeys, mapHostToHostname } from '../services/frigate.proxy/frigate.api';
|
import { frigateApi, frigateQueryKeys, mapHostToHostname, proxyApi } from '../services/frigate.proxy/frigate.api';
|
||||||
import { Button, Flex, Text, useMantineTheme } from '@mantine/core';
|
import { Button, Flex, Text, useMantineTheme } from '@mantine/core';
|
||||||
import { useClipboard } from '@mantine/hooks';
|
import { useClipboard } from '@mantine/hooks';
|
||||||
import { configureMonacoYaml } from "monaco-yaml";
|
import { configureMonacoYaml } from "monaco-yaml";
|
||||||
@ -21,7 +21,7 @@ const HostConfigPage = () => {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const host = await frigateApi.getHost(id || '')
|
const host = await frigateApi.getHost(id || '')
|
||||||
const hostName = mapHostToHostname(host)
|
const hostName = mapHostToHostname(host)
|
||||||
return frigateApi.getHostConfigRaw(hostName)
|
return proxyApi.getHostConfigRaw(hostName)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { Fragment, useContext, useEffect } from 'react';
|
import React, { useContext, useEffect } from 'react';
|
||||||
import { Context } from '..';
|
import { Context } from '..';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
@ -8,7 +8,6 @@ import CenterLoader from '../shared/components/CenterLoader';
|
|||||||
import RetryError from './RetryError';
|
import RetryError from './RetryError';
|
||||||
import Player from '../shared/components/frigate/Player';
|
import Player from '../shared/components/frigate/Player';
|
||||||
import { Flex } from '@mantine/core';
|
import { Flex } from '@mantine/core';
|
||||||
import JSMpegPlayer from '../shared/components/frigate/JSMpegPlayer';
|
|
||||||
|
|
||||||
const LiveCameraPage = observer(() => {
|
const LiveCameraPage = observer(() => {
|
||||||
let { id: cameraId } = useParams<'id'>()
|
let { id: cameraId } = useParams<'id'>()
|
||||||
@ -31,15 +30,9 @@ const LiveCameraPage = observer(() => {
|
|||||||
|
|
||||||
if (isError) return <RetryError onRetry={refetch} />
|
if (isError) return <RetryError onRetry={refetch} />
|
||||||
|
|
||||||
// const hostNameWPort = camera.frigateHost ? new URL(camera.frigateHost.host).host : ''
|
|
||||||
// const wsUrl = frigateApi.cameraWsURL(hostNameWPort, camera.name)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex w='100%' h='100%' justify='center'>
|
<Flex w='100%' h='100%' justify='center'>
|
||||||
<Player camera={camera} />
|
<Player camera={camera} />
|
||||||
{/* <JSMpegPlayer key={wsUrl} wsUrl={wsUrl}/> */}
|
|
||||||
{/* {JSON.stringify(camera)} */}
|
|
||||||
{/* {cameraWsURL} */}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@ -7,10 +7,8 @@ import { Context } from '..';
|
|||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import CenterLoader from '../shared/components/CenterLoader';
|
import CenterLoader from '../shared/components/CenterLoader';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { frigateApi, frigateQueryKeys, mapHostToHostname } from '../services/frigate.proxy/frigate.api';
|
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
|
||||||
import RetryError from './RetryError';
|
import RetryError from './RetryError';
|
||||||
import { FrigateConfig } from '../types/frigateConfig';
|
|
||||||
import { GetFrigateHostWConfig } from '../services/frigate.proxy/frigate.schema';
|
|
||||||
import CameraCard from '../shared/components/CameraCard';
|
import CameraCard from '../shared/components/CameraCard';
|
||||||
|
|
||||||
const MainBody = observer(() => {
|
const MainBody = observer(() => {
|
||||||
@ -41,7 +39,8 @@ const MainBody = observer(() => {
|
|||||||
<CameraCard
|
<CameraCard
|
||||||
key={camera.id}
|
key={camera.id}
|
||||||
camera={camera}
|
camera={camera}
|
||||||
/>))
|
/>)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -67,7 +66,6 @@ const MainBody = observer(() => {
|
|||||||
<Grid mt='sm' justify="center" mb='sm' align='stretch'>
|
<Grid mt='sm' justify="center" mb='sm' align='stretch'>
|
||||||
{cards()}
|
{cards()}
|
||||||
</Grid>
|
</Grid>
|
||||||
))
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -10,7 +10,7 @@ interface RetryErrorProps {
|
|||||||
onRetry?: () => void
|
onRetry?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const RetryError = ( {onRetry} : RetryErrorProps) => {
|
const RetryError = ({ onRetry }: RetryErrorProps) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const { sideBarsStore } = useContext(Context)
|
const { sideBarsStore } = useContext(Context)
|
||||||
@ -25,7 +25,11 @@ const RetryError = ( {onRetry} : RetryErrorProps) => {
|
|||||||
navigate(routesPath.MAIN_PATH)
|
navigate(routesPath.MAIN_PATH)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRetry(event: React.MouseEvent<HTMLButtonElement, MouseEvent>): void {
|
const handleGoBack = () => {
|
||||||
|
navigate(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRetry = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
|
||||||
if (onRetry) onRetry()
|
if (onRetry) onRetry()
|
||||||
else window.location.reload()
|
else window.location.reload()
|
||||||
}
|
}
|
||||||
@ -35,8 +39,11 @@ const RetryError = ( {onRetry} : RetryErrorProps) => {
|
|||||||
<Text fz='lg' fw={700}>{strings.errors.somthengGoesWrong}</Text>
|
<Text fz='lg' fw={700}>{strings.errors.somthengGoesWrong}</Text>
|
||||||
{ExclamationCogWheel}
|
{ExclamationCogWheel}
|
||||||
<Text fz='lg' fw={700}>{strings.youCanRetryOrGoToMain}</Text>
|
<Text fz='lg' fw={700}>{strings.youCanRetryOrGoToMain}</Text>
|
||||||
<Button onClick={handleRetry}>{strings.retry}</Button>
|
<Flex>
|
||||||
<Button onClick={handleGoToMain}>{strings.goToMainPage}</Button>
|
<Button ml='1rem' onClick={handleRetry}>{strings.retry}</Button>
|
||||||
|
<Button ml='1rem' onClick={handleGoBack}>{strings.back}</Button>
|
||||||
|
<Button ml='1rem' onClick={handleGoToMain}>{strings.goToMainPage}</Button>
|
||||||
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,35 +3,46 @@ import { proxyURL } from "../../shared/env.const"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { GetConfig, DeleteFrigateHost, GetFrigateHost, PutConfig, PutFrigateHost, GetFrigateHostWithCameras, GetCameraWHost, GetCameraWHostWConfig } from "./frigate.schema";
|
import { GetConfig, DeleteFrigateHost, GetFrigateHost, PutConfig, PutFrigateHost, GetFrigateHostWithCameras, GetCameraWHost, GetCameraWHostWConfig } from "./frigate.schema";
|
||||||
import { FrigateConfig } from "../../types/frigateConfig";
|
import { FrigateConfig } from "../../types/frigateConfig";
|
||||||
|
import { url } from "inspector";
|
||||||
|
|
||||||
|
|
||||||
const instance = axios.create({
|
const instanceApi = axios.create({
|
||||||
baseURL: proxyURL.toString(),
|
baseURL: proxyURL.toString(),
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const frigateApi = {
|
export const frigateApi = {
|
||||||
getConfig: () => instance.get<GetConfig[]>('apiv1/config').then(res => res.data),
|
getConfig: () => instanceApi.get<GetConfig[]>('apiv1/config').then(res => res.data),
|
||||||
putConfig: (config: PutConfig[]) => instance.put('apiv1/config', config).then(res => res.data),
|
putConfig: (config: PutConfig[]) => instanceApi.put('apiv1/config', config).then(res => res.data),
|
||||||
getHosts: () => instance.get<GetFrigateHost[]>('apiv1/frigate-hosts').then(res => {
|
getHosts: () => instanceApi.get<GetFrigateHost[]>('apiv1/frigate-hosts').then(res => {
|
||||||
return res.data
|
return res.data
|
||||||
}),
|
}),
|
||||||
getHostWithCameras: () => instance.get<GetFrigateHostWithCameras[]>('apiv1/frigate-hosts', { params: { include: 'cameras'}}).then(res => {
|
getHostWithCameras: () => instanceApi.get<GetFrigateHostWithCameras[]>('apiv1/frigate-hosts', { params: { include: 'cameras' } }).then(res => {
|
||||||
return res.data
|
return res.data
|
||||||
}),
|
}),
|
||||||
getHost: (id: string) => instance.get<GetFrigateHostWithCameras>(`apiv1/frigate-hosts/${id}`).then(res => {
|
getHost: (id: string) => instanceApi.get<GetFrigateHostWithCameras>(`apiv1/frigate-hosts/${id}`).then(res => {
|
||||||
return res.data
|
return res.data
|
||||||
}),
|
}),
|
||||||
getCamerasWHost: () => instance.get<GetCameraWHostWConfig[]>(`apiv1/cameras`).then(res => {return res.data}),
|
getCamerasWHost: () => instanceApi.get<GetCameraWHostWConfig[]>(`apiv1/cameras`).then(res => { return res.data }),
|
||||||
getCameraWHost: (id: string) => instance.get<GetCameraWHostWConfig>(`apiv1/cameras/${id}`).then(res => {return res.data}),
|
getCameraWHost: (id: string) => instanceApi.get<GetCameraWHostWConfig>(`apiv1/cameras/${id}`).then(res => { return res.data }),
|
||||||
putHosts: (hosts: PutFrigateHost[]) => instance.put<GetFrigateHost[]>('apiv1/frigate-hosts', hosts).then(res => {
|
putHosts: (hosts: PutFrigateHost[]) => instanceApi.put<GetFrigateHost[]>('apiv1/frigate-hosts', hosts).then(res => {
|
||||||
return res.data
|
return res.data
|
||||||
}),
|
}),
|
||||||
deleteHosts: (hosts: DeleteFrigateHost[]) => instance.delete<GetFrigateHost[]>('apiv1/frigate-hosts', { data: hosts }).then(res => {
|
deleteHosts: (hosts: DeleteFrigateHost[]) => instanceApi.delete<GetFrigateHost[]>('apiv1/frigate-hosts', { data: hosts }).then(res => {
|
||||||
return res.data
|
return res.data
|
||||||
}),
|
}),
|
||||||
getHostConfigRaw: (hostName: string) => instance.get('proxy/api/config/raw', { params: { hostName: hostName } }).then(res => res.data),
|
}
|
||||||
getHostConfig: (hostName: string) => instance.get('proxy/api/config', { params: { hostName: hostName } }).then(res => res.data),
|
|
||||||
|
export const proxyApi = {
|
||||||
|
getHostConfigRaw: (hostName: string) => instanceApi.get('proxy/api/config/raw', { params: { hostName: hostName } }).then(res => res.data),
|
||||||
|
getHostConfig: (hostName: string) => instanceApi.get('proxy/api/config', { params: { hostName: hostName } }).then(res => res.data),
|
||||||
|
getImageFrigate: async (imageUrl: string) => {
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Network response was not ok');
|
||||||
|
}
|
||||||
|
return response.blob();
|
||||||
|
},
|
||||||
cameraWsURL: (hostName: string, cameraName: string) => {
|
cameraWsURL: (hostName: string, cameraName: string) => {
|
||||||
return `ws://${proxyURL.host}/proxy-ws/live/jsmpeg/${cameraName}?hostName=${hostName}`
|
return `ws://${proxyURL.host}/proxy-ws/live/jsmpeg/${cameraName}?hostName=${hostName}`
|
||||||
},
|
},
|
||||||
|
|||||||
@ -5,7 +5,8 @@ import AutoUpdatingCameraImage from './frigate/AutoUpdatingCameraImage';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { routesPath } from '../../router/routes.path';
|
import { routesPath } from '../../router/routes.path';
|
||||||
import { GetCameraWHostWConfig, GetFrigateHost } from '../../services/frigate.proxy/frigate.schema';
|
import { GetCameraWHostWConfig, GetFrigateHost } from '../../services/frigate.proxy/frigate.schema';
|
||||||
import { frigateApi, mapHostToHostname } from '../../services/frigate.proxy/frigate.api';
|
import { frigateApi, mapHostToHostname, proxyApi } from '../../services/frigate.proxy/frigate.api';
|
||||||
|
import AutoUpdatedImage from './frigate/CameraImage';
|
||||||
|
|
||||||
|
|
||||||
const useStyles = createStyles((theme) => ({
|
const useStyles = createStyles((theme) => ({
|
||||||
@ -18,6 +19,12 @@ const useStyles = createStyles((theme) => ({
|
|||||||
backgroundColor: theme.colorScheme === 'dark' ? theme.fn.darken(theme.colors.cyan[9], 0.5) : theme.colors.cyan[1],
|
backgroundColor: theme.colorScheme === 'dark' ? theme.fn.darken(theme.colors.cyan[9], 0.5) : theme.colors.cyan[1],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
cameraImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
alignSelf: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
},
|
||||||
bottomGroup: {
|
bottomGroup: {
|
||||||
marginTop: 'auto',
|
marginTop: 'auto',
|
||||||
},
|
},
|
||||||
@ -36,8 +43,7 @@ const CameraCard = ({
|
|||||||
}: CameraCardProps) => {
|
}: CameraCardProps) => {
|
||||||
const { classes } = useStyles();
|
const { classes } = useStyles();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const imageUrl = camera.frigateHost ? frigateApi.cameraImageURL(mapHostToHostname(camera.frigateHost), camera.name) : '' //todo implement get URL from live cameras
|
const imageUrl = camera.frigateHost ? proxyApi.cameraImageURL(mapHostToHostname(camera.frigateHost), camera.name) : '' //todo implement get URL from live cameras
|
||||||
|
|
||||||
|
|
||||||
const handleOpenLiveView = () => {
|
const handleOpenLiveView = () => {
|
||||||
const url = routesPath.LIVE_PATH.replace(':id', camera.id)
|
const url = routesPath.LIVE_PATH.replace(':id', camera.id)
|
||||||
@ -52,14 +58,10 @@ const CameraCard = ({
|
|||||||
return (
|
return (
|
||||||
<Grid.Col md={6} lg={3} p='0.2rem'>
|
<Grid.Col md={6} lg={3} p='0.2rem'>
|
||||||
<Card h='100%' radius="lg" padding='0.5rem' className={classes.mainCard}>
|
<Card h='100%' radius="lg" padding='0.5rem' className={classes.mainCard}>
|
||||||
{/* <Card maw='25rem' mah='25rem' mih='15rem' miw='15rem'> */}
|
|
||||||
<Text align='center' size='md' className={classes.headText} >{camera.name} / {camera.frigateHost?.name}</Text>
|
<Text align='center' size='md' className={classes.headText} >{camera.name} / {camera.frigateHost?.name}</Text>
|
||||||
<AutoUpdatingCameraImage
|
<Flex direction='column' className={classes.cameraImage}>
|
||||||
onClick={handleOpenLiveView}
|
<AutoUpdatedImage onClick={handleOpenLiveView} enabled={camera.config?.enabled} imageUrl={imageUrl} />
|
||||||
cameraConfig={camera.config}
|
</Flex>
|
||||||
url={imageUrl}
|
|
||||||
showFps={false}
|
|
||||||
/>
|
|
||||||
<Group
|
<Group
|
||||||
className={classes.bottomGroup}>
|
className={classes.bottomGroup}>
|
||||||
<Flex justify='space-evenly' mt='0.5rem' w='100%'>
|
<Flex justify='space-evenly' mt='0.5rem' w='100%'>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { DEFAULT_THEME, Loader, LoadingOverlay } from '@mantine/core';
|
import { DEFAULT_THEME, Loader, LoadingOverlay } from '@mantine/core';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import СogwheelSVG from './svg/СogwheelSVG';
|
import СogwheelSVG from './svg/CogwheelSVG';
|
||||||
|
|
||||||
const CenterLoader = () => {
|
const CenterLoader = () => {
|
||||||
return <LoadingOverlay loader={СogwheelSVG} visible />;
|
return <LoadingOverlay loader={СogwheelSVG} visible />;
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Center, DEFAULT_THEME } from '@mantine/core';
|
import { Center, DEFAULT_THEME } from '@mantine/core';
|
||||||
import СogwheelSVG from './svg/СogwheelSVG';
|
import CogwheelSVG from './svg/CogwheelSVG';
|
||||||
|
|
||||||
|
|
||||||
const СogwheelLoader = () => {
|
const CogwheelLoader = () => {
|
||||||
return (
|
return (
|
||||||
<Center>
|
<Center>
|
||||||
{СogwheelSVG}
|
{CogwheelSVG}
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default СogwheelLoader;
|
export default CogwheelLoader;
|
||||||
@ -7,7 +7,7 @@ import { Modal, createStyles, getStylesRef, rem, Text, Box, Flex, Grid, Divider,
|
|||||||
import { useMediaQuery } from '@mantine/hooks';
|
import { useMediaQuery } from '@mantine/hooks';
|
||||||
import { dimensions } from '../dimensions/dimensions';
|
import { dimensions } from '../dimensions/dimensions';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import KomponentLoader from './СogwheelLoader';
|
import KomponentLoader from './CogwheelLoader';
|
||||||
import ProductParameter from './ProductParameter';
|
import ProductParameter from './ProductParameter';
|
||||||
import { productString } from '../strings/product.strings';
|
import { productString } from '../strings/product.strings';
|
||||||
import { IconArrowBadgeLeft, IconArrowBadgeRight } from '@tabler/icons-react';
|
import { IconArrowBadgeLeft, IconArrowBadgeRight } from '@tabler/icons-react';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Box, LoadingOverlay } from '@mantine/core';
|
import { Box, LoadingOverlay } from '@mantine/core';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import СogwheelSVG from './svg/СogwheelSVG';
|
import СogwheelSVG from './svg/CogwheelSVG';
|
||||||
|
|
||||||
const SideBarLoader = () => {
|
const SideBarLoader = () => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -12,6 +12,7 @@ interface AutoUpdatingCameraImageProps extends React.ImgHTMLAttributes<HTMLImage
|
|||||||
url: string
|
url: string
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO Delete
|
||||||
export default function AutoUpdatingCameraImage({
|
export default function AutoUpdatingCameraImage({
|
||||||
cameraConfig,
|
cameraConfig,
|
||||||
searchParams = "",
|
searchParams = "",
|
||||||
@ -82,13 +83,13 @@ export default function AutoUpdatingCameraImage({
|
|||||||
return (
|
return (
|
||||||
// <AspectRatio ratio={1}>
|
// <AspectRatio ratio={1}>
|
||||||
<Flex direction='column' h='100%'>
|
<Flex direction='column' h='100%'>
|
||||||
<CameraImage
|
{/* <CameraImage
|
||||||
cameraConfig={cameraConfig}
|
cameraConfig={cameraConfig}
|
||||||
onload={handleLoad}
|
onload={handleLoad}
|
||||||
enabled={cameraConfig?.enabled}
|
enabled={cameraConfig?.enabled}
|
||||||
url={url}
|
url={url}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/> */}
|
||||||
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
|
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
|
||||||
</Flex>
|
</Flex>
|
||||||
// </AspectRatio >
|
// </AspectRatio >
|
||||||
|
|||||||
115
src/shared/components/frigate/Button.jsx
Normal file
115
src/shared/components/frigate/Button.jsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import Tooltip from './Tooltip';
|
||||||
|
import { Fragment, useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const ButtonColors = {
|
||||||
|
blue: {
|
||||||
|
contained: 'bg-blue-500 focus:bg-blue-400 active:bg-blue-600 ring-blue-300',
|
||||||
|
outlined:
|
||||||
|
'text-blue-500 border-2 border-blue-500 hover:bg-blue-500 hover:bg-opacity-20 focus:bg-blue-500 focus:bg-opacity-40 active:bg-blue-500 active:bg-opacity-40',
|
||||||
|
text: 'text-blue-500 hover:bg-blue-500 hover:bg-opacity-20 focus:bg-blue-500 focus:bg-opacity-40 active:bg-blue-500 active:bg-opacity-40',
|
||||||
|
iconOnly: 'text-blue-500 hover:text-blue-200',
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
contained: 'bg-red-500 focus:bg-red-400 active:bg-red-600 ring-red-300',
|
||||||
|
outlined:
|
||||||
|
'text-red-500 border-2 border-red-500 hover:bg-red-500 hover:bg-opacity-20 focus:bg-red-500 focus:bg-opacity-40 active:bg-red-500 active:bg-opacity-40',
|
||||||
|
text: 'text-red-500 hover:bg-red-500 hover:bg-opacity-20 focus:bg-red-500 focus:bg-opacity-40 active:bg-red-500 active:bg-opacity-40',
|
||||||
|
iconOnly: 'text-red-500 hover:text-red-200',
|
||||||
|
},
|
||||||
|
yellow: {
|
||||||
|
contained: 'bg-yellow-500 focus:bg-yellow-400 active:bg-yellow-600 ring-yellow-300',
|
||||||
|
outlined:
|
||||||
|
'text-yellow-500 border-2 border-yellow-500 hover:bg-yellow-500 hover:bg-opacity-20 focus:bg-yellow-500 focus:bg-opacity-40 active:bg-yellow-500 active:bg-opacity-40',
|
||||||
|
text: 'text-yellow-500 hover:bg-yellow-500 hover:bg-opacity-20 focus:bg-yellow-500 focus:bg-opacity-40 active:bg-yellow-500 active:bg-opacity-40',
|
||||||
|
iconOnly: 'text-yellow-500 hover:text-yellow-200',
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
contained: 'bg-green-500 focus:bg-green-400 active:bg-green-600 ring-green-300',
|
||||||
|
outlined:
|
||||||
|
'text-green-500 border-2 border-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40',
|
||||||
|
text: 'text-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40',
|
||||||
|
iconOnly: 'text-green-500 hover:text-green-200',
|
||||||
|
},
|
||||||
|
gray: {
|
||||||
|
contained: 'bg-gray-500 focus:bg-gray-400 active:bg-gray-600 ring-gray-300',
|
||||||
|
outlined:
|
||||||
|
'text-gray-500 border-2 border-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
|
||||||
|
text: 'text-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
|
||||||
|
iconOnly: 'text-gray-500 hover:text-gray-200',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
contained: 'bg-gray-400',
|
||||||
|
outlined:
|
||||||
|
'text-gray-500 border-2 border-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
|
||||||
|
text: 'text-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
|
||||||
|
iconOnly: 'text-gray-500 hover:text-gray-200',
|
||||||
|
},
|
||||||
|
black: {
|
||||||
|
contained: '',
|
||||||
|
outlined: '',
|
||||||
|
text: 'text-black dark:text-white',
|
||||||
|
iconOnly: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ButtonTypes = {
|
||||||
|
contained: 'text-white shadow focus:shadow-xl hover:shadow-md',
|
||||||
|
outlined: '',
|
||||||
|
text: 'transition-opacity',
|
||||||
|
iconOnly: 'transition-opacity',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
color = 'blue',
|
||||||
|
disabled = false,
|
||||||
|
ariaCapitalize = false,
|
||||||
|
href,
|
||||||
|
target,
|
||||||
|
type = 'contained',
|
||||||
|
...attrs
|
||||||
|
}) {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const ref = useRef();
|
||||||
|
|
||||||
|
let classes = `whitespace-nowrap flex items-center space-x-1 ${className} ${ButtonTypes[type]} ${
|
||||||
|
ButtonColors[disabled ? 'disabled' : color][type]
|
||||||
|
} font-sans inline-flex font-bold uppercase text-xs px-1.5 md:px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${
|
||||||
|
disabled ? 'cursor-not-allowed' : `${type == 'iconOnly' ? '' : 'focus:ring-2'} cursor-pointer`
|
||||||
|
}`;
|
||||||
|
|
||||||
|
if (disabled) {
|
||||||
|
classes = classes.replace(/(?:focus|active|hover):[^ ]+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMousenter = useCallback(() => {
|
||||||
|
setHovered(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseleave = useCallback(() => {
|
||||||
|
setHovered(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const Element = href ? 'a' : 'div';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<Element
|
||||||
|
role="button"
|
||||||
|
aria-disabled={disabled ? 'true' : 'false'}
|
||||||
|
tabindex="0"
|
||||||
|
className={classes}
|
||||||
|
href={href}
|
||||||
|
target={target}
|
||||||
|
ref={ref}
|
||||||
|
onmouseenter={handleMousenter}
|
||||||
|
onmouseleave={handleMouseleave}
|
||||||
|
{...attrs}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Element>
|
||||||
|
{hovered && attrs['aria-label'] ? <Tooltip text={attrs['aria-label']} relativeTo={ref} capitalize={ariaCapitalize} /> : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,51 +1,63 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { CameraConfig } from "../../../types/frigateConfig";
|
import { CameraConfig } from "../../../types/frigateConfig";
|
||||||
import { AspectRatio, Flex, createStyles, Text } from "@mantine/core";
|
import { AspectRatio, Flex, createStyles, Text } from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import CenterLoader from "../CenterLoader";
|
||||||
|
import axios from "axios";
|
||||||
|
import { frigateApi, proxyApi } from "../../../services/frigate.proxy/frigate.api";
|
||||||
|
|
||||||
interface CameraImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
interface CameraImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||||
className?: string;
|
className?: string;
|
||||||
cameraConfig?: CameraConfig;
|
cameraConfig?: CameraConfig;
|
||||||
onload?: () => void;
|
onload?: () => void;
|
||||||
url: string,
|
imageUrl: string;
|
||||||
enabled?: boolean
|
enabled?: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
const useStyles = createStyles((theme) => ({
|
const AutoUpdatedImage = ({
|
||||||
|
imageUrl,
|
||||||
}))
|
|
||||||
|
|
||||||
|
|
||||||
export default function CameraImage({
|
|
||||||
className,
|
|
||||||
cameraConfig,
|
|
||||||
onload,
|
|
||||||
enabled,
|
enabled,
|
||||||
url, ...rest }: CameraImageProps) {
|
...rest
|
||||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
}: CameraImageProps) => {
|
||||||
const { classes } = useStyles();
|
const { data: imageBlob, refetch, isPending, isError } = useQuery({
|
||||||
|
queryKey: ['image', imageUrl],
|
||||||
|
queryFn: () => proxyApi.getImageFrigate(imageUrl),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
gcTime: Infinity,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cameraConfig || !imgRef.current) {
|
const intervalId = setInterval(() => {
|
||||||
return;
|
refetch();
|
||||||
}
|
}, 60 * 1000);
|
||||||
imgRef.current.src = url
|
|
||||||
}, [imgRef]);
|
return () => clearInterval(intervalId);
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (isPending) return <CenterLoader />
|
||||||
|
|
||||||
|
if (isError) return (
|
||||||
|
<Flex direction="column" justify="center" h="100%">
|
||||||
|
<Text align="center">Error loading!</Text>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!imageBlob || !(imageBlob instanceof Blob)) console.error('imageBlob not Blob object:', imageBlob)
|
||||||
|
|
||||||
|
const image = URL.createObjectURL(imageBlob!)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction='column' justify='center' h='100%'>
|
<>
|
||||||
{enabled ? (
|
{enabled ? <img src={image} alt="Dynamic Content" {...rest}/>
|
||||||
<AspectRatio ratio={1.5}>
|
:
|
||||||
<img
|
<Flex direction="column" justify="center" h="100%">
|
||||||
ref={imgRef}
|
<Text align="center">Camera is disabled in config, no stream or snapshot available!</Text>
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
</AspectRatio>
|
|
||||||
) : (
|
|
||||||
<Text align='center'>
|
|
||||||
Camera is disabled in config, no stream or snapshot available!
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
}
|
||||||
}
|
</>)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AutoUpdatedImage
|
||||||
35
src/shared/components/frigate/Dialog.jsx
Normal file
35
src/shared/components/frigate/Dialog.jsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { h, Fragment } from 'preact';
|
||||||
|
import { createPortal } from 'preact/compat';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
export default function Dialog({ children, portalRootID = 'dialogs' }) {
|
||||||
|
const portalRoot = portalRootID && document.getElementById(portalRootID);
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
setShow(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dialog = (
|
||||||
|
<Fragment>
|
||||||
|
<div
|
||||||
|
data-testid="scrim"
|
||||||
|
key="scrim"
|
||||||
|
className="fixed bg-fixed inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="modal"
|
||||||
|
className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 sm:max-w-sm md:max-w-md lg:max-w-lg text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
|
||||||
|
show ? 'scale-100 opacity-100' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
return portalRoot ? createPortal(dialog, portalRoot) : dialog;
|
||||||
|
}
|
||||||
16
src/shared/components/frigate/Link.jsx
Normal file
16
src/shared/components/frigate/Link.jsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { Link as RouterLink } from 'preact-router/match';
|
||||||
|
|
||||||
|
export default function Link({
|
||||||
|
activeClassName = '',
|
||||||
|
className = 'text-blue-500 hover:underline',
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<RouterLink activeClassName={activeClassName} className={className} href={href} {...props}>
|
||||||
|
{children}
|
||||||
|
</RouterLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/shared/components/frigate/Menu.jsx
Normal file
48
src/shared/components/frigate/Menu.jsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import RelativeModal from './RelativeModal';
|
||||||
|
import { useCallback } from 'preact/hooks';
|
||||||
|
|
||||||
|
export default function Menu({ className, children, onDismiss, relativeTo, widthRelative }) {
|
||||||
|
return relativeTo ? (
|
||||||
|
<RelativeModal
|
||||||
|
children={children}
|
||||||
|
className={`${className || ''} py-2`}
|
||||||
|
role="listbox"
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
portalRootID="menus"
|
||||||
|
relativeTo={relativeTo}
|
||||||
|
widthRelative={widthRelative}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MenuItem({ focus, icon: Icon, label, href, onSelect, value, ...attrs }) {
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
onSelect && onSelect(value, label);
|
||||||
|
}, [onSelect, value, label]);
|
||||||
|
|
||||||
|
const Element = href ? 'a' : 'div';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Element
|
||||||
|
className={`flex space-x-2 p-2 px-5 hover:bg-gray-200 dark:hover:bg-gray-800 dark:hover:text-white cursor-pointer ${
|
||||||
|
focus ? 'bg-gray-200 dark:bg-gray-800 dark:text-white' : ''
|
||||||
|
}`}
|
||||||
|
href={href}
|
||||||
|
onClick={handleClick}
|
||||||
|
role="option"
|
||||||
|
{...attrs}
|
||||||
|
>
|
||||||
|
{Icon ? (
|
||||||
|
<div className="w-6 h-6 self-center mr-4 text-gray-500 flex-shrink-0">
|
||||||
|
<Icon />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="whitespace-nowrap">{label}</div>
|
||||||
|
</Element>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MenuSeparator() {
|
||||||
|
return <div className="border-b border-gray-200 dark:border-gray-800 my-2" />;
|
||||||
|
}
|
||||||
70
src/shared/components/frigate/MultiSelect.jsx
Normal file
70
src/shared/components/frigate/MultiSelect.jsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useRef, useState } from 'preact/hooks';
|
||||||
|
import Menu from './Menu';
|
||||||
|
import { ArrowDropdown } from '../icons/ArrowDropdown';
|
||||||
|
import Heading from './Heading';
|
||||||
|
import Button from './Button';
|
||||||
|
import SelectOnlyIcon from '../icons/SelectOnly';
|
||||||
|
|
||||||
|
export default function MultiSelect({ className, title, options, selection, onToggle, onShowAll, onSelectSingle }) {
|
||||||
|
const popupRef = useRef(null);
|
||||||
|
|
||||||
|
const [state, setState] = useState({
|
||||||
|
showMenu: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isOptionSelected = (item) => {
|
||||||
|
return selection == 'all' || selection.split(',').indexOf(item) > -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuHeight = Math.round(window.innerHeight * 0.55);
|
||||||
|
return (
|
||||||
|
<div className={`${className} p-2`} ref={popupRef}>
|
||||||
|
<div className="flex justify-between min-w-[120px]" onClick={() => setState({ showMenu: true })}>
|
||||||
|
<label>{title}</label>
|
||||||
|
<ArrowDropdown className="w-6" />
|
||||||
|
</div>
|
||||||
|
{state.showMenu ? (
|
||||||
|
<Menu
|
||||||
|
className={`max-h-[${menuHeight}px] overflow-auto`}
|
||||||
|
relativeTo={popupRef}
|
||||||
|
onDismiss={() => setState({ showMenu: false })}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap justify-between items-center">
|
||||||
|
<Heading className="p-4 justify-center" size="md">
|
||||||
|
{title}
|
||||||
|
</Heading>
|
||||||
|
<Button tabindex="false" className="mx-4" onClick={() => onShowAll()}>
|
||||||
|
Show All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{options.map((item) => (
|
||||||
|
<div className="flex flex-grow" key={item}>
|
||||||
|
<label
|
||||||
|
className={`flex flex-shrink space-x-2 p-1 my-1 min-w-[176px] hover:bg-gray-200 dark:hover:bg-gray-800 dark:hover:text-white cursor-pointer capitalize text-sm`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="mx-4 m-0 align-middle"
|
||||||
|
type="checkbox"
|
||||||
|
checked={isOptionSelected(item)}
|
||||||
|
onChange={() => onToggle(item)}
|
||||||
|
/>
|
||||||
|
{item.replaceAll('_', ' ')}
|
||||||
|
</label>
|
||||||
|
<div className="justify-right">
|
||||||
|
<Button
|
||||||
|
color={isOptionSelected(item) ? 'blue' : 'black'}
|
||||||
|
type="text"
|
||||||
|
className="max-h-[35px] mx-2"
|
||||||
|
onClick={() => onSelectSingle(item)}
|
||||||
|
>
|
||||||
|
{ ( <SelectOnlyIcon /> ) }
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@ import useCameraActivity from '../../../hooks/use-camera-activity';
|
|||||||
import useCameraLiveMode from '../../../hooks/use-camera-live-mode';
|
import useCameraLiveMode from '../../../hooks/use-camera-live-mode';
|
||||||
import WebRtcPlayer from './WebRTCPlayer';
|
import WebRtcPlayer from './WebRTCPlayer';
|
||||||
import { Flex } from '@mantine/core';
|
import { Flex } from '@mantine/core';
|
||||||
import { frigateApi } from '../../../services/frigate.proxy/frigate.api';
|
import { frigateApi, proxyApi } from '../../../services/frigate.proxy/frigate.api';
|
||||||
import { GetCameraWHostWConfig } from '../../../services/frigate.proxy/frigate.schema';
|
import { GetCameraWHostWConfig } from '../../../services/frigate.proxy/frigate.schema';
|
||||||
|
|
||||||
type LivePlayerProps = {
|
type LivePlayerProps = {
|
||||||
@ -24,7 +24,7 @@ const Player = ({
|
|||||||
}: LivePlayerProps) => {
|
}: LivePlayerProps) => {
|
||||||
|
|
||||||
const hostNameWPort = camera.frigateHost ? new URL(camera.frigateHost.host).host : ''
|
const hostNameWPort = camera.frigateHost ? new URL(camera.frigateHost.host).host : ''
|
||||||
const wsUrl = frigateApi.cameraWsURL(hostNameWPort, camera.name)
|
const wsUrl = proxyApi.cameraWsURL(hostNameWPort, camera.name)
|
||||||
const cameraConfig = camera.config!
|
const cameraConfig = camera.config!
|
||||||
|
|
||||||
const { activeMotion, activeAudio, activeTracking } =
|
const { activeMotion, activeAudio, activeTracking } =
|
||||||
|
|||||||
41
src/shared/components/frigate/Tabs.jsx
Normal file
41
src/shared/components/frigate/Tabs.jsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useCallback, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
export function Tabs({ children, selectedIndex: selectedIndexProp, onChange, className }) {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(selectedIndexProp);
|
||||||
|
|
||||||
|
const handleSelected = useCallback(
|
||||||
|
(index) => () => {
|
||||||
|
setSelectedIndex(index);
|
||||||
|
onChange && onChange(index);
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const RenderChildren = useCallback(() => {
|
||||||
|
return children.map((child, i) => {
|
||||||
|
child.props.selected = i === selectedIndex;
|
||||||
|
child.props.onClick = handleSelected(i);
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
}, [selectedIndex, children, handleSelected]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex ${className}`}>
|
||||||
|
<RenderChildren />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextTab({ selected, text, onClick, disabled }) {
|
||||||
|
const selectedStyle = disabled
|
||||||
|
? 'text-gray-400 dark:text-gray-600 bg-transparent'
|
||||||
|
: selected
|
||||||
|
? 'text-white bg-blue-500 dark:text-black dark:bg-white'
|
||||||
|
: 'text-black dark:text-white bg-transparent';
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} disabled={disabled} className={`rounded-full px-4 py-2 ${selectedStyle}`}>
|
||||||
|
<span>{text}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/shared/components/frigate/TimeAgo.tsx
Normal file
83
src/shared/components/frigate/TimeAgo.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { FunctionComponent, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
interface IProp {
|
||||||
|
/** The time to calculate time-ago from */
|
||||||
|
time: Date;
|
||||||
|
/** OPTIONAL: overwrite current time */
|
||||||
|
currentTime?: Date;
|
||||||
|
/** OPTIONAL: boolean that determines whether to show the time-ago text in dense format */
|
||||||
|
dense?: boolean;
|
||||||
|
/** OPTIONAL: set custom refresh interval in milliseconds, default 1000 (1 sec) */
|
||||||
|
refreshInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeUnit = {
|
||||||
|
unit: string;
|
||||||
|
full: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeAgo = ({ time, currentTime = new Date(), dense = false }: IProp): string => {
|
||||||
|
if (typeof time !== 'number' || time < 0) return 'Invalid Time Provided';
|
||||||
|
|
||||||
|
const pastTime: Date = new Date(time);
|
||||||
|
const elapsedTime: number = currentTime.getTime() - pastTime.getTime();
|
||||||
|
|
||||||
|
const timeUnits: TimeUnit[] = [
|
||||||
|
{ unit: 'yr', full: 'year', value: 31536000 },
|
||||||
|
{ unit: 'mo', full: 'month', value: 0 },
|
||||||
|
{ unit: 'd', full: 'day', value: 86400 },
|
||||||
|
{ unit: 'h', full: 'hour', value: 3600 },
|
||||||
|
{ unit: 'm', full: 'minute', value: 60 },
|
||||||
|
{ unit: 's', full: 'second', value: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const elapsed: number = elapsedTime / 1000;
|
||||||
|
if (elapsed < 10) {
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < timeUnits.length; i++) {
|
||||||
|
// if months
|
||||||
|
if (i === 1) {
|
||||||
|
// Get the month and year for the time provided
|
||||||
|
const pastMonth = pastTime.getUTCMonth();
|
||||||
|
const pastYear = pastTime.getUTCFullYear();
|
||||||
|
|
||||||
|
// get current month and year
|
||||||
|
const currentMonth = currentTime.getUTCMonth();
|
||||||
|
const currentYear = currentTime.getUTCFullYear();
|
||||||
|
|
||||||
|
let monthDiff = (currentYear - pastYear) * 12 + (currentMonth - pastMonth);
|
||||||
|
|
||||||
|
// check if the time provided is the previous month but not exceeded 1 month ago.
|
||||||
|
if (currentTime.getUTCDate() < pastTime.getUTCDate()) {
|
||||||
|
monthDiff--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monthDiff > 0) {
|
||||||
|
const unitAmount = monthDiff;
|
||||||
|
return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`;
|
||||||
|
}
|
||||||
|
} else if (elapsed >= timeUnits[i].value) {
|
||||||
|
const unitAmount: number = Math.floor(elapsed / timeUnits[i].value);
|
||||||
|
return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Invalid Time';
|
||||||
|
};
|
||||||
|
|
||||||
|
const TimeAgo: FunctionComponent<IProp> = ({ refreshInterval = 1000, ...rest }): JSX.Element => {
|
||||||
|
const [currentTime, setCurrentTime] = useState<Date>(new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const intervalId: NodeJS.Timeout = setInterval(() => {
|
||||||
|
setCurrentTime(new Date());
|
||||||
|
}, refreshInterval);
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [refreshInterval]);
|
||||||
|
|
||||||
|
const timeAgoValue = useMemo(() => timeAgo({ currentTime, ...rest }), [currentTime, rest]);
|
||||||
|
|
||||||
|
return <span>{timeAgoValue}</span>;
|
||||||
|
};
|
||||||
|
export default TimeAgo;
|
||||||
65
src/shared/components/frigate/TimelineEventOverlay.jsx
Normal file
65
src/shared/components/frigate/TimelineEventOverlay.jsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { Fragment, h } from 'preact';
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
export default function TimelineEventOverlay({ eventOverlay, cameraConfig }) {
|
||||||
|
const boxLeftEdge = Math.round(eventOverlay.data.box[0] * 100);
|
||||||
|
const boxTopEdge = Math.round(eventOverlay.data.box[1] * 100);
|
||||||
|
const boxRightEdge = Math.round((1 - eventOverlay.data.box[2] - eventOverlay.data.box[0]) * 100);
|
||||||
|
const boxBottomEdge = Math.round((1 - eventOverlay.data.box[3] - eventOverlay.data.box[1]) * 100);
|
||||||
|
|
||||||
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
|
const getHoverStyle = () => {
|
||||||
|
if (boxLeftEdge < 15) {
|
||||||
|
// show object stats on right side
|
||||||
|
return {
|
||||||
|
left: `${boxLeftEdge + eventOverlay.data.box[2] * 100 + 1}%`,
|
||||||
|
top: `${boxTopEdge}%`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
right: `${boxRightEdge + eventOverlay.data.box[2] * 100 + 1}%`,
|
||||||
|
top: `${boxTopEdge}%`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getObjectArea = () => {
|
||||||
|
const width = eventOverlay.data.box[2] * cameraConfig.detect.width;
|
||||||
|
const height = eventOverlay.data.box[3] * cameraConfig.detect.height;
|
||||||
|
return Math.round(width * height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getObjectRatio = () => {
|
||||||
|
const width = eventOverlay.data.box[2] * cameraConfig.detect.width;
|
||||||
|
const height = eventOverlay.data.box[3] * cameraConfig.detect.height;
|
||||||
|
return Math.round(100 * (width / height)) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<div
|
||||||
|
className="absolute border-4 border-red-600"
|
||||||
|
onMouseEnter={() => setIsHovering(true)}
|
||||||
|
onMouseLeave={() => setIsHovering(false)}
|
||||||
|
onTouchStart={() => setIsHovering(true)}
|
||||||
|
onTouchEnd={() => setIsHovering(false)}
|
||||||
|
style={{
|
||||||
|
left: `${boxLeftEdge}%`,
|
||||||
|
top: `${boxTopEdge}%`,
|
||||||
|
right: `${boxRightEdge}%`,
|
||||||
|
bottom: `${boxBottomEdge}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{eventOverlay.class_type == 'entered_zone' ? (
|
||||||
|
<div className="absolute w-2 h-2 bg-yellow-500 left-[50%] -translate-x-1/2 translate-y-3/4 bottom-0" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{isHovering && (
|
||||||
|
<div className="absolute bg-white dark:bg-slate-800 p-4 block text-black dark:text-white text-lg" style={getHoverStyle()}>
|
||||||
|
<div>{`Area: ${getObjectArea()} px`}</div>
|
||||||
|
<div>{`Ratio: ${getObjectRatio()}`}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
src/shared/components/frigate/TimelineSummary.jsx
Normal file
218
src/shared/components/frigate/TimelineSummary.jsx
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import ActivityIndicator from './ActivityIndicator';
|
||||||
|
import { formatUnixTimestampToDateTime } from '../utils/dateUtil';
|
||||||
|
import About from '../icons/About';
|
||||||
|
import ActiveObjectIcon from '../icons/ActiveObject';
|
||||||
|
import PlayIcon from '../icons/Play';
|
||||||
|
import ExitIcon from '../icons/Exit';
|
||||||
|
import StationaryObjectIcon from '../icons/StationaryObject';
|
||||||
|
import FaceIcon from '../icons/Face';
|
||||||
|
import LicensePlateIcon from '../icons/LicensePlate';
|
||||||
|
import DeliveryTruckIcon from '../icons/DeliveryTruck';
|
||||||
|
import ZoneIcon from '../icons/Zone';
|
||||||
|
import { useMemo, useState } from 'preact/hooks';
|
||||||
|
import Button from './Button';
|
||||||
|
|
||||||
|
export default function TimelineSummary({ event, onFrameSelected }) {
|
||||||
|
const { data: eventTimeline } = useSWR([
|
||||||
|
'timeline',
|
||||||
|
{
|
||||||
|
source_id: event.id,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { data: config } = useSWR('config');
|
||||||
|
|
||||||
|
const annotationOffset = useMemo(() => {
|
||||||
|
if (!config) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (config.cameras[event.camera]?.detect?.annotation_offset || 0) / 1000;
|
||||||
|
}, [config, event]);
|
||||||
|
|
||||||
|
const [timeIndex, setTimeIndex] = useState(-1);
|
||||||
|
|
||||||
|
const recordingParams = useMemo(() => {
|
||||||
|
if (!event.end_time) {
|
||||||
|
return {
|
||||||
|
after: event.start_time,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
before: event.end_time,
|
||||||
|
after: event.start_time,
|
||||||
|
};
|
||||||
|
}, [event]);
|
||||||
|
const { data: recordings } = useSWR([`${event.camera}/recordings`, recordingParams], { revalidateOnFocus: false });
|
||||||
|
|
||||||
|
// calculates the seek seconds by adding up all the seconds in the segments prior to the playback time
|
||||||
|
const getSeekSeconds = (seekUnix) => {
|
||||||
|
if (!recordings) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seekSeconds = 0;
|
||||||
|
recordings.every((segment) => {
|
||||||
|
// if the next segment is past the desired time, stop calculating
|
||||||
|
if (segment.start_time > seekUnix) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segment.end_time < seekUnix) {
|
||||||
|
seekSeconds += segment.end_time - segment.start_time;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
seekSeconds += segment.end_time - segment.start_time - (segment.end_time - seekUnix);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return seekSeconds;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelectMoment = async (index) => {
|
||||||
|
setTimeIndex(index);
|
||||||
|
onFrameSelected(eventTimeline[index], getSeekSeconds(eventTimeline[index].timestamp + annotationOffset));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!eventTimeline || !config) {
|
||||||
|
return <ActivityIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventTimeline.length == 0) {
|
||||||
|
return <div />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="h-14 flex justify-center">
|
||||||
|
<div className="flex flex-row flex-nowrap justify-between overflow-auto">
|
||||||
|
{eventTimeline.map((item, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
className="rounded-full"
|
||||||
|
type="iconOnly"
|
||||||
|
color={index == timeIndex ? 'blue' : 'gray'}
|
||||||
|
aria-label={window.innerWidth > 640 ? getTimelineItemDescription(config, item, event) : ''}
|
||||||
|
onClick={() => onSelectMoment(index)}
|
||||||
|
>
|
||||||
|
{getTimelineIcon(item)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{timeIndex >= 0 ? (
|
||||||
|
<div className="m-2 max-w-md self-center">
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="text-lg flex justify-between py-4">Bounding boxes may not align</div>
|
||||||
|
<Button
|
||||||
|
className="rounded-full"
|
||||||
|
type="text"
|
||||||
|
color="gray"
|
||||||
|
aria-label=" Disclaimer: This data comes from the detect feed but is shown on the recordings, it is unlikely that the
|
||||||
|
streams are perfectly in sync so the bounding box and the footage will not line up perfectly. The annotation_offset field can be used to adjust this."
|
||||||
|
>
|
||||||
|
<About className="w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimelineIcon(timelineItem) {
|
||||||
|
switch (timelineItem.class_type) {
|
||||||
|
case 'visible':
|
||||||
|
return <PlayIcon className="w-8" />;
|
||||||
|
case 'gone':
|
||||||
|
return <ExitIcon className="w-8" />;
|
||||||
|
case 'active':
|
||||||
|
return <ActiveObjectIcon className="w-8" />;
|
||||||
|
case 'stationary':
|
||||||
|
return <StationaryObjectIcon className="w-8" />;
|
||||||
|
case 'entered_zone':
|
||||||
|
return <ZoneIcon className="w-8" />;
|
||||||
|
case 'attribute':
|
||||||
|
switch (timelineItem.data.attribute) {
|
||||||
|
case 'face':
|
||||||
|
return <FaceIcon className="w-8" />;
|
||||||
|
case 'license_plate':
|
||||||
|
return <LicensePlateIcon className="w-8" />;
|
||||||
|
default:
|
||||||
|
return <DeliveryTruckIcon className="w-8" />;
|
||||||
|
}
|
||||||
|
case 'sub_label':
|
||||||
|
switch (timelineItem.data.label) {
|
||||||
|
case 'person':
|
||||||
|
return <FaceIcon className="w-8" />;
|
||||||
|
case 'car':
|
||||||
|
return <LicensePlateIcon className="w-8" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimelineItemDescription(config, timelineItem, event) {
|
||||||
|
switch (timelineItem.class_type) {
|
||||||
|
case 'visible':
|
||||||
|
return `${event.label} detected at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
|
||||||
|
date_style: 'short',
|
||||||
|
time_style: 'medium',
|
||||||
|
time_format: config.ui.time_format,
|
||||||
|
})}`;
|
||||||
|
case 'entered_zone':
|
||||||
|
return `${event.label.replaceAll('_', ' ')} entered ${timelineItem.data.zones
|
||||||
|
.join(' and ')
|
||||||
|
.replaceAll('_', ' ')} at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
|
||||||
|
date_style: 'short',
|
||||||
|
time_style: 'medium',
|
||||||
|
time_format: config.ui.time_format,
|
||||||
|
})}`;
|
||||||
|
case 'active':
|
||||||
|
return `${event.label} became active at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
|
||||||
|
date_style: 'short',
|
||||||
|
time_style: 'medium',
|
||||||
|
time_format: config.ui.time_format,
|
||||||
|
})}`;
|
||||||
|
case 'stationary':
|
||||||
|
return `${event.label} became stationary at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
|
||||||
|
date_style: 'short',
|
||||||
|
time_style: 'medium',
|
||||||
|
time_format: config.ui.time_format,
|
||||||
|
})}`;
|
||||||
|
case 'attribute': {
|
||||||
|
let title = "";
|
||||||
|
if (timelineItem.data.attribute == 'face' || timelineItem.data.attribute == 'license_plate') {
|
||||||
|
title = `${timelineItem.data.attribute.replaceAll("_", " ")} detected for ${event.label}`;
|
||||||
|
} else {
|
||||||
|
title = `${event.label} recognized as ${timelineItem.data.attribute.replaceAll("_", " ")}`
|
||||||
|
}
|
||||||
|
return `${title} at ${formatUnixTimestampToDateTime(
|
||||||
|
timelineItem.timestamp,
|
||||||
|
{
|
||||||
|
date_style: 'short',
|
||||||
|
time_style: 'medium',
|
||||||
|
time_format: config.ui.time_format,
|
||||||
|
}
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
case 'sub_label':
|
||||||
|
return `${event.label} recognized as ${timelineItem.data.sub_label} at ${formatUnixTimestampToDateTime(
|
||||||
|
timelineItem.timestamp,
|
||||||
|
{
|
||||||
|
date_style: 'short',
|
||||||
|
time_style: 'medium',
|
||||||
|
time_format: config.ui.time_format,
|
||||||
|
}
|
||||||
|
)}`;
|
||||||
|
case 'gone':
|
||||||
|
return `${event.label} left at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
|
||||||
|
date_style: 'short',
|
||||||
|
time_style: 'medium',
|
||||||
|
time_format: config.ui.time_format,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/shared/components/frigate/Tooltip.jsx
Normal file
62
src/shared/components/frigate/Tooltip.jsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { createPortal } from 'react'; // TODO implement
|
||||||
|
import { useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const TIP_SPACE = 20;
|
||||||
|
|
||||||
|
export default function Tooltip({ relativeTo, text, capitalize }) {
|
||||||
|
const [position, setPosition] = useState({ top: -9999, left: -9999 });
|
||||||
|
const portalRoot = document.getElementById('tooltips');
|
||||||
|
const ref = useRef();
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (ref && ref.current && relativeTo && relativeTo.current) {
|
||||||
|
const windowWidth = window.innerWidth;
|
||||||
|
const {
|
||||||
|
x: relativeToX,
|
||||||
|
y: relativeToY,
|
||||||
|
width: relativeToWidth,
|
||||||
|
height: relativeToHeight,
|
||||||
|
} = relativeTo.current.getBoundingClientRect();
|
||||||
|
const { width: _tipWidth, height: _tipHeight } = ref.current.getBoundingClientRect();
|
||||||
|
const tipWidth = _tipWidth * 1.1;
|
||||||
|
const tipHeight = _tipHeight * 1.1;
|
||||||
|
|
||||||
|
const left = relativeToX + Math.round(relativeToWidth / 2) + window.scrollX;
|
||||||
|
const top = relativeToY + Math.round(relativeToHeight / 2) + window.scrollY;
|
||||||
|
|
||||||
|
let newTop = top - TIP_SPACE - tipHeight;
|
||||||
|
let newLeft = left - Math.round(tipWidth / 2);
|
||||||
|
// too far right
|
||||||
|
if (newLeft + tipWidth + TIP_SPACE > windowWidth - window.scrollX) {
|
||||||
|
newLeft = Math.max(0, left - tipWidth - TIP_SPACE);
|
||||||
|
newTop = top - Math.round(tipHeight / 2);
|
||||||
|
}
|
||||||
|
// too far left
|
||||||
|
else if (newLeft < TIP_SPACE + window.scrollX) {
|
||||||
|
newLeft = left + TIP_SPACE;
|
||||||
|
newTop = top - Math.round(tipHeight / 2);
|
||||||
|
}
|
||||||
|
// too close to top
|
||||||
|
else if (newTop <= TIP_SPACE + window.scrollY) {
|
||||||
|
newTop = top + tipHeight + TIP_SPACE;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosition({ left: newLeft, top: newTop });
|
||||||
|
}
|
||||||
|
}, [relativeTo, ref]);
|
||||||
|
|
||||||
|
const tooltip = (
|
||||||
|
<div
|
||||||
|
role="tooltip"
|
||||||
|
className={`shadow max-w-lg absolute pointer-events-none bg-gray-900 dark:bg-gray-200 bg-opacity-80 rounded px-2 py-1 transition-transform transition-opacity duration-75 transform scale-90 opacity-0 text-gray-100 dark:text-gray-900 text-sm ${
|
||||||
|
capitalize ? 'capitalize' : ''
|
||||||
|
} ${position.top >= 0 ? 'opacity-100 scale-100' : ''}`}
|
||||||
|
ref={ref}
|
||||||
|
style={position}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return portalRoot ? createPortal(tooltip, portalRoot) : tooltip;
|
||||||
|
}
|
||||||
100
src/shared/components/frigate/VideoPlayer.jsx
Normal file
100
src/shared/components/frigate/VideoPlayer.jsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
import videojs from 'video.js';
|
||||||
|
import 'videojs-playlist';
|
||||||
|
import 'video.js/dist/video-js.css';
|
||||||
|
|
||||||
|
export default function VideoPlayer({
|
||||||
|
children,
|
||||||
|
options,
|
||||||
|
seekOptions = { forward: 30, backward: 10 }, onReady = () => { }, onDispose = () => { }
|
||||||
|
}) {
|
||||||
|
const playerRef = useRef();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const defaultOptions = {
|
||||||
|
controls: true,
|
||||||
|
controlBar: {
|
||||||
|
skipButtons: seekOptions,
|
||||||
|
},
|
||||||
|
playbackRates: [0.5, 1, 2, 4, 8],
|
||||||
|
fluid: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
if (!videojs.browser.IS_FIREFOX) {
|
||||||
|
defaultOptions.playbackRates.push(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = videojs(playerRef.current, { ...defaultOptions, ...options }, () => {
|
||||||
|
onReady(player);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allows player to continue on error
|
||||||
|
player.reloadSourceOnError();
|
||||||
|
|
||||||
|
// Disable fullscreen on iOS if we have children
|
||||||
|
if (
|
||||||
|
children &&
|
||||||
|
videojs.browser.IS_IOS &&
|
||||||
|
videojs.browser.IOS_VERSION > 9 &&
|
||||||
|
!player.el_.ownerDocument.querySelector('.bc-iframe')
|
||||||
|
) {
|
||||||
|
player.tech_.el_.setAttribute('playsinline', 'playsinline');
|
||||||
|
player.tech_.supportsFullScreen = function () {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const screen = window.screen;
|
||||||
|
|
||||||
|
const angle = () => {
|
||||||
|
// iOS
|
||||||
|
if (typeof window.orientation === 'number') {
|
||||||
|
return window.orientation;
|
||||||
|
}
|
||||||
|
// Android
|
||||||
|
if (screen && screen.orientation && screen.orientation.angle) {
|
||||||
|
return window.orientation;
|
||||||
|
}
|
||||||
|
videojs.log('angle unknown');
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotationHandler = () => {
|
||||||
|
const currentAngle = angle();
|
||||||
|
|
||||||
|
if (currentAngle === 90 || currentAngle === 270 || currentAngle === -90) {
|
||||||
|
if (player.paused() === false) {
|
||||||
|
player.requestFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((currentAngle === 0 || currentAngle === 180) && player.isFullscreen()) {
|
||||||
|
player.exitFullscreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (videojs.browser.IS_IOS) {
|
||||||
|
window.addEventListener('orientationchange', rotationHandler);
|
||||||
|
} else if (videojs.browser.IS_ANDROID && screen.orientation) {
|
||||||
|
// addEventListener('orientationchange') is not a user interaction on Android
|
||||||
|
screen.orientation.onchange = rotationHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (videojs.browser.IS_IOS) {
|
||||||
|
window.removeEventListener('orientationchange', rotationHandler);
|
||||||
|
}
|
||||||
|
player.dispose();
|
||||||
|
onDispose();
|
||||||
|
};
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-vjs-player>
|
||||||
|
{/* Setting an empty data-setup is required to override the default values and allow video to be fit the size of its parent */}
|
||||||
|
<video ref={playerRef} className="small-player video-js vjs-default-skin" data-setup="{}" controls playsinline />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
237
src/shared/components/frigate/dateUtil.ts
Normal file
237
src/shared/components/frigate/dateUtil.ts
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
import strftime from 'strftime';
|
||||||
|
import { fromUnixTime, intervalToDuration, formatDuration } from 'date-fns';
|
||||||
|
export const longToDate = (long: number): Date => new Date(long * 1000);
|
||||||
|
export const epochToLong = (date: number): number => date / 1000;
|
||||||
|
export const dateToLong = (date: Date): number => epochToLong(date.getTime());
|
||||||
|
|
||||||
|
const getDateTimeYesterday = (dateTime: Date): Date => {
|
||||||
|
const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000;
|
||||||
|
return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNowYesterday = (): Date => {
|
||||||
|
return getDateTimeYesterday(new Date());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNowYesterdayInLong = (): number => {
|
||||||
|
return dateToLong(getNowYesterday());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function takes in a Unix timestamp, configuration options for date/time display, and an optional strftime format string,
|
||||||
|
* and returns a formatted date/time string.
|
||||||
|
*
|
||||||
|
* If the Unix timestamp is not provided, it returns "Invalid time".
|
||||||
|
*
|
||||||
|
* The configuration options determine how the date and time are formatted.
|
||||||
|
* The `timezone` option allows you to specify a specific timezone for the output, otherwise the user's browser timezone will be used.
|
||||||
|
* The `use12hour` option allows you to display time in a 12-hour format if true, and 24-hour format if false.
|
||||||
|
* The `dateStyle` and `timeStyle` options allow you to specify pre-defined formats for displaying the date and time.
|
||||||
|
* The `strftime_fmt` option allows you to specify a custom format using the strftime syntax.
|
||||||
|
*
|
||||||
|
* If both `strftime_fmt` and `dateStyle`/`timeStyle` are provided, `strftime_fmt` takes precedence.
|
||||||
|
*
|
||||||
|
* @param unixTimestamp The Unix timestamp to format
|
||||||
|
* @param config An object containing the configuration options for date/time display
|
||||||
|
* @returns The formatted date/time string, or "Invalid time" if the Unix timestamp is not provided or invalid.
|
||||||
|
*/
|
||||||
|
interface DateTimeStyle {
|
||||||
|
timezone: string;
|
||||||
|
time_format: 'browser' | '12hour' | '24hour';
|
||||||
|
date_style: 'full' | 'long' | 'medium' | 'short';
|
||||||
|
time_style: 'full' | 'long' | 'medium' | 'short';
|
||||||
|
strftime_fmt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// only used as a fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
|
||||||
|
const formatMap: {
|
||||||
|
[k: string]: {
|
||||||
|
date: { year: 'numeric' | '2-digit'; month: 'long' | 'short' | '2-digit'; day: 'numeric' | '2-digit' };
|
||||||
|
time: { hour: 'numeric'; minute: 'numeric'; second?: 'numeric'; timeZoneName?: 'short' | 'long' };
|
||||||
|
};
|
||||||
|
} = {
|
||||||
|
full: {
|
||||||
|
date: { year: 'numeric', month: 'long', day: 'numeric' },
|
||||||
|
time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' },
|
||||||
|
},
|
||||||
|
long: {
|
||||||
|
date: { year: 'numeric', month: 'long', day: 'numeric' },
|
||||||
|
time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' },
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
date: { year: 'numeric', month: 'short', day: 'numeric' },
|
||||||
|
time: { hour: 'numeric', minute: 'numeric', second: 'numeric' },
|
||||||
|
},
|
||||||
|
short: { date: { year: '2-digit', month: '2-digit', day: '2-digit' }, time: { hour: 'numeric', minute: 'numeric' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to get the system's time zone using Intl.DateTimeFormat. If that fails (for instance, in environments
|
||||||
|
* where Intl is not fully supported), it calculates the UTC offset for the current system time and returns
|
||||||
|
* it in a string format.
|
||||||
|
*
|
||||||
|
* Keeping the Intl.DateTimeFormat for now, as this is the recommended way to get the time zone.
|
||||||
|
* https://stackoverflow.com/a/34602679
|
||||||
|
*
|
||||||
|
* Intl.DateTimeFormat function as of April 2023, works in 95.03% of the browsers used globally
|
||||||
|
* https://caniuse.com/mdn-javascript_builtins_intl_datetimeformat_resolvedoptions_computed_timezone
|
||||||
|
*
|
||||||
|
* @returns {string} The resolved time zone or a calculated UTC offset.
|
||||||
|
* The returned string will either be a named time zone (e.g., "America/Los_Angeles"), or it will follow
|
||||||
|
* the format "UTC±HH:MM".
|
||||||
|
*/
|
||||||
|
const getResolvedTimeZone = () => {
|
||||||
|
try {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
} catch (error) {
|
||||||
|
const offsetMinutes = new Date().getTimezoneOffset();
|
||||||
|
return `UTC${offsetMinutes < 0 ? '+' : '-'}${Math.abs(offsetMinutes / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}:${Math.abs(offsetMinutes % 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a Unix timestamp into a human-readable date/time string.
|
||||||
|
*
|
||||||
|
* The format of the output string is determined by a configuration object passed as an argument, which
|
||||||
|
* may specify a time zone, 12- or 24-hour time, and various stylistic options for the date and time.
|
||||||
|
* If these options are not specified, the function will use system defaults or sensible fallbacks.
|
||||||
|
*
|
||||||
|
* The function is robust to environments where the Intl API is not fully supported, and includes a
|
||||||
|
* fallback method to create a formatted date/time string in such cases.
|
||||||
|
*
|
||||||
|
* @param {number} unixTimestamp - The Unix timestamp to be formatted.
|
||||||
|
* @param {DateTimeStyle} config - User configuration object.
|
||||||
|
* @returns {string} A formatted date/time string.
|
||||||
|
*
|
||||||
|
* @throws {Error} If the given unixTimestamp is not a valid number, the function will return 'Invalid time'.
|
||||||
|
*/
|
||||||
|
export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: DateTimeStyle): string => {
|
||||||
|
const { timezone, time_format, date_style, time_style, strftime_fmt } = config;
|
||||||
|
const locale = window.navigator?.language || 'en-us';
|
||||||
|
if (isNaN(unixTimestamp)) {
|
||||||
|
return 'Invalid time';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(unixTimestamp * 1000);
|
||||||
|
const resolvedTimeZone = getResolvedTimeZone();
|
||||||
|
|
||||||
|
// use strftime_fmt if defined in config
|
||||||
|
if (strftime_fmt) {
|
||||||
|
const offset = getUTCOffset(date, timezone || resolvedTimeZone);
|
||||||
|
// @ts-ignore
|
||||||
|
const strftime_locale = strftime.timezone(offset).localizeByIdentifier(locale);
|
||||||
|
return strftime_locale(strftime_fmt, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DateTime format options
|
||||||
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
|
dateStyle: date_style,
|
||||||
|
timeStyle: time_style,
|
||||||
|
hour12: time_format !== 'browser' ? time_format == '12hour' : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only set timeZone option when resolvedTimeZone does not match UTC±HH:MM format, or when timezone is set in config
|
||||||
|
const isUTCOffsetFormat = /^UTC[+-]\d{2}:\d{2}$/.test(resolvedTimeZone);
|
||||||
|
if (timezone || !isUTCOffsetFormat) {
|
||||||
|
options.timeZone = timezone || resolvedTimeZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatter = new Intl.DateTimeFormat(locale, options);
|
||||||
|
const formattedDateTime = formatter.format(date);
|
||||||
|
|
||||||
|
// Regex to check for existence of time. This is needed because dateStyle/timeStyle is not always supported.
|
||||||
|
const containsTime = /\d{1,2}:\d{1,2}/.test(formattedDateTime);
|
||||||
|
|
||||||
|
// fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
|
||||||
|
// This works even tough the timezone is undefined, it will use the runtime's default time zone
|
||||||
|
if (!containsTime) {
|
||||||
|
const dateOptions = { ...formatMap[date_style]?.date, timeZone: options.timeZone, hour12: options.hour12 };
|
||||||
|
const timeOptions = { ...formatMap[time_style]?.time, timeZone: options.timeZone, hour12: options.hour12 };
|
||||||
|
|
||||||
|
return `${date.toLocaleDateString(locale, dateOptions)} ${date.toLocaleTimeString(locale, timeOptions)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formattedDateTime;
|
||||||
|
} catch (error) {
|
||||||
|
return 'Invalid time';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DurationToken {
|
||||||
|
xSeconds: string;
|
||||||
|
xMinutes: string;
|
||||||
|
xHours: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function takes in start and end time in unix timestamp,
|
||||||
|
* and returns the duration between start and end time in hours, minutes and seconds.
|
||||||
|
* If end time is not provided, it returns 'In Progress'
|
||||||
|
* @param start_time: number - Unix timestamp for start time
|
||||||
|
* @param end_time: number|null - Unix timestamp for end time
|
||||||
|
* @returns string - duration or 'In Progress' if end time is not provided
|
||||||
|
*/
|
||||||
|
export const getDurationFromTimestamps = (start_time: number, end_time: number | null): string => {
|
||||||
|
if (isNaN(start_time)) {
|
||||||
|
return 'Invalid start time';
|
||||||
|
}
|
||||||
|
let duration = 'In Progress';
|
||||||
|
if (end_time !== null) {
|
||||||
|
if (isNaN(end_time)) {
|
||||||
|
return 'Invalid end time';
|
||||||
|
}
|
||||||
|
const start = fromUnixTime(start_time);
|
||||||
|
const end = fromUnixTime(end_time);
|
||||||
|
const formatDistanceLocale: DurationToken = {
|
||||||
|
xSeconds: '{{count}}s',
|
||||||
|
xMinutes: '{{count}}m',
|
||||||
|
xHours: '{{count}}h',
|
||||||
|
};
|
||||||
|
const shortEnLocale = {
|
||||||
|
formatDistance: (token: keyof DurationToken, count: number) =>
|
||||||
|
formatDistanceLocale[token].replace('{{count}}', count.toString()),
|
||||||
|
};
|
||||||
|
duration = formatDuration(intervalToDuration({ start, end }), {
|
||||||
|
format: ['hours', 'minutes', 'seconds'],
|
||||||
|
// @ts-ignore
|
||||||
|
locale: shortEnLocale,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return duration;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapted from https://stackoverflow.com/a/29268535 this takes a timezone string and
|
||||||
|
* returns the offset of that timezone from UTC in minutes.
|
||||||
|
* @param timezone string representation of the timezone the user is requesting
|
||||||
|
* @returns number of minutes offset from UTC
|
||||||
|
*/
|
||||||
|
const getUTCOffset = (date: Date, timezone: string): number => {
|
||||||
|
// If timezone is in UTC±HH:MM format, parse it to get offset
|
||||||
|
const utcOffsetMatch = timezone.match(/^UTC([+-])(\d{2}):(\d{2})$/);
|
||||||
|
if (utcOffsetMatch) {
|
||||||
|
const hours = parseInt(utcOffsetMatch[2], 10);
|
||||||
|
const minutes = parseInt(utcOffsetMatch[3], 10);
|
||||||
|
return (utcOffsetMatch[1] === '+' ? 1 : -1) * (hours * 60 + minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, calculate offset using provided timezone
|
||||||
|
const utcDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
|
||||||
|
// locale of en-CA is required for proper locale format
|
||||||
|
let iso = utcDate.toLocaleString('en-CA', { timeZone: timezone, hour12: false }).replace(', ', 'T');
|
||||||
|
iso += `.${utcDate.getMilliseconds().toString().padStart(3, '0')}`;
|
||||||
|
let target = new Date(`${iso}Z`);
|
||||||
|
|
||||||
|
// safari doesn't like the default format
|
||||||
|
if (isNaN(target.getTime())) {
|
||||||
|
iso = iso.replace("T", " ").split(".")[0];
|
||||||
|
target = new Date(`${iso}+000`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (target.getTime() - utcDate.getTime()) / 60 / 1000;
|
||||||
|
};
|
||||||
19
src/shared/components/frigate/icons/About.jsx
Normal file
19
src/shared/components/frigate/icons/About.jsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function About({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className={`${className}`}
|
||||||
|
>
|
||||||
|
<path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(About);
|
||||||
24
src/shared/components/frigate/icons/CalendarIcon.jsx
Normal file
24
src/shared/components/frigate/icons/CalendarIcon.jsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function CalendarIcon({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(CalendarIcon);
|
||||||
24
src/shared/components/frigate/icons/Camera.jsx
Normal file
24
src/shared/components/frigate/icons/Camera.jsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Camera({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Camera);
|
||||||
24
src/shared/components/frigate/icons/Clip.jsx
Normal file
24
src/shared/components/frigate/icons/Clip.jsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Clip({ className = 'h-6 w-6', stroke = 'currentColor', onClick = () => {} }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Clip);
|
||||||
24
src/shared/components/frigate/icons/Clock.jsx
Normal file
24
src/shared/components/frigate/icons/Clock.jsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Clock({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Clock);
|
||||||
24
src/shared/components/frigate/icons/Delete.jsx
Normal file
24
src/shared/components/frigate/icons/Delete.jsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Delete({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Delete);
|
||||||
24
src/shared/components/frigate/icons/Download.jsx
Normal file
24
src/shared/components/frigate/icons/Download.jsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Download({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Download);
|
||||||
13
src/shared/components/frigate/icons/Menu.jsx
Normal file
13
src/shared/components/frigate/icons/Menu.jsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Menu({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
||||||
|
<path d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Menu);
|
||||||
13
src/shared/components/frigate/icons/MenuOpen.jsx
Normal file
13
src/shared/components/frigate/icons/MenuOpen.jsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function MenuOpen({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
||||||
|
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||||
|
<path d="M3 18h13v-2H3v2zm0-5h10v-2H3v2zm0-7v2h13V6H3zm18 9.59L17.42 12 21 8.41 19.59 7l-5 5 5 5L21 15.59z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(MenuOpen);
|
||||||
20
src/shared/components/frigate/icons/Score.jsx
Normal file
20
src/shared/components/frigate/icons/Score.jsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Score({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'currentColor', onClick = () => {} }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<title>percent</title>
|
||||||
|
<path d="M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M19,19H15V21H19A2,2 0 0,0 21,19V15H19M19,3H15V5H19V9H21V5A2,2 0 0,0 19,3M5,5H9V3H5A2,2 0 0,0 3,5V9H5M5,15H3V19A2,2 0 0,0 5,21H9V19H5V15Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Score);
|
||||||
24
src/shared/components/frigate/icons/Snapshot.jsx
Normal file
24
src/shared/components/frigate/icons/Snapshot.jsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Snapshot({ className = 'h-6 w-6', stroke = 'currentColor', onClick = () => {} }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Snapshot);
|
||||||
24
src/shared/components/frigate/icons/StarRecording.jsx
Normal file
24
src/shared/components/frigate/icons/StarRecording.jsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function StarRecording({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(StarRecording);
|
||||||
19
src/shared/components/frigate/icons/Submitted.jsx
Normal file
19
src/shared/components/frigate/icons/Submitted.jsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Submitted({ className = 'h-6 w-6', inner_fill = 'none', outer_stroke = 'currentColor', onClick = () => {} }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<rect x="10" y="15" fill={inner_fill} width="12" height="2"/>
|
||||||
|
<rect x="15" y="10" fill={inner_fill} width="2" height="12"/>
|
||||||
|
<circle fill="none" stroke={outer_stroke} stroke-width="2" stroke-miterlimit="10" cx="16" cy="16" r="12"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Submitted);
|
||||||
23
src/shared/components/frigate/icons/UploadPlus.jsx
Normal file
23
src/shared/components/frigate/icons/UploadPlus.jsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function UploadPlus({ className = 'h-6 w-6', stroke = 'currentColor', onClick = () => {} }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(UploadPlus);
|
||||||
25
src/shared/components/frigate/icons/Zone.jsx
Normal file
25
src/shared/components/frigate/icons/Zone.jsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Zone({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||||
|
/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Zone);
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { DEFAULT_THEME } from '@mantine/core';
|
import { DEFAULT_THEME } from '@mantine/core';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const СogwheelSVG = (
|
const CogwheelSVG = (
|
||||||
<svg
|
<svg
|
||||||
width="86"
|
width="86"
|
||||||
height="86"
|
height="86"
|
||||||
@ -35,4 +35,4 @@ const СogwheelSVG = (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default СogwheelSVG;
|
export default CogwheelSVG;
|
||||||
15
yarn.lock
15
yarn.lock
@ -2572,6 +2572,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
|
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
|
||||||
integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==
|
integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==
|
||||||
|
|
||||||
|
"@types/strftime@^0.9.8":
|
||||||
|
version "0.9.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/strftime/-/strftime-0.9.8.tgz#1473a08514841faff9c7ea0805afcf66a162b9b6"
|
||||||
|
integrity sha512-QIvDlGAKyF3YJbT3QZnfC+RIvV5noyDbi+ZJ5rkaSRqxCGrYJefgXm3leZAjtoQOutZe1hCXbAg+p89/Vj4HlQ==
|
||||||
|
|
||||||
"@types/testing-library__jest-dom@^5.9.1":
|
"@types/testing-library__jest-dom@^5.9.1":
|
||||||
version "5.14.7"
|
version "5.14.7"
|
||||||
resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.7.tgz#fff92bed2a32c58a9224a85603e731519c0a9037"
|
resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.7.tgz#fff92bed2a32c58a9224a85603e731519c0a9037"
|
||||||
@ -4046,6 +4051,11 @@ data-urls@^2.0.0:
|
|||||||
whatwg-mimetype "^2.3.0"
|
whatwg-mimetype "^2.3.0"
|
||||||
whatwg-url "^8.0.0"
|
whatwg-url "^8.0.0"
|
||||||
|
|
||||||
|
date-fns@^3.3.1:
|
||||||
|
version "3.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.3.1.tgz#7581daca0892d139736697717a168afbb908cfed"
|
||||||
|
integrity sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==
|
||||||
|
|
||||||
dayjs@^1.11.9:
|
dayjs@^1.11.9:
|
||||||
version "1.11.9"
|
version "1.11.9"
|
||||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.9.tgz#9ca491933fadd0a60a2c19f6c237c03517d71d1a"
|
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.9.tgz#9ca491933fadd0a60a2c19f6c237c03517d71d1a"
|
||||||
@ -8939,6 +8949,11 @@ stop-iteration-iterator@^1.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
internal-slot "^1.0.4"
|
internal-slot "^1.0.4"
|
||||||
|
|
||||||
|
strftime@0.10.1:
|
||||||
|
version "0.10.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/strftime/-/strftime-0.10.1.tgz#108af1176a7d5252cfbddbdb2af044dfae538389"
|
||||||
|
integrity sha512-nVvH6JG8KlXFPC0f8lojLgEsPA18lRpLZ+RrJh/NkQV2tqOgZfbas8gcU8SFgnnqR3rWzZPYu6N2A3xzs/8rQg==
|
||||||
|
|
||||||
string-length@^4.0.1:
|
string-length@^4.0.1:
|
||||||
version "4.0.2"
|
version "4.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"
|
resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user