import { h, Fragment } from 'preact'; import { route } from 'preact-router'; import ActivityIndicator from '../components/ActivityIndicator'; import Heading from '../components/Heading'; import { useApiHost } from '../api'; import useSWR from 'swr'; import useSWRInfinite from 'swr/infinite'; import axios from 'axios'; import { useState, useRef, useCallback, useMemo } from 'preact/hooks'; import VideoPlayer from '../components/VideoPlayer'; import { StarRecording } from '../icons/StarRecording'; import { Snapshot } from '../icons/Snapshot'; import { UploadPlus } from '../icons/UploadPlus'; import { Clip } from '../icons/Clip'; import { Zone } from '../icons/Zone'; import { Camera } from '../icons/Camera'; import { Delete } from '../icons/Delete'; import { Download } from '../icons/Download'; import Menu, { MenuItem } from '../components/Menu'; import CalendarIcon from '../icons/Calendar'; import Calendar from '../components/Calendar'; import Button from '../components/Button'; import Dialog from '../components/Dialog'; const API_LIMIT = 25; const daysAgo = (num) => { let date = new Date(); date.setDate(date.getDate() - num); return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000; }; const monthsAgo = (num) => { 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 [searchParams, setSearchParams] = useState({ before: null, after: null, camera: props.camera ?? 'all', label: props.label ?? 'all', zone: props.zone ?? 'all', }); const [state, setState] = useState({ showDownloadMenu: false, showDatePicker: false, showCalendar: false, showPlusConfig: false, }); const [uploading, setUploading] = useState([]); const [viewEvent, setViewEvent] = useState(); const [downloadEvent, setDownloadEvent] = useState({ id: null, has_clip: false, has_snapshot: false, plus_id: undefined, }); const eventsFetcher = useCallback((path, params) => { params = { ...params, include_thumbnails: 0, limit: API_LIMIT }; return axios.get(path, { params }).then((res) => res.data); }, []); 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: eventPages, mutate, size, setSize, isValidating } = useSWRInfinite(getKey, eventsFetcher); const { data: config } = useSWR('config'); 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), labels: Object.values(config?.cameras || {}) .reduce((memo, camera) => { memo = memo.concat(camera?.objects?.track || []); return memo; }, config?.objects?.track || []) .filter((value, i, self) => self.indexOf(value) === i), }), [config] ); 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) => { e.stopPropagation(); const response = await axios.delete(`events/${eventId}`); if (response.status === 200) { mutate(); } }; const datePicker = useRef(); const downloadButton = useRef(); const onDownloadClick = (e, event) => { e.stopPropagation(); setDownloadEvent((_prev) => ({ id: event.id, has_clip: event.has_clip, has_snapshot: event.has_snapshot, plus_id: event.plus_id, })); downloadButton.current = e.target; setState({ ...state, showDownloadMenu: true }); }; const handleSelectDateRange = useCallback( (dates) => { setSearchParams({ ...searchParams, before: dates.before, after: dates.after }); setState({ ...state, showDatePicker: false }); }, [searchParams, setSearchParams, state, setState] ); const onFilter = useCallback( (name, value) => { 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 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(); observer.current = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && !isDone) { setSize(size + 1); } }); if (node) observer.current.observe(node); }, [size, setSize, isValidating, isDone] ); const onSendToPlus = async (id, e) => { if (e) { e.stopPropagation(); } if (uploading.includes(id)) { return; } if (!config.plus.enabled) { setState({ ...state, showDownloadMenu: false, showPlusConfig: true }); return; } setUploading((prev) => [...prev, id]); const response = await axios.post(`events/${id}/plus`); 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 }); } }; if (!config) { return ; } return (
Events
setState({ ...state, showDatePicker: true })} />
{state.showDownloadMenu && ( setState({ ...state, showDownloadMenu: false })} relativeTo={downloadButton}> {downloadEvent.has_snapshot && ( )} {downloadEvent.has_clip && ( )} {downloadEvent.has_snapshot && !downloadEvent.plus_id && ( onSendToPlus(downloadEvent.id)} /> )} {downloadEvent.plus_id && ( setState({ ...state, showDownloadMenu: false })} /> )} )} {state.showDatePicker && ( setState({ ...state, setShowDatePicker: false })} relativeTo={datePicker} > { setState({ ...state, showCalendar: true, showDatePicker: false }); }} /> )} {state.showCalendar && ( setState({ ...state, showCalendar: false })} relativeTo={datePicker} > setState({ ...state, showCalendar: false })} /> )} {state.showPlusConfig && (
Setup a Frigate+ Account

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

https://plus.frigate.video
)}
{eventPages ? ( eventPages.map((page, i) => { const lastPage = eventPages.length === i + 1; return page.map((event, j) => { const lastEvent = lastPage && page.length === j + 1; return (
(viewEvent === event.id ? setViewEvent(null) : setViewEvent(event.id))} >
onSave(e, event.id, !event.retain_indefinitely)} fill={event.retain_indefinitely ? 'currentColor' : 'none'} /> {event.end_time ? null : (
In progress
)}
{event.sub_label ? `${event.label}: ${event.sub_label}` : event.label} ( {(event.top_score * 100).toFixed(0)}%)
{new Date(event.start_time * 1000).toLocaleDateString()}{' '} {new Date(event.start_time * 1000).toLocaleTimeString()}
{event.camera}
{event.zones.join(',')}
onDelete(e, event.id)} /> onDownloadClick(e, event)} />
{viewEvent !== event.id ? null : (
{event.has_clip ? ( <> Clip {}} /> ) : (
{event.has_snapshot ? 'Best Image' : 'Thumbnail'} {`${event.label}
)}
)}
); }); }) ) : ( )}
{isDone ? null : }
); }