mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-02 01:05:20 +03:00
improved date range selection
This commit is contained in:
parent
bd3638b755
commit
86f54bb4ff
@ -35,8 +35,7 @@ const Calender = ({ onChange, calenderRef }) => {
|
|||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
selectedDay: null,
|
selectedDay: null,
|
||||||
selectedRange: { before: null, after: null },
|
timeRange: { before: null, after: null },
|
||||||
selectedRangeBefore: false,
|
|
||||||
monthDetails: null,
|
monthDetails: null,
|
||||||
});
|
});
|
||||||
const getNumberOfDays = useCallback((year, month) => {
|
const getNumberOfDays = useCallback((year, month) => {
|
||||||
@ -104,42 +103,55 @@ const Calender = ({ onChange, calenderRef }) => {
|
|||||||
return day.timestamp === todayTimestamp;
|
return day.timestamp === todayTimestamp;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSelectedDay = (day) => {
|
|
||||||
return day.timestamp === state.selectedDay;
|
|
||||||
};
|
|
||||||
const isSelectedRange = (day) => {
|
const isSelectedRange = (day) => {
|
||||||
if (!state.selectedRange.after || !state.selectedRange.before) return;
|
if (!state.timeRange.after || !state.timeRange.before) return;
|
||||||
|
|
||||||
return day.timestamp < state.selectedRange.before * 1000 && day.timestamp >= state.selectedRange.after * 1000;
|
return day.timestamp < state.timeRange.before && day.timestamp >= state.timeRange.after;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isFirstDayInRange = (day) => {
|
const isFirstDayInRange = (day) => {
|
||||||
if (isCurrentDay(day)) return;
|
if (isCurrentDay(day)) return;
|
||||||
return state.selectedRange.after * 1000 === day.timestamp;
|
return state.timeRange.after === day.timestamp;
|
||||||
};
|
};
|
||||||
const isLastDayInRange = (day) => {
|
const isLastDayInRange = (day) => {
|
||||||
if (state.selectedRange.after * 1000 === todayTimestamp) return;
|
return state.timeRange.before === new Date(day.timestamp).setHours(24, 0, 0, 0);
|
||||||
return state.selectedRange.before * 1000 === day.timestamp + 86400000;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMonthStr = (month) => monthMap[Math.max(Math.min(11, month), 0)] || 'Month';
|
const getMonthStr = (month) => monthMap[Math.max(Math.min(11, month), 0)] || 'Month';
|
||||||
|
|
||||||
const onDateClick = (day) => {
|
const onDateClick = (day) => {
|
||||||
const range = {
|
const { before, after } = state.timeRange;
|
||||||
selectedRange: state.selectedRangeBefore
|
let timeRange = { before: null, after: null };
|
||||||
? { ...state.selectedRange, before: new Date(day.timestamp).setHours(24, 0, 0, 0) / 1000 }
|
|
||||||
: { ...state.selectedRange, after: day.timestamp / 1000 },
|
// user has selected a date < after, reset values
|
||||||
};
|
if (after === null || day.timestamp < after) {
|
||||||
|
timeRange = { before: new Date(day.timestamp).setHours(24, 0, 0, 0), after: day.timestamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
// user has selected a date > after
|
||||||
|
if (after !== null && before !== new Date(day.timestamp).setHours(24, 0, 0, 0) && day.timestamp > after) {
|
||||||
|
timeRange = {
|
||||||
|
after,
|
||||||
|
before:
|
||||||
|
day.timestamp >= todayTimestamp
|
||||||
|
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
|
||||||
|
: new Date(day.timestamp).setHours(24, 0, 0, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset values
|
||||||
|
if (before === new Date(day.timestamp).setHours(24, 0, 0, 0)) {
|
||||||
|
timeRange = { before: null, after: null };
|
||||||
|
}
|
||||||
|
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
...range,
|
timeRange,
|
||||||
selectedDay: day.timestamp,
|
selectedDay: day.timestamp,
|
||||||
selectedRangeBefore: !state.selectedRangeBefore,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange({ before: range.selectedRange.before, after: range.selectedRange.after });
|
onChange(timeRange.after ? { before: timeRange.before / 1000, after: timeRange.after / 1000 } : ['all']);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -185,9 +197,8 @@ const Calender = ({ onChange, calenderRef }) => {
|
|||||||
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
|
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
|
||||||
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
|
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
|
||||||
}
|
}
|
||||||
${isSelectedDay(day) ? 'bg-gray-100 dark:hover:bg-gray-100' : ''}
|
|
||||||
${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''}
|
${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''}
|
||||||
${isSelectedRange(day) ? ' bg-blue-500 dark:hover:bg-blue-600' : ''}
|
${isSelectedRange(day) ? ' bg-blue-600 dark:hover:bg-blue-600' : ''}
|
||||||
${isLastDayInRange(day) ? ' rounded-r-xl ' : ''}
|
${isLastDayInRange(day) ? ' rounded-r-xl ' : ''}
|
||||||
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
|
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@ -1,316 +0,0 @@
|
|||||||
import { h, Fragment } from 'preact';
|
|
||||||
import ActivityIndicator from '../components/ActivityIndicator';
|
|
||||||
import Heading from '../components/Heading';
|
|
||||||
import Link from '../components/Link';
|
|
||||||
import Select from '../components/Select';
|
|
||||||
import produce from 'immer';
|
|
||||||
import { route } from 'preact-router';
|
|
||||||
import { useIntersectionObserver } from '../hooks';
|
|
||||||
import { FetchStatus, useApiHost, useConfig, useEvents } from '../api';
|
|
||||||
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from '../components/Table';
|
|
||||||
import { useCallback, useEffect, useMemo, useReducer, useState } from 'preact/hooks';
|
|
||||||
import { DateFilterOptions } from '../components/DatePicker';
|
|
||||||
|
|
||||||
const API_LIMIT = 25;
|
|
||||||
|
|
||||||
const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {} });
|
|
||||||
const reducer = (state = initialState, action) => {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'APPEND_EVENTS': {
|
|
||||||
const {
|
|
||||||
meta: { searchString },
|
|
||||||
payload,
|
|
||||||
} = action;
|
|
||||||
|
|
||||||
return produce(state, (draftState) => {
|
|
||||||
draftState.searchStrings[searchString] = true;
|
|
||||||
draftState.events.push(...payload);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'REACHED_END': {
|
|
||||||
const {
|
|
||||||
meta: { searchString },
|
|
||||||
} = action;
|
|
||||||
return produce(state, (draftState) => {
|
|
||||||
draftState.reachedEnd = true;
|
|
||||||
draftState.searchStrings[searchString] = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'RESET':
|
|
||||||
return initialState;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultSearchString = (limit) => `include_thumbnails=0&limit=${limit}`;
|
|
||||||
function removeDefaultSearchKeys(searchParams) {
|
|
||||||
searchParams.delete('limit');
|
|
||||||
searchParams.delete('include_thumbnails');
|
|
||||||
// searchParams.delete('before');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
|
|
||||||
const apiHost = useApiHost();
|
|
||||||
const [{ events, reachedEnd, searchStrings }, dispatch] = useReducer(reducer, initialState);
|
|
||||||
const { searchParams: initialSearchParams } = new URL(window.location);
|
|
||||||
const [searchString, setSearchString] = useState(`${defaultSearchString(limit)}&${initialSearchParams.toString()}`);
|
|
||||||
const { data, status, deleted } = useEvents(searchString);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data && !(searchString in searchStrings)) {
|
|
||||||
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data && Array.isArray(data) && data.length + deleted < limit) {
|
|
||||||
dispatch({ type: 'REACHED_END', meta: { searchString } });
|
|
||||||
}
|
|
||||||
}, [data, limit, searchString, searchStrings, deleted]);
|
|
||||||
|
|
||||||
const [entry, setIntersectNode] = useIntersectionObserver();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (entry && entry.isIntersecting) {
|
|
||||||
const { startTime } = entry.target.dataset;
|
|
||||||
const { searchParams } = new URL(window.location);
|
|
||||||
searchParams.set('before', parseFloat(startTime) - 0.0001);
|
|
||||||
|
|
||||||
setSearchString(`${defaultSearchString(limit)}&${searchParams.toString()}`);
|
|
||||||
}
|
|
||||||
}, [entry, limit]);
|
|
||||||
|
|
||||||
const lastCellRef = useCallback(
|
|
||||||
(node) => {
|
|
||||||
if (node !== null && !reachedEnd) {
|
|
||||||
setIntersectNode(node);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setIntersectNode, reachedEnd]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFilter = useCallback(
|
|
||||||
(searchParams) => {
|
|
||||||
dispatch({ type: 'RESET' });
|
|
||||||
removeDefaultSearchKeys(searchParams);
|
|
||||||
setSearchString(`${defaultSearchString(limit)}&${searchParams.toString()}`);
|
|
||||||
route(`${pathname}?${searchParams.toString()}`);
|
|
||||||
},
|
|
||||||
[limit, pathname, setSearchString]
|
|
||||||
);
|
|
||||||
|
|
||||||
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 w-full">
|
|
||||||
<Heading>Events</Heading>
|
|
||||||
|
|
||||||
<Filters onChange={handleFilter} searchParams={searchParams} />
|
|
||||||
|
|
||||||
<div className="min-w-0 overflow-auto">
|
|
||||||
<Table className="min-w-full table-fixed">
|
|
||||||
<Thead>
|
|
||||||
<Tr>
|
|
||||||
<Th />
|
|
||||||
<Th>Camera</Th>
|
|
||||||
<Th>Label</Th>
|
|
||||||
<Th>Score</Th>
|
|
||||||
<Th>Zones</Th>
|
|
||||||
<Th>Date</Th>
|
|
||||||
<Th>Start</Th>
|
|
||||||
<Th>End</Th>
|
|
||||||
</Tr>
|
|
||||||
</Thead>
|
|
||||||
<Tbody>
|
|
||||||
{events.map(
|
|
||||||
(
|
|
||||||
{ camera, id, label, start_time: startTime, end_time: endTime, thumbnail, top_score: score, zones },
|
|
||||||
i
|
|
||||||
) => {
|
|
||||||
const start = new Date(parseInt(startTime * 1000, 10));
|
|
||||||
const end = new Date(parseInt(endTime * 1000, 10));
|
|
||||||
const ref = i === events.length - 1 ? lastCellRef : undefined;
|
|
||||||
return (
|
|
||||||
<Tr data-testid={`event-${id}`} key={id}>
|
|
||||||
<Td className="w-40">
|
|
||||||
<a href={`/events/${id}`} ref={ref} data-start-time={startTime} data-reached-end={reachedEnd}>
|
|
||||||
<img
|
|
||||||
width="150"
|
|
||||||
height="150"
|
|
||||||
style="min-height: 48px; min-width: 48px;"
|
|
||||||
src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<Filterable
|
|
||||||
onFilter={handleFilter}
|
|
||||||
pathname={pathname}
|
|
||||||
searchParams={searchParams}
|
|
||||||
paramName="camera"
|
|
||||||
name={camera}
|
|
||||||
/>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<Filterable
|
|
||||||
onFilter={handleFilter}
|
|
||||||
pathname={pathname}
|
|
||||||
searchParams={searchParams}
|
|
||||||
paramName="label"
|
|
||||||
name={label}
|
|
||||||
/>
|
|
||||||
</Td>
|
|
||||||
<Td>{(score * 100).toFixed(2)}%</Td>
|
|
||||||
<Td>
|
|
||||||
<ul>
|
|
||||||
{zones.map((zone) => (
|
|
||||||
<li>
|
|
||||||
<Filterable
|
|
||||||
onFilter={handleFilter}
|
|
||||||
pathname={pathname}
|
|
||||||
searchParams={searchString}
|
|
||||||
paramName="zone"
|
|
||||||
name={zone}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Td>
|
|
||||||
<Td>{start.toLocaleDateString()}</Td>
|
|
||||||
<Td>{start.toLocaleTimeString()}</Td>
|
|
||||||
<Td>{end.toLocaleTimeString()}</Td>
|
|
||||||
</Tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</Tbody>
|
|
||||||
<Tfoot>
|
|
||||||
<Tr>
|
|
||||||
<Td className="text-center p-4" colspan="8">
|
|
||||||
{status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null}
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
</Tfoot>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
|
|
||||||
const href = useMemo(() => {
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
|
||||||
params.set(paramName, name);
|
|
||||||
removeDefaultSearchKeys(params);
|
|
||||||
return `${pathname}?${params.toString()}`;
|
|
||||||
}, [searchParams, paramName, pathname, name]);
|
|
||||||
|
|
||||||
const handleClick = useCallback(
|
|
||||||
(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
route(href, true);
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
|
||||||
params.set(paramName, name);
|
|
||||||
onFilter(params);
|
|
||||||
},
|
|
||||||
[href, searchParams, onFilter, paramName, name]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={href} onclick={handleClick}>
|
|
||||||
{name}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Filters({ onChange, searchParams }) {
|
|
||||||
const { data } = useConfig();
|
|
||||||
|
|
||||||
const cameras = useMemo(() => Object.keys(data.cameras), [data]);
|
|
||||||
|
|
||||||
const zones = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.values(data.cameras)
|
|
||||||
.reduce((memo, camera) => {
|
|
||||||
memo = memo.concat(Object.keys(camera.zones));
|
|
||||||
return memo;
|
|
||||||
}, [])
|
|
||||||
.filter((value, i, self) => self.indexOf(value) === i),
|
|
||||||
[data]
|
|
||||||
);
|
|
||||||
|
|
||||||
const labels = useMemo(() => {
|
|
||||||
return Object.values(data.cameras)
|
|
||||||
.reduce((memo, camera) => {
|
|
||||||
memo = memo.concat(camera.objects?.track || []);
|
|
||||||
return memo;
|
|
||||||
}, data.objects?.track || [])
|
|
||||||
.filter((value, i, self) => self.indexOf(value) === i);
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<div className="flex space-x-4">
|
|
||||||
<Filter
|
|
||||||
type="dropdown"
|
|
||||||
onChange={onChange}
|
|
||||||
options={['all', ...cameras]}
|
|
||||||
paramName={['camera']}
|
|
||||||
label="Camera"
|
|
||||||
searchParams={searchParams}
|
|
||||||
/>
|
|
||||||
<Filter
|
|
||||||
type="dropdown"
|
|
||||||
onChange={onChange}
|
|
||||||
options={['all', ...zones]}
|
|
||||||
paramName={['zone']}
|
|
||||||
label="Zone"
|
|
||||||
searchParams={searchParams}
|
|
||||||
/>
|
|
||||||
<Filter
|
|
||||||
type="dropdown"
|
|
||||||
onChange={onChange}
|
|
||||||
options={['all', ...labels]}
|
|
||||||
paramName={['label']}
|
|
||||||
label="Label"
|
|
||||||
searchParams={searchParams}
|
|
||||||
/>
|
|
||||||
<Filter
|
|
||||||
type="datepicker"
|
|
||||||
onChange={onChange}
|
|
||||||
options={DateFilterOptions}
|
|
||||||
paramName={['before', 'after']}
|
|
||||||
label="DatePicker"
|
|
||||||
searchParams={searchParams}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Filter({ onChange, searchParams, paramName, options, type, ...rest }) {
|
|
||||||
const handleSelect = useCallback(
|
|
||||||
(key) => {
|
|
||||||
const newParams = new URLSearchParams(searchParams.toString());
|
|
||||||
Object.keys(key).map((entries) => {
|
|
||||||
if (key[entries] !== 'all') {
|
|
||||||
newParams.set(entries, key[entries]);
|
|
||||||
} else {
|
|
||||||
paramName.map((p) => newParams.delete(p));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onChange(newParams);
|
|
||||||
},
|
|
||||||
[searchParams, paramName, onChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const obj = {};
|
|
||||||
paramName.map((p) => Object.assign(obj, { [p]: searchParams.get(p) }), [searchParams]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select onChange={handleSelect} options={options} selected={obj} paramName={paramName} type={type} {...rest} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user