import { h, Fragment } from 'preact'; import { route } from 'preact-router'; import ActivityIndicator from '../components/ActivityIndicator'; import Heading from '../components/Heading'; import { Tabs, TextTab } from '../components/Tabs'; 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'; import { fromUnixTime, intervalToDuration, formatDuration } from 'date-fns'; import MultiSelect from '../components/MultiSelect'; 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; }; const clipDuration = (start_time, end_time) => { const start = fromUnixTime(start_time); const end = fromUnixTime(end_time); let duration = 'In Progress'; if (end_time) { duration = formatDuration(intervalToDuration({ start, end })); } return duration; }; export default function Events({ path, ...props }) { const apiHost = useApiHost(); 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', favorites: props.favorites ?? 0, }); const [state, setState] = useState({ showDownloadMenu: false, showDatePicker: false, showCalendar: false, showPlusConfig: false, }); const [uploading, setUploading] = useState([]); const [viewEvent, setViewEvent] = useState(); const [eventDetailType, setEventDetailType] = useState('clip'); const [downloadEvent, setDownloadEvent] = useState({ id: null, has_clip: false, has_snapshot: false, plus_id: undefined, }); const [deleteFavoriteState, setDeleteFavoriteState] = useState({ deletingFavoriteEventId: null, showDeleteFavorite: false, }); 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 { 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(config?.cameras || {}) .reduce((memo, camera) => { memo = memo.concat(camera?.objects?.track || []); return memo; }, config?.objects?.track || []) .filter((value, i, self) => self.indexOf(value) === i), sub_labels: (allSubLabels || []).length > 0 ? [...Object.values(allSubLabels), 'None'] : [], }), [config, 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 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 }); } }; const handleEventDetailTabChange = (index) => { setEventDetailType(index == 0 ? 'clip' : 'image'); }; if (!config) { return ; } const timezone = config.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone; return (
Events
onToggleNamedFilter('cameras', item)} onShowAll={() => onFilter('cameras', ['all'])} onSelectSingle={(item) => onFilter('cameras', item)} /> onToggleNamedFilter('labels', item)} onShowAll={() => onFilter('labels', ['all'])} onSelectSingle={(item) => onFilter('labels', item)} /> onToggleNamedFilter('zones', item)} onShowAll={() => onFilter('zones', ['all'])} onSelectSingle={(item) => onFilter('zones', item)} /> {filterValues.sub_labels.length > 0 && ( onToggleNamedFilter('sub_labels', item)} onShowAll={() => onFilter('sub_labels', ['all'])} onSelectSingle={(item) => onFilter('sub_labels', item)} /> )} onFilter('favorites', searchParams.favorites ? 0 : 1)} fill={searchParams.favorites == 1 ? 'currentColor' : 'none'} />
setState({ ...state, showDatePicker: true })} />
{state.showDownloadMenu && ( setState({ ...state, showDownloadMenu: false })} relativeTo={downloadButton}> {downloadEvent.has_snapshot && ( )} {downloadEvent.has_clip && ( )} {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
)} {deleteFavoriteState.showDeleteFavorite && (
Delete Saved Event?

Confirm deletion of saved event.

)}
{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.replaceAll('_', ' ')}: ${event.sub_label.replaceAll('_', ' ')}` : event.label.replaceAll('_', ' ')}{' '} ({(event.top_score * 100).toFixed(0)}%)
{new Date(event.start_time * 1000).toLocaleDateString({ timeZone: timezone })}{' '} {new Date(event.start_time * 1000).toLocaleTimeString({ timeZone: timezone })} ( {clipDuration(event.start_time, event.end_time)})
{event.camera.replaceAll('_', ' ')}
{event.zones.join(', ').replaceAll('_', ' ')}
onDelete(e, event.id, event.retain_indefinitely)} /> onDownloadClick(e, event)} />
{viewEvent !== event.id ? null : (
{eventDetailType == 'clip' && event.has_clip ? ( {}} /> ) : null} {eventDetailType == 'image' || !event.has_clip ? (
{`${event.label}
) : null}
)}
); }); }) ) : ( )}
{isDone ? null : }
); }