forked from Github/frigate
@@ -1,15 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
const sizes = {
|
||||
sm: 'h-4 w-4 border-2 border-t-2',
|
||||
md: 'h-8 w-8 border-4 border-t-4',
|
||||
lg: 'h-16 w-16 border-8 border-t-8',
|
||||
};
|
||||
|
||||
export default function ActivityIndicator({ size = 'md' }) {
|
||||
return (
|
||||
<div className="w-full flex items-center justify-center" aria-label="Loading…">
|
||||
<div className={`activityindicator ease-in rounded-full border-gray-200 text-blue-500 ${sizes[size]}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Button from './Button';
|
||||
import MenuIcon from '../icons/Menu';
|
||||
import MoreIcon from '../icons/More';
|
||||
import { useDrawer } from '../context';
|
||||
import { useLayoutEffect, useCallback, useState } from 'preact/hooks';
|
||||
|
||||
// We would typically preserve these in component state
|
||||
// But need to avoid too many re-renders
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
export default function AppBar({ title: Title, overflowRef, onOverflowClick }) {
|
||||
const [show, setShow] = useState(true);
|
||||
const [atZero, setAtZero] = useState(window.scrollY === 0);
|
||||
const { setShowDrawer } = useDrawer();
|
||||
|
||||
const scrollListener = useCallback(() => {
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
setShow(scrollY <= 0 || lastScrollY > scrollY);
|
||||
setAtZero(scrollY === 0);
|
||||
lastScrollY = scrollY;
|
||||
});
|
||||
}, [setShow]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.addEventListener('scroll', scrollListener);
|
||||
return () => {
|
||||
document.removeEventListener('scroll', scrollListener);
|
||||
};
|
||||
}, [scrollListener]);
|
||||
|
||||
const handleShowDrawer = useCallback(() => {
|
||||
setShowDrawer(true);
|
||||
}, [setShowDrawer]);
|
||||
|
||||
return (
|
||||
<div
|
||||
id="appbar"
|
||||
className={`w-full border-b border-gray-200 dark:border-gray-700 flex items-center align-middle p-2 fixed left-0 right-0 z-10 bg-white dark:bg-gray-900 transform transition-all duration-200 ${
|
||||
!show ? '-translate-y-full' : 'translate-y-0'
|
||||
} ${!atZero ? 'shadow-sm' : ''}`}
|
||||
data-testid="appbar"
|
||||
>
|
||||
<div className="lg:hidden">
|
||||
<Button color="black" className="rounded-full w-10 h-10" onClick={handleShowDrawer} type="text">
|
||||
<MenuIcon className="w-10 h-10" />
|
||||
</Button>
|
||||
</div>
|
||||
<Title />
|
||||
<div className="flex-grow-1 flex justify-end w-full">
|
||||
{overflowRef && onOverflowClick ? (
|
||||
<div className="w-auto" ref={overflowRef}>
|
||||
<Button
|
||||
aria-label="More options"
|
||||
color="black"
|
||||
className="rounded-full w-9 h-9"
|
||||
onClick={onOverflowClick}
|
||||
type="text"
|
||||
>
|
||||
<MoreIcon className="w-10 h-10" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import CameraImage from './CameraImage';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
|
||||
const MIN_LOAD_TIMEOUT_MS = 200;
|
||||
|
||||
export default function AutoUpdatingCameraImage({ camera, searchParams = '', showFps = true, className }) {
|
||||
const [key, setKey] = useState(Date.now());
|
||||
const [fps, setFps] = useState(0);
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
const loadTime = Date.now() - key;
|
||||
setFps((1000 / Math.max(loadTime, MIN_LOAD_TIMEOUT_MS)).toFixed(1));
|
||||
setTimeout(
|
||||
() => {
|
||||
setKey(Date.now());
|
||||
},
|
||||
loadTime > MIN_LOAD_TIMEOUT_MS ? 1 : MIN_LOAD_TIMEOUT_MS
|
||||
);
|
||||
}, [key, setFps]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<CameraImage camera={camera} onload={handleLoad} searchParams={`cache=${key}&${searchParams}`} />
|
||||
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { h, JSX } from 'preact';
|
||||
|
||||
interface BubbleButtonProps {
|
||||
variant?: 'primary' | 'secondary';
|
||||
children?: JSX.Element;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const BubbleButton = ({
|
||||
variant = 'primary',
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: BubbleButtonProps) => {
|
||||
const BASE_CLASS = 'rounded-full px-4 py-2';
|
||||
const PRIMARY_CLASS = 'text-white bg-blue-500 dark:text-black dark:bg-white';
|
||||
const SECONDARY_CLASS = 'text-black dark:text-white bg-transparent';
|
||||
let computedClass = BASE_CLASS;
|
||||
|
||||
if (disabled) {
|
||||
computedClass += ' text-gray-200 dark:text-gray-200';
|
||||
} else if (variant === 'primary') {
|
||||
computedClass += ` ${PRIMARY_CLASS}`;
|
||||
} else if (variant === 'secondary') {
|
||||
computedClass += ` ${SECONDARY_CLASS}`;
|
||||
}
|
||||
|
||||
const onClickHandler = () => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<button onClick={onClickHandler} className={`${computedClass} ${className}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,116 +0,0 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import Tooltip from './Tooltip';
|
||||
import { useCallback, useRef, useState } from 'preact/hooks';
|
||||
|
||||
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,46 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
|
||||
export default function ButtonsTabbed({
|
||||
viewModes = [''],
|
||||
currentViewMode = '',
|
||||
setViewMode = null,
|
||||
setHeader = null,
|
||||
headers = [''],
|
||||
className = 'text-gray-600 py-0 px-4 block hover:text-gray-500',
|
||||
selectedClassName = `${className} focus:outline-none border-b-2 font-medium border-gray-500`,
|
||||
}) {
|
||||
const [selected, setSelected] = useState(viewModes ? viewModes.indexOf(currentViewMode) : 0);
|
||||
const captitalize = (str) => {
|
||||
return `${str.charAt(0).toUpperCase()}${str.slice(1)}`;
|
||||
};
|
||||
|
||||
const getHeader = useCallback(
|
||||
(i) => {
|
||||
return headers.length === viewModes.length ? headers[i] : captitalize(viewModes[i]);
|
||||
},
|
||||
[headers, viewModes]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(i) => {
|
||||
setSelected(i);
|
||||
setViewMode && setViewMode(viewModes[i]);
|
||||
setHeader && setHeader(getHeader(i));
|
||||
},
|
||||
[setViewMode, setHeader, setSelected, viewModes, getHeader]
|
||||
);
|
||||
|
||||
setHeader && setHeader(getHeader(selected));
|
||||
return (
|
||||
<nav className="flex justify-end">
|
||||
{viewModes.map((item, i) => {
|
||||
return (
|
||||
<button key={i} onClick={() => handleClick(i)} className={i === selected ? selectedClassName : className}>
|
||||
{captitalize(item)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'preact/hooks';
|
||||
import ArrowRight from '../icons/ArrowRight';
|
||||
import ArrowRightDouble from '../icons/ArrowRightDouble';
|
||||
|
||||
const todayTimestamp = new Date().setHours(0, 0, 0, 0).valueOf();
|
||||
|
||||
const Calendar = ({ onChange, calendarRef, close, dateRange, children }) => {
|
||||
const keyRef = useRef([]);
|
||||
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
|
||||
const daysMap = useMemo(() => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], []);
|
||||
const monthMap = useMemo(
|
||||
() => [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const [state, setState] = useState({
|
||||
getMonthDetails: [],
|
||||
year,
|
||||
month,
|
||||
selectedDay: null,
|
||||
timeRange: dateRange,
|
||||
monthDetails: null,
|
||||
});
|
||||
|
||||
const getNumberOfDays = useCallback((year, month) => {
|
||||
return 40 - new Date(year, month, 40).getDate();
|
||||
}, []);
|
||||
|
||||
const getDayDetails = useCallback(
|
||||
(args) => {
|
||||
const date = args.index - args.firstDay;
|
||||
const day = args.index % 7;
|
||||
let prevMonth = args.month - 1;
|
||||
let prevYear = args.year;
|
||||
if (prevMonth < 0) {
|
||||
prevMonth = 11;
|
||||
prevYear--;
|
||||
}
|
||||
const prevMonthNumberOfDays = getNumberOfDays(prevYear, prevMonth);
|
||||
const _date = (date < 0 ? prevMonthNumberOfDays + date : date % args.numberOfDays) + 1;
|
||||
const month = date < 0 ? -1 : date >= args.numberOfDays ? 1 : 0;
|
||||
const timestamp = new Date(args.year, args.month, _date).getTime();
|
||||
return {
|
||||
date: _date,
|
||||
day,
|
||||
month,
|
||||
timestamp,
|
||||
dayString: daysMap[day],
|
||||
};
|
||||
},
|
||||
[getNumberOfDays, daysMap]
|
||||
);
|
||||
|
||||
const getMonthDetails = useCallback(
|
||||
(year, month) => {
|
||||
const firstDay = new Date(year, month).getDay();
|
||||
const numberOfDays = getNumberOfDays(year, month);
|
||||
const monthArray = [];
|
||||
const rows = 6;
|
||||
let currentDay = null;
|
||||
let index = 0;
|
||||
const cols = 7;
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
currentDay = getDayDetails({
|
||||
index,
|
||||
numberOfDays,
|
||||
firstDay,
|
||||
year,
|
||||
month,
|
||||
});
|
||||
monthArray.push(currentDay);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
return monthArray;
|
||||
},
|
||||
[getNumberOfDays, getDayDetails]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedDay: todayTimestamp,
|
||||
monthDetails: getMonthDetails(year, month),
|
||||
}));
|
||||
}, [year, month, getMonthDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
// add refs for keyboard navigation
|
||||
if (state.monthDetails) {
|
||||
keyRef.current = keyRef.current.slice(0, state.monthDetails.length);
|
||||
}
|
||||
// set today date in focus for keyboard navigation
|
||||
const todayDate = new Date(todayTimestamp).getDate();
|
||||
keyRef.current.find((t) => t.tabIndex === todayDate)?.focus();
|
||||
}, [state.monthDetails]);
|
||||
|
||||
const isCurrentDay = (day) => day.timestamp === todayTimestamp;
|
||||
|
||||
const isSelectedRange = useCallback(
|
||||
(day) => {
|
||||
if (!state.timeRange.after || !state.timeRange.before) return;
|
||||
|
||||
return day.timestamp < state.timeRange.before && day.timestamp >= new Date(state.timeRange.after).setHours(0);
|
||||
},
|
||||
[state.timeRange]
|
||||
);
|
||||
|
||||
const isFirstDayInRange = useCallback(
|
||||
(day) => {
|
||||
if (isCurrentDay(day)) return;
|
||||
return new Date(state.timeRange.after).setHours(0) === day.timestamp;
|
||||
},
|
||||
[state.timeRange.after]
|
||||
);
|
||||
|
||||
const isLastDayInRange = useCallback(
|
||||
(day) => {
|
||||
// if the hour is not above 0, we will use 24 hour.
|
||||
const beforeHour = new Date(state.timeRange.before).getHours() || 24;
|
||||
|
||||
/**
|
||||
* When user selects a day in the calendar, the before will be 00:00.
|
||||
* When user selects a time in timepicker, the day.timestamp hour must be changed to match the selected end () hour.
|
||||
*/
|
||||
return state.timeRange.before === new Date(day.timestamp).setHours(beforeHour);
|
||||
},
|
||||
[state.timeRange.before]
|
||||
);
|
||||
|
||||
const getMonthStr = useCallback(
|
||||
(month) => {
|
||||
return monthMap[Math.max(Math.min(11, month), 0)] || 'Month';
|
||||
},
|
||||
[monthMap]
|
||||
);
|
||||
|
||||
const onDateClick = (day) => {
|
||||
const { before, after } = state.timeRange;
|
||||
let timeRange = { before: null, after: null };
|
||||
|
||||
// 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) => ({
|
||||
...prev,
|
||||
timeRange,
|
||||
selectedDay: day.timestamp,
|
||||
}));
|
||||
|
||||
if (onChange) {
|
||||
onChange(timeRange.after ? { before: timeRange.before / 1000, after: timeRange.after / 1000 } : ['all']);
|
||||
}
|
||||
};
|
||||
|
||||
const setYear = useCallback(
|
||||
(offset) => {
|
||||
const year = state.year + offset;
|
||||
const month = state.month;
|
||||
setState((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
year,
|
||||
monthDetails: getMonthDetails(year, month),
|
||||
};
|
||||
});
|
||||
},
|
||||
[state.year, state.month, getMonthDetails]
|
||||
);
|
||||
|
||||
const setMonth = (offset) => {
|
||||
let year = state.year;
|
||||
let month = state.month + offset;
|
||||
if (month === -1) {
|
||||
month = 11;
|
||||
year--;
|
||||
} else if (month === 12) {
|
||||
month = 0;
|
||||
year++;
|
||||
}
|
||||
setState((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
year,
|
||||
month,
|
||||
monthDetails: getMonthDetails(year, month),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeydown = (e, day, index) => {
|
||||
if ((keyRef.current && e.key === 'Enter') || e.keyCode === 32) {
|
||||
e.preventDefault();
|
||||
day.month === 0 && onDateClick(day);
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
index > 0 && keyRef.current[index - 1].focus();
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
index < 41 && keyRef.current[index + 1].focus();
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
index > 6 && keyRef.current[index - 7].focus();
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
index < 36 && keyRef.current[index + 7].focus();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const renderCalendar = () => {
|
||||
const days =
|
||||
state.monthDetails &&
|
||||
state.monthDetails.map((day, idx) => {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onDateClick(day)}
|
||||
onkeydown={(e) => handleKeydown(e, day, idx)}
|
||||
ref={(ref) => (keyRef.current[idx] = ref)}
|
||||
tabIndex={day.month === 0 ? day.date : null}
|
||||
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer hover:border hover:rounded-md border-gray-600 ${
|
||||
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
|
||||
}
|
||||
${isFirstDayInRange(day) ? ' rounded-l-xl hover:rounded-l-xl' : ''}
|
||||
${isSelectedRange(day) ? ' bg-blue-600 hover:rounded-none' : ''}
|
||||
${isLastDayInRange(day) ? ' rounded-r-xl hover:rounded-r-xl' : ''}
|
||||
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
|
||||
key={idx}
|
||||
>
|
||||
<div className="font-light">
|
||||
<span className="text-gray-400">{day.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full flex justify-start flex-shrink">
|
||||
{['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'].map((d, i) => (
|
||||
<div key={i} className="w-12 text-xs font-light text-center">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full h-56">{days}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="select-none w-11/12 flex" ref={calendarRef}>
|
||||
<div className="px-6">
|
||||
<div className="flex items-center">
|
||||
<div className="w-1/6 relative flex justify-around">
|
||||
<div
|
||||
tabIndex={100}
|
||||
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
|
||||
onClick={() => setYear(-1)}
|
||||
>
|
||||
<ArrowRightDouble className="h-2/6 transform rotate-180 " />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/6 relative flex justify-around ">
|
||||
<div
|
||||
tabIndex={101}
|
||||
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
|
||||
onClick={() => setMonth(-1)}
|
||||
>
|
||||
<ArrowRight className="h-2/6 transform rotate-180 red" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/3">
|
||||
<div className="text-3xl text-center text-gray-200 font-extralight">{state.year}</div>
|
||||
<div className="text-center text-gray-400 font-extralight">{getMonthStr(state.month)}</div>
|
||||
</div>
|
||||
<div className="w-1/6 relative flex justify-around ">
|
||||
<div
|
||||
tabIndex={102}
|
||||
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
|
||||
onClick={() => setMonth(1)}
|
||||
>
|
||||
<ArrowRight className="h-2/6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/6 relative flex justify-around" tabIndex={104} onClick={() => setYear(1)}>
|
||||
<div className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800">
|
||||
<ArrowRightDouble className="h-2/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">{renderCalendar()}</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calendar;
|
||||
@@ -1,257 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useCallback, useState } from 'preact/hooks';
|
||||
import useSWR from 'swr';
|
||||
import { usePtzCommand } from '../api/ws';
|
||||
import ActivityIndicator from './ActivityIndicator';
|
||||
import ArrowRightDouble from '../icons/ArrowRightDouble';
|
||||
import ArrowUpDouble from '../icons/ArrowUpDouble';
|
||||
import ArrowDownDouble from '../icons/ArrowDownDouble';
|
||||
import ArrowLeftDouble from '../icons/ArrowLeftDouble';
|
||||
import Button from './Button';
|
||||
import Heading from './Heading';
|
||||
|
||||
export default function CameraControlPanel({ camera = '' }) {
|
||||
const { data: ptz } = useSWR(`${camera}/ptz/info`);
|
||||
const [currentPreset, setCurrentPreset] = useState('');
|
||||
|
||||
const { payload: _, send: sendPtz } = usePtzCommand(camera);
|
||||
|
||||
const onSetPreview = async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (currentPreset == 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
sendPtz(`preset_${currentPreset}`);
|
||||
setCurrentPreset('');
|
||||
};
|
||||
|
||||
const onSetMove = useCallback(async (e, dir) => {
|
||||
e.stopPropagation();
|
||||
sendPtz(`MOVE_${dir}`);
|
||||
setCurrentPreset('');
|
||||
}, [sendPtz, setCurrentPreset]);
|
||||
|
||||
const onSetZoom = useCallback(async (e, dir) => {
|
||||
e.stopPropagation();
|
||||
sendPtz(`ZOOM_${dir}`);
|
||||
setCurrentPreset('');
|
||||
}, [sendPtz, setCurrentPreset]);
|
||||
|
||||
const onSetStop = useCallback(async (e) => {
|
||||
e.stopPropagation();
|
||||
sendPtz('STOP');
|
||||
}, [sendPtz]);
|
||||
|
||||
const keydownListener = useCallback((e) => {
|
||||
if (!ptz || !e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.repeat) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ptz.features.includes('pt')) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
onSetMove(e, 'LEFT');
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
onSetMove(e, 'RIGHT');
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
onSetMove(e, 'UP');
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
onSetMove(e, 'DOWN');
|
||||
}
|
||||
|
||||
if (ptz.features.includes('zoom')) {
|
||||
if (e.key == '+') {
|
||||
e.preventDefault();
|
||||
onSetZoom(e, 'IN');
|
||||
} else if (e.key == '-') {
|
||||
e.preventDefault();
|
||||
onSetZoom(e, 'OUT');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [onSetMove, onSetZoom, ptz]);
|
||||
|
||||
const keyupListener = useCallback((e) => {
|
||||
if (!e || e.repeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
e.key === 'ArrowLeft' ||
|
||||
e.key === 'ArrowRight' ||
|
||||
e.key === 'ArrowUp' ||
|
||||
e.key === 'ArrowDown' ||
|
||||
e.key === '+' ||
|
||||
e.key === '-'
|
||||
) {
|
||||
e.preventDefault();
|
||||
onSetStop(e);
|
||||
}
|
||||
}, [onSetStop]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', keydownListener);
|
||||
document.addEventListener('keyup', keyupListener);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', keydownListener);
|
||||
document.removeEventListener('keyup', keyupListener);
|
||||
};
|
||||
}, [keydownListener, keyupListener, ptz]);
|
||||
|
||||
if (!ptz) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="control-panel" className="p-4 text-center sm:flex justify-start">
|
||||
{ptz.features.includes('pt') && (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-44 px-4">
|
||||
<Heading size="xs" className="my-4">
|
||||
Pan / Tilt
|
||||
</Heading>
|
||||
<div className="w-full flex justify-center">
|
||||
<button
|
||||
onMouseDown={(e) => onSetMove(e, 'UP')}
|
||||
onMouseUp={(e) => onSetStop(e)}
|
||||
onTouchStart={(e) => {
|
||||
onSetMove(e, 'UP');
|
||||
e.preventDefault();
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
onSetStop(e);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ArrowUpDouble className="h-12 p-2 bg-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full flex justify-between">
|
||||
<button
|
||||
onMouseDown={(e) => onSetMove(e, 'LEFT')}
|
||||
onMouseUp={(e) => onSetStop(e)}
|
||||
onTouchStart={(e) => {
|
||||
onSetMove(e, 'LEFT');
|
||||
e.preventDefault();
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
onSetStop(e);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ArrowLeftDouble className="btn h-12 p-2 bg-slate-500" />
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(e) => onSetMove(e, 'RIGHT')}
|
||||
onMouseUp={(e) => onSetStop(e)}
|
||||
onTouchStart={(e) => {
|
||||
onSetMove(e, 'RIGHT');
|
||||
e.preventDefault();
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
onSetStop(e);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ArrowRightDouble className="h-12 p-2 bg-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onMouseDown={(e) => onSetMove(e, 'DOWN')}
|
||||
onMouseUp={(e) => onSetStop(e)}
|
||||
onTouchStart={(e) => {
|
||||
onSetMove(e, 'DOWN');
|
||||
e.preventDefault();
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
onSetStop(e);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ArrowDownDouble className="h-12 p-2 bg-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ptz.features.includes('zoom') && (
|
||||
<div className="px-4 sm:w-44">
|
||||
<Heading size="xs" className="my-4">
|
||||
Zoom
|
||||
</Heading>
|
||||
<div className="w-full flex justify-center">
|
||||
<button
|
||||
onMouseDown={(e) => onSetZoom(e, 'IN')}
|
||||
onMouseUp={(e) => onSetStop(e)}
|
||||
onTouchStart={(e) => {
|
||||
onSetZoom(e, 'IN');
|
||||
e.preventDefault();
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
onSetStop(e);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="h-12 w-12 p-2 text-2xl bg-slate-500 select-none">+</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-12" />
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onMouseDown={(e) => onSetZoom(e, 'OUT')}
|
||||
onMouseUp={(e) => onSetStop(e)}
|
||||
onTouchStart={(e) => {
|
||||
onSetZoom(e, 'OUT');
|
||||
e.preventDefault();
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
onSetStop(e);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="h-12 w-12 p-2 text-2xl bg-slate-500 select-none">-</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(ptz.presets || []).length > 0 && (
|
||||
<div className="px-4">
|
||||
<Heading size="xs" className="my-4">
|
||||
Presets
|
||||
</Heading>
|
||||
<div className="py-4">
|
||||
<select
|
||||
className="cursor-pointer rounded dark:bg-slate-800"
|
||||
value={currentPreset}
|
||||
onChange={(e) => {
|
||||
setCurrentPreset(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Select Preset</option>
|
||||
{ptz.presets.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item.charAt(0).toUpperCase() + item.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button onClick={(e) => onSetPreview(e)}>Move Camera To Preset</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import ActivityIndicator from './ActivityIndicator';
|
||||
import { useApiHost } from '../api';
|
||||
import useSWR from 'swr';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { useResizeObserver } from '../hooks';
|
||||
|
||||
export default function CameraImage({ camera, onload, searchParams = '', stretch = false }) {
|
||||
const { data: config } = useSWR('config');
|
||||
const apiHost = useApiHost();
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
const canvasRef = useRef(null);
|
||||
const [{ width: containerWidth }] = useResizeObserver(containerRef);
|
||||
|
||||
// Add scrollbar width (when visible) to the available observer width to eliminate screen juddering.
|
||||
// https://github.com/blakeblackshear/frigate/issues/1657
|
||||
let scrollBarWidth = 0;
|
||||
if (window.innerWidth && document.body.offsetWidth) {
|
||||
scrollBarWidth = window.innerWidth - document.body.offsetWidth;
|
||||
}
|
||||
const availableWidth = scrollBarWidth ? containerWidth + scrollBarWidth : containerWidth;
|
||||
|
||||
const { name } = config ? config.cameras[camera] : '';
|
||||
const enabled = config ? config.cameras[camera].enabled : 'True';
|
||||
const { width, height } = config ? config.cameras[camera].detect : { width: 1, height: 1 };
|
||||
const aspectRatio = width / height;
|
||||
|
||||
const scaledHeight = useMemo(() => {
|
||||
const scaledHeight = Math.floor(availableWidth / aspectRatio);
|
||||
const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height);
|
||||
|
||||
if (finalHeight > 0) {
|
||||
return finalHeight;
|
||||
}
|
||||
|
||||
return 100;
|
||||
}, [availableWidth, aspectRatio, height, stretch]);
|
||||
const scaledWidth = useMemo(
|
||||
() => Math.ceil(scaledHeight * aspectRatio - scrollBarWidth),
|
||||
[scaledHeight, aspectRatio, scrollBarWidth]
|
||||
);
|
||||
|
||||
const img = useMemo(() => new Image(), []);
|
||||
img.onload = useCallback(
|
||||
(event) => {
|
||||
setHasLoaded(true);
|
||||
if (canvasRef.current) {
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, scaledWidth, scaledHeight);
|
||||
}
|
||||
onload && onload(event);
|
||||
},
|
||||
[img, scaledHeight, scaledWidth, setHasLoaded, onload, canvasRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config || scaledHeight === 0 || !canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
img.src = `${apiHost}api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`;
|
||||
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={containerRef}>
|
||||
{enabled ? (
|
||||
<canvas data-testid="cameraimage-canvas" height={scaledHeight} ref={canvasRef} width={scaledWidth} />
|
||||
) : (
|
||||
<div class="text-center pt-6">Camera is disabled in config, no stream or snapshot available!</div>
|
||||
)}
|
||||
{!hasLoaded && enabled ? (
|
||||
<div className="absolute inset-0 flex justify-center" style={`height: ${scaledHeight}px`}>
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Button from './Button';
|
||||
import Heading from './Heading';
|
||||
|
||||
export default function Box({
|
||||
buttons = [],
|
||||
className = '',
|
||||
content,
|
||||
elevated = true,
|
||||
header,
|
||||
href,
|
||||
icons = [],
|
||||
media = null,
|
||||
...props
|
||||
}) {
|
||||
const Element = href ? 'a' : 'div';
|
||||
|
||||
const typeClasses = elevated
|
||||
? 'shadow-md hover:shadow-lg transition-shadow'
|
||||
: 'border border-gray-200 dark:border-gray-700';
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg overflow-hidden ${typeClasses} ${className}`}>
|
||||
{media || header ? (
|
||||
<Element href={href} {...props}>
|
||||
{media}
|
||||
<div className="p-4 pb-2">{header ? <Heading size="base">{header}</Heading> : null}</div>
|
||||
</Element>
|
||||
) : null}
|
||||
{buttons.length || content || icons.length ? (
|
||||
<div className="px-4 pb-2">
|
||||
{content || null}
|
||||
{buttons.length ? (
|
||||
<div className="flex space-x-4 -ml-2">
|
||||
{buttons.map(({ name, href }) => (
|
||||
<Button key={name} href={href} type="text">
|
||||
{name}
|
||||
</Button>
|
||||
))}
|
||||
<div class="flex-grow" />
|
||||
{icons.map(({ name, icon: Icon, ...props }) => (
|
||||
<Button aria-label={name} ariaCapitalize={true} className="rounded-full" key={name} type="text" {...props}>
|
||||
<Icon className="w-6" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export const DateFilterOptions = [
|
||||
{
|
||||
label: 'All',
|
||||
value: ['all'],
|
||||
},
|
||||
{
|
||||
label: 'Today',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||
//After
|
||||
after: new Date().setHours(0, 0, 0, 0) / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Yesterday',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(24, 0, 0, 0) / 1000,
|
||||
//After
|
||||
after: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(0, 0, 0, 0) / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last 7 Days',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||
//After
|
||||
after: new Date(new Date().setDate(new Date().getDate() - 7)).setHours(0, 0, 0, 0) / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'This Month',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||
//After
|
||||
after: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last Month',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
|
||||
//After
|
||||
after: new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1).getTime() / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Custom Range',
|
||||
value: 'custom_range',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DatePicker({
|
||||
helpText,
|
||||
keyboardType = 'text',
|
||||
inputRef,
|
||||
label,
|
||||
leadingIcon: LeadingIcon,
|
||||
onBlur,
|
||||
onChangeText,
|
||||
onFocus,
|
||||
trailingIcon: TrailingIcon,
|
||||
value: propValue = '',
|
||||
...props
|
||||
}) {
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
const [value, setValue] = useState(propValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (propValue !== value) {
|
||||
setValue(propValue);
|
||||
}
|
||||
}, [propValue, setValue, value]);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(event) => {
|
||||
setFocused(true);
|
||||
onFocus && onFocus(event);
|
||||
},
|
||||
[onFocus]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event) => {
|
||||
setFocused(false);
|
||||
onBlur && onBlur(event);
|
||||
},
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event) => {
|
||||
const { value } = event.target;
|
||||
setValue(value);
|
||||
onChangeText && onChangeText(value);
|
||||
},
|
||||
[onChangeText, setValue]
|
||||
);
|
||||
|
||||
const onClick = (e) => {
|
||||
props.onclick(e);
|
||||
};
|
||||
const labelMoved = isFocused || value !== '';
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{props.children}
|
||||
<div
|
||||
className={`bg-gray-100 dark:bg-gray-700 rounded rounded-b-none border-gray-400 border-b p-1 pl-4 pr-3 ${
|
||||
isFocused ? 'border-blue-500 dark:border-blue-500' : ''
|
||||
}`}
|
||||
ref={inputRef}
|
||||
>
|
||||
<label
|
||||
className="flex space-x-2 items-center"
|
||||
data-testid={`label-${label.toLowerCase().replace(/[^\w]+/g, '_')}`}
|
||||
>
|
||||
{LeadingIcon ? (
|
||||
<div className="w-10 h-full">
|
||||
<LeadingIcon />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
className="h-6 mt-6 w-full bg-transparent focus:outline-none focus:ring-0"
|
||||
type={keyboardType}
|
||||
readOnly
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onInput={handleChange}
|
||||
tabIndex="0"
|
||||
onClick={onClick}
|
||||
value={propValue}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-3 transition transform text-gray-600 dark:text-gray-400 ${
|
||||
labelMoved ? 'text-xs -translate-y-2' : ''
|
||||
} ${isFocused ? 'text-blue-500 dark:text-blue-500' : ''}`}
|
||||
>
|
||||
<p>{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
{TrailingIcon ? (
|
||||
<div className="w-10 h-10">
|
||||
<TrailingIcon />
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
{helpText ? <div className="text-xs pl-3 pt-1">{helpText}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Link from './Link';
|
||||
import Switch from './Switch';
|
||||
import { useCallback, useMemo } from 'preact/hooks';
|
||||
import { usePersistence } from '../context';
|
||||
import AutoUpdatingCameraImage from './AutoUpdatingCameraImage';
|
||||
|
||||
const emptyObject = Object.freeze({});
|
||||
|
||||
export function DebugCamera({ camera }) {
|
||||
const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
|
||||
|
||||
const handleSetOption = useCallback(
|
||||
(id, value) => {
|
||||
const newOptions = { ...options, [id]: value };
|
||||
setOptions(newOptions);
|
||||
},
|
||||
[options, setOptions]
|
||||
);
|
||||
|
||||
const searchParams = useMemo(
|
||||
() =>
|
||||
new URLSearchParams(
|
||||
Object.keys(options).reduce((memo, key) => {
|
||||
memo.push([key, options[key] === true ? '1' : '0']);
|
||||
return memo;
|
||||
}, [])
|
||||
),
|
||||
[options]
|
||||
);
|
||||
|
||||
const optionContent = (
|
||||
<div className='grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4'>
|
||||
<Switch
|
||||
checked={options['bbox']}
|
||||
id='bbox'
|
||||
onChange={handleSetOption}
|
||||
label='Bounding box'
|
||||
labelPosition='after'
|
||||
/>
|
||||
<Switch
|
||||
checked={options['timestamp']}
|
||||
id='timestamp'
|
||||
onChange={handleSetOption}
|
||||
label='Timestamp'
|
||||
labelPosition='after'
|
||||
/>
|
||||
<Switch checked={options['zones']} id='zones' onChange={handleSetOption} label='Zones' labelPosition='after' />
|
||||
<Switch checked={options['mask']} id='mask' onChange={handleSetOption} label='Masks' labelPosition='after' />
|
||||
<Switch
|
||||
checked={options['motion']}
|
||||
id='motion'
|
||||
onChange={handleSetOption}
|
||||
label='Motion boxes'
|
||||
labelPosition='after'
|
||||
/>
|
||||
<Switch
|
||||
checked={options['regions']}
|
||||
id='regions'
|
||||
onChange={handleSetOption}
|
||||
label='Regions'
|
||||
labelPosition='after'
|
||||
/>
|
||||
<Link href={`/cameras/${camera}/editor`}>Mask & Zone creator</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AutoUpdatingCameraImage camera={camera} searchParams={searchParams} />
|
||||
{optionContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
|
||||
export default function LargeDialog({ 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 w-full max-h-fit sm:max-w-md md:max-w-lg lg:max-w-xl xl:max-w-2xl dark:bg-gray-700 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;
|
||||
}
|
||||
305
web/src/components/Header.tsx
Normal file
305
web/src/components/Header.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import Logo from "@/components/Logo";
|
||||
import {
|
||||
LuActivity,
|
||||
LuGithub,
|
||||
LuHardDrive,
|
||||
LuLifeBuoy,
|
||||
LuMenu,
|
||||
LuMoon,
|
||||
LuMoreVertical,
|
||||
LuPenSquare,
|
||||
LuRotateCw,
|
||||
LuSettings,
|
||||
LuSun,
|
||||
LuSunMoon,
|
||||
} from "react-icons/lu";
|
||||
import { IoColorPalette } from "react-icons/io5";
|
||||
import { CgDarkMode } from "react-icons/cg";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Heading from "./ui/heading";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
colorSchemes,
|
||||
friendlyColorSchemeName,
|
||||
useTheme,
|
||||
} from "@/context/theme-provider";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "./ui/sheet";
|
||||
import ActivityIndicator from "./ui/activity-indicator";
|
||||
import { useRestart } from "@/api/ws";
|
||||
|
||||
type HeaderProps = {
|
||||
onToggleNavbar: () => void;
|
||||
};
|
||||
|
||||
function Header({ onToggleNavbar }: HeaderProps) {
|
||||
const { theme, colorScheme, setTheme, setColorScheme } = useTheme();
|
||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
|
||||
const [countdown, setCountdown] = useState(60);
|
||||
|
||||
const { send: sendRestart } = useRestart();
|
||||
|
||||
useEffect(() => {
|
||||
let countdownInterval: NodeJS.Timeout;
|
||||
|
||||
if (restartingSheetOpen) {
|
||||
countdownInterval = setInterval(() => {
|
||||
setCountdown((prevCountdown) => prevCountdown - 1);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(countdownInterval);
|
||||
};
|
||||
}, [restartingSheetOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown === 0) {
|
||||
window.location.href = "/";
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
const handleForceReload = () => {
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-10 lg:gap-20 justify-between pt-2 mb-2 border-b-[1px] px-4 items-center">
|
||||
<div className="flex gap-4 items-center flex-shrink-0 m-5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
onClick={onToggleNavbar}
|
||||
>
|
||||
<LuMenu />
|
||||
</Button>
|
||||
<Link to="/">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="w-10 mr-5">
|
||||
<Logo />
|
||||
</div>
|
||||
<Heading as="h1">Frigate</Heading>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 md:gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<LuMoreVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-72 mr-5">
|
||||
<DropdownMenuLabel>System</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<Link to="/storage">
|
||||
<DropdownMenuItem>
|
||||
<LuHardDrive className="mr-2 h-4 w-4" />
|
||||
<span>Storage</span>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link to="/system">
|
||||
<DropdownMenuItem>
|
||||
<LuActivity className="mr-2 h-4 w-4" />
|
||||
<span>System metrics</span>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="mt-3">
|
||||
Configuration
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<Link to="/settings">
|
||||
<DropdownMenuItem>
|
||||
<LuSettings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link to="/config">
|
||||
<DropdownMenuItem>
|
||||
<LuPenSquare className="mr-2 h-4 w-4" />
|
||||
<span>Configuration editor</span>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuLabel className="mt-3">Appearance</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<LuSunMoon className="mr-2 h-4 w-4" />
|
||||
<span>Dark Mode</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
{theme === "light" ? (
|
||||
<>
|
||||
<LuSun className="mr-2 h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
Light
|
||||
</>
|
||||
) : (
|
||||
<span className="mr-2 ml-6">Light</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
{theme === "dark" ? (
|
||||
<>
|
||||
<LuMoon className="mr-2 h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
Dark
|
||||
</>
|
||||
) : (
|
||||
<span className="mr-2 ml-6">Dark</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
{theme === "system" ? (
|
||||
<>
|
||||
<CgDarkMode className="mr-2 h-4 w-4 scale-100 transition-all" />
|
||||
System
|
||||
</>
|
||||
) : (
|
||||
<span className="mr-2 ml-6">System</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<LuSunMoon className="mr-2 h-4 w-4" />
|
||||
<span>Theme</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{colorSchemes.map((scheme) => (
|
||||
<DropdownMenuItem
|
||||
key={scheme}
|
||||
onClick={() => setColorScheme(scheme)}
|
||||
>
|
||||
{scheme === colorScheme ? (
|
||||
<>
|
||||
<IoColorPalette className="mr-2 h-4 w-4 rotate-0 scale-100 transition-all" />
|
||||
{friendlyColorSchemeName(scheme)}
|
||||
</>
|
||||
) : (
|
||||
<span className="mr-2 ml-6">
|
||||
{friendlyColorSchemeName(scheme)}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="mt-3">Help</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<a href="https://docs.frigate.video">
|
||||
<DropdownMenuItem>
|
||||
<LuLifeBuoy className="mr-2 h-4 w-4" />
|
||||
<span>Documentation</span>
|
||||
</DropdownMenuItem>
|
||||
</a>
|
||||
<a href="https://github.com/blakeblackshear/frigate">
|
||||
<DropdownMenuItem>
|
||||
<LuGithub className="mr-2 h-4 w-4" />
|
||||
<span>GitHub</span>
|
||||
</DropdownMenuItem>
|
||||
</a>
|
||||
<DropdownMenuSeparator className="mt-3" />
|
||||
<DropdownMenuItem onClick={() => setRestartDialogOpen(true)}>
|
||||
<LuRotateCw className="mr-2 h-4 w-4" />
|
||||
<span>Restart Frigate</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{restartDialogOpen && (
|
||||
<AlertDialog
|
||||
open={restartDialogOpen}
|
||||
onOpenChange={() => setRestartDialogOpen(false)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure you want to restart Frigate?
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setRestartingSheetOpen(true);
|
||||
sendRestart("restart");
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
{restartingSheetOpen && (
|
||||
<>
|
||||
<Sheet
|
||||
open={restartingSheetOpen}
|
||||
onOpenChange={() => setRestartingSheetOpen(false)}
|
||||
>
|
||||
<SheetContent
|
||||
side="top"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<ActivityIndicator />
|
||||
<SheetHeader className="mt-5 text-center">
|
||||
<SheetTitle className="text-center">
|
||||
Frigate is Restarting
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-center">
|
||||
<p>This page will reload in {countdown} seconds.</p>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Button size="lg" className="mt-5" onClick={handleForceReload}>
|
||||
Force Reload Now
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
@@ -1,5 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
export default function Heading({ children, className = '', size = '2xl' }) {
|
||||
return <h1 className={`font-semibold tracking-widest uppercase text-${size} ${className}`}>{children}</h1>;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Heading from '../Heading';
|
||||
import type { TimelineEvent } from '../Timeline/TimelineEvent';
|
||||
|
||||
interface HistoryHeaderProps {
|
||||
event?: TimelineEvent;
|
||||
className?: string;
|
||||
}
|
||||
export const HistoryHeader = ({ event, className = '' }: HistoryHeaderProps) => {
|
||||
let title = 'No Event Found';
|
||||
let subtitle = <span>Event was not found at marker position.</span>;
|
||||
if (event) {
|
||||
const { startTime, endTime, label } = event;
|
||||
const thisMorning = new Date();
|
||||
thisMorning.setHours(0, 0, 0);
|
||||
const isToday = endTime.getTime() > thisMorning.getTime();
|
||||
title = label;
|
||||
subtitle = (
|
||||
<span>
|
||||
{isToday ? 'Today' : 'Yesterday'}, {startTime.toLocaleTimeString()} - {endTime.toLocaleTimeString()} ·
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`text-center ${className}`}>
|
||||
<Heading size='lg'>{title}</Heading>
|
||||
<div>{subtitle}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useApiHost } from '../../api';
|
||||
import { isNullOrUndefined } from '../../utils/objectUtils';
|
||||
|
||||
import 'video.js/dist/video-js.css';
|
||||
|
||||
import videojs from 'video.js';
|
||||
import type Player from 'video.js/dist/types/player';
|
||||
|
||||
interface OnTimeUpdateEvent {
|
||||
timestamp: number;
|
||||
isPlaying: boolean;
|
||||
}
|
||||
|
||||
interface HistoryVideoProps {
|
||||
id?: string;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
onTimeUpdate?: (event: OnTimeUpdateEvent) => void;
|
||||
onPause: () => void;
|
||||
onPlay: () => void;
|
||||
}
|
||||
|
||||
export const HistoryVideo = ({
|
||||
id,
|
||||
isPlaying: videoIsPlaying,
|
||||
currentTime,
|
||||
onTimeUpdate,
|
||||
onPause,
|
||||
onPlay,
|
||||
}: HistoryVideoProps) => {
|
||||
const apiHost = useApiHost();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const [video, setVideo] = useState<Player>();
|
||||
|
||||
useEffect(() => {
|
||||
let video: Player
|
||||
if (videoRef.current) {
|
||||
video = videojs(videoRef.current, {})
|
||||
setVideo(video)
|
||||
}
|
||||
() => video?.dispose()
|
||||
}, [videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!video) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (!id) {
|
||||
video.pause()
|
||||
return
|
||||
}
|
||||
|
||||
video.src({
|
||||
src: `${apiHost}vod/event/${id}/master.m3u8`,
|
||||
type: 'application/vnd.apple.mpegurl',
|
||||
});
|
||||
video.poster(`${apiHost}api/events/${id}/snapshot.jpg`);
|
||||
if (videoIsPlaying) {
|
||||
video.play();
|
||||
}
|
||||
}, [video, id, apiHost, videoIsPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
const videoExists = !isNullOrUndefined(video);
|
||||
const hasSeeked = currentTime >= 0;
|
||||
if (video && videoExists && hasSeeked) {
|
||||
video.currentTime = currentTime;
|
||||
}
|
||||
}, [currentTime, videoRef]);
|
||||
|
||||
const onTimeUpdateHandler = useCallback(
|
||||
(event: Event) => {
|
||||
const target = event.target as HTMLMediaElement;
|
||||
const timeUpdateEvent = {
|
||||
isPlaying: videoIsPlaying,
|
||||
timestamp: target.currentTime,
|
||||
};
|
||||
onTimeUpdate && onTimeUpdate(timeUpdateEvent);
|
||||
},
|
||||
[videoIsPlaying, onTimeUpdate]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (video && (video as any).readyState() >= 1) {
|
||||
if (videoIsPlaying) {
|
||||
video.play()
|
||||
} else {
|
||||
video.pause()
|
||||
}
|
||||
}
|
||||
}, [video, videoIsPlaying])
|
||||
|
||||
const onLoad = useCallback(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (video && (video as any).readyState() >= 1 && videoIsPlaying) {
|
||||
video.play()
|
||||
}
|
||||
}, [video, videoIsPlaying])
|
||||
|
||||
return (
|
||||
<div data-vjs-player>
|
||||
<video
|
||||
ref={videoRef}
|
||||
onTimeUpdate={onTimeUpdateHandler}
|
||||
onLoadedMetadata={onLoad}
|
||||
onPause={onPause}
|
||||
onPlay={onPlay}
|
||||
className="video-js vjs-fluid"
|
||||
data-setup="{}"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Fragment, h } from 'preact';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import useSWR from 'swr';
|
||||
import axios from 'axios';
|
||||
import Timeline from '../Timeline/Timeline';
|
||||
import type { TimelineChangeEvent } from '../Timeline/TimelineChangeEvent';
|
||||
import type { TimelineEvent } from '../Timeline/TimelineEvent';
|
||||
import { HistoryHeader } from './HistoryHeader';
|
||||
import { HistoryVideo } from './HistoryVideo';
|
||||
|
||||
export default function HistoryViewer({ camera }: {camera: string}) {
|
||||
const searchParams = {
|
||||
before: null,
|
||||
after: null,
|
||||
camera,
|
||||
label: 'all',
|
||||
zone: 'all',
|
||||
};
|
||||
|
||||
// TODO: refactor
|
||||
const eventsFetcher = (path: string, params: {[name:string]: string|number}) => {
|
||||
params = { ...params, include_thumbnails: 0, limit: 500 };
|
||||
return axios.get<TimelineEvent[]>(path, { params }).then((res) => res.data);
|
||||
};
|
||||
|
||||
const { data: events } = useSWR(['events', searchParams], eventsFetcher);
|
||||
|
||||
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>([]);
|
||||
const [currentEvent, setCurrentEvent] = useState<TimelineEvent>();
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const [currentTime, setCurrentTime] = useState<number>(new Date().getTime());
|
||||
|
||||
useEffect(() => {
|
||||
if (events) {
|
||||
const filteredEvents = [...events].reverse().filter((e) => e.end_time !== undefined);
|
||||
setTimelineEvents(filteredEvents);
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
const handleTimelineChange = useCallback(
|
||||
(event: TimelineChangeEvent) => {
|
||||
if (event.seekComplete) {
|
||||
setCurrentEvent(event.timelineEvent);
|
||||
|
||||
if (isPlaying && event.timelineEvent) {
|
||||
const eventTime = (event.markerTime.getTime() - event.timelineEvent.startTime.getTime()) / 1000;
|
||||
setCurrentTime(eventTime);
|
||||
}
|
||||
}
|
||||
},
|
||||
[isPlaying]
|
||||
);
|
||||
|
||||
const onPlayHandler = () => {
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
const onPausedHandler = () => {
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const onPlayPauseHandler = (isPlaying: boolean) => {
|
||||
setIsPlaying(isPlaying);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Fragment>
|
||||
<div className='relative flex flex-col'>
|
||||
<Fragment>
|
||||
<HistoryHeader event={currentEvent} className='mb-2' />
|
||||
<HistoryVideo
|
||||
id={currentEvent ? currentEvent.id : undefined}
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
onPlay={onPlayHandler}
|
||||
onPause={onPausedHandler}
|
||||
/>
|
||||
</Fragment>
|
||||
</div>
|
||||
</Fragment>
|
||||
|
||||
<Timeline
|
||||
events={timelineEvents}
|
||||
isPlaying={isPlaying}
|
||||
onChange={handleTimelineChange}
|
||||
onPlayPause={onPlayPauseHandler}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { baseUrl } from '../api/baseUrl';
|
||||
import { useRef, useEffect } from 'preact/hooks';
|
||||
import JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||
|
||||
export default function JSMpegPlayer({ camera, width, height }) {
|
||||
const playerRef = useRef();
|
||||
const url = `${baseUrl.replace(/^http/, 'ws')}live/jsmpeg/${camera}`;
|
||||
|
||||
useEffect(() => {
|
||||
const video = new JSMpeg.VideoElement(
|
||||
playerRef.current,
|
||||
url,
|
||||
{},
|
||||
{protocols: [], audio: false, videoBufferSize: 1024*1024*4}
|
||||
);
|
||||
|
||||
const fullscreen = () => {
|
||||
if (video.els.canvas.webkitRequestFullScreen) {
|
||||
video.els.canvas.webkitRequestFullScreen();
|
||||
}
|
||||
else {
|
||||
video.els.canvas.mozRequestFullScreen();
|
||||
}
|
||||
};
|
||||
|
||||
video.els.canvas.addEventListener('click',fullscreen);
|
||||
|
||||
return () => {
|
||||
video.destroy();
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div ref={playerRef} class="jsmpeg" style={`max-height: ${height}px; max-width: ${width}px`} />
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Heading from './Heading';
|
||||
import Logo from './Logo';
|
||||
|
||||
export default function LinkedLogo() {
|
||||
return (
|
||||
<Heading size="lg">
|
||||
<a className="transition-colors flex items-center space-x-4 dark:text-white hover:text-blue-500" href="/">
|
||||
<div className="w-10">
|
||||
<Logo />
|
||||
</div>
|
||||
Frigate
|
||||
</a>
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
export function LiveChip({ className }) {
|
||||
return (
|
||||
<div className={`inline relative px-2 py-1 rounded-full ${className}`}>
|
||||
<div className='relative inline-block w-3 h-3 mr-2'>
|
||||
<span class='flex h-3 w-3'>
|
||||
<span
|
||||
class='animate-ping absolute inline-flex h-full w-full rounded-full opacity-75'
|
||||
style={{ backgroundColor: 'rgb(74 222 128)' }}
|
||||
/>
|
||||
<span class='relative inline-flex rounded-full h-3 w-3' style={{ backgroundColor: 'rgb(74 222 128)' }} />
|
||||
</span>
|
||||
</div>
|
||||
<span>Live</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
export default function Logo() {
|
||||
return (
|
||||
<svg viewBox="0 0 512 512" className="fill-current">
|
||||
@@ -1,48 +0,0 @@
|
||||
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" />;
|
||||
}
|
||||
@@ -1,649 +0,0 @@
|
||||
class VideoRTC extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.DISCONNECT_TIMEOUT = 5000;
|
||||
this.RECONNECT_TIMEOUT = 30000;
|
||||
|
||||
this.CODECS = [
|
||||
'avc1.640029', // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
|
||||
'avc1.64002A', // H.264 high 4.2 (Chromecast 3rd Gen)
|
||||
'avc1.640033', // H.264 high 5.1 (Chromecast with Google TV)
|
||||
'hvc1.1.6.L153.B0', // H.265 main 5.1 (Chromecast Ultra)
|
||||
'mp4a.40.2', // AAC LC
|
||||
'mp4a.40.5', // AAC HE
|
||||
'flac', // FLAC (PCM compatible)
|
||||
'opus', // OPUS Chrome, Firefox
|
||||
];
|
||||
|
||||
/**
|
||||
* [config] Supported modes (webrtc, mse, mp4, mjpeg).
|
||||
* @type {string}
|
||||
*/
|
||||
this.mode = 'webrtc,mse,mp4,mjpeg';
|
||||
|
||||
/**
|
||||
* [config] Run stream when not displayed on the screen. Default `false`.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.background = false;
|
||||
|
||||
/**
|
||||
* [config] Run stream only when player in the viewport. Stop when user scroll out player.
|
||||
* Value is percentage of visibility from `0` (not visible) to `1` (full visible).
|
||||
* Default `0` - disable;
|
||||
* @type {number}
|
||||
*/
|
||||
this.visibilityThreshold = 0;
|
||||
|
||||
/**
|
||||
* [config] Run stream only when browser page on the screen. Stop when user change browser
|
||||
* tab or minimise browser windows.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.visibilityCheck = true;
|
||||
|
||||
/**
|
||||
* [config] WebRTC configuration
|
||||
* @type {RTCConfiguration}
|
||||
*/
|
||||
this.pcConfig = {
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||
sdpSemantics: 'unified-plan', // important for Chromecast 1
|
||||
};
|
||||
|
||||
/**
|
||||
* [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED
|
||||
* @type {number}
|
||||
*/
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
|
||||
/**
|
||||
* [info] WebRTC connection state.
|
||||
* @type {number}
|
||||
*/
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
|
||||
/**
|
||||
* @type {HTMLVideoElement}
|
||||
*/
|
||||
this.video = null;
|
||||
|
||||
/**
|
||||
* @type {WebSocket}
|
||||
*/
|
||||
this.ws = null;
|
||||
|
||||
/**
|
||||
* @type {string|URL}
|
||||
*/
|
||||
this.wsURL = '';
|
||||
|
||||
/**
|
||||
* @type {RTCPeerConnection}
|
||||
*/
|
||||
this.pc = null;
|
||||
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
this.connectTS = 0;
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
this.mseCodecs = '';
|
||||
|
||||
/**
|
||||
* [internal] Disconnect TimeoutID.
|
||||
* @type {number}
|
||||
*/
|
||||
this.disconnectTID = 0;
|
||||
|
||||
/**
|
||||
* [internal] Reconnect TimeoutID.
|
||||
* @type {number}
|
||||
*/
|
||||
this.reconnectTID = 0;
|
||||
|
||||
/**
|
||||
* [internal] Handler for receiving Binary from WebSocket.
|
||||
* @type {Function}
|
||||
*/
|
||||
this.ondata = null;
|
||||
|
||||
/**
|
||||
* [internal] Handlers list for receiving JSON from WebSocket
|
||||
* @type {Object.<string,Function>}}
|
||||
*/
|
||||
this.onmessage = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set video source (WebSocket URL). Support relative path.
|
||||
* @param {string|URL} value
|
||||
*/
|
||||
set src(value) {
|
||||
if (typeof value !== 'string') value = value.toString();
|
||||
if (value.startsWith('http')) {
|
||||
value = `ws${value.substring(4)}`;
|
||||
} else if (value.startsWith('/')) {
|
||||
value = `ws${location.origin.substring(4)}${value}`;
|
||||
}
|
||||
|
||||
this.wsURL = value;
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Play video. Support automute when autoplay blocked.
|
||||
* https://developer.chrome.com/blog/autoplay/
|
||||
*/
|
||||
play() {
|
||||
this.video.play().catch((er) => {
|
||||
if (er.name === 'NotAllowedError' && !this.video.muted) {
|
||||
this.video.muted = true;
|
||||
this.video.play().catch(() => { });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to server via WebSocket
|
||||
* @param {Object} value
|
||||
*/
|
||||
send(value) {
|
||||
if (this.ws) this.ws.send(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/** @param {Function} isSupported */
|
||||
codecs(isSupported) {
|
||||
return this.CODECS.filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join();
|
||||
}
|
||||
|
||||
/**
|
||||
* `CustomElement`. Invoked each time the custom element is appended into a
|
||||
* document-connected element.
|
||||
*/
|
||||
connectedCallback() {
|
||||
if (this.disconnectTID) {
|
||||
clearTimeout(this.disconnectTID);
|
||||
this.disconnectTID = 0;
|
||||
}
|
||||
|
||||
// because video autopause on disconnected from DOM
|
||||
if (this.video) {
|
||||
const seek = this.video.seekable;
|
||||
if (seek.length > 0) {
|
||||
this.video.currentTime = seek.end(seek.length - 1);
|
||||
}
|
||||
this.play();
|
||||
} else {
|
||||
this.oninit();
|
||||
}
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* `CustomElement`. Invoked each time the custom element is disconnected from the
|
||||
* document's DOM.
|
||||
*/
|
||||
disconnectedCallback() {
|
||||
if (this.background || this.disconnectTID) return;
|
||||
if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
|
||||
|
||||
this.disconnectTID = setTimeout(() => {
|
||||
if (this.reconnectTID) {
|
||||
clearTimeout(this.reconnectTID);
|
||||
this.reconnectTID = 0;
|
||||
}
|
||||
|
||||
this.disconnectTID = 0;
|
||||
|
||||
this.ondisconnect();
|
||||
}, this.DISCONNECT_TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates child DOM elements. Called automatically once on `connectedCallback`.
|
||||
*/
|
||||
oninit() {
|
||||
this.video = document.createElement('video');
|
||||
this.video.controls = true;
|
||||
this.video.playsInline = true;
|
||||
this.video.preload = 'auto';
|
||||
this.video.muted = true;
|
||||
|
||||
this.video.style.display = 'block'; // fix bottom margin 4px
|
||||
this.video.style.width = '100%';
|
||||
this.video.style.height = '100%';
|
||||
|
||||
this.appendChild(this.video);
|
||||
|
||||
if (this.background) return;
|
||||
|
||||
if ('hidden' in document && this.visibilityCheck) {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.disconnectedCallback();
|
||||
} else if (this.isConnected) {
|
||||
this.connectedCallback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ('IntersectionObserver' in window && this.visibilityThreshold) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
this.disconnectedCallback();
|
||||
} else if (this.isConnected) {
|
||||
this.connectedCallback();
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: this.visibilityThreshold }
|
||||
);
|
||||
observer.observe(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket. Called automatically on `connectedCallback`.
|
||||
* @return {boolean} true if the connection has started.
|
||||
*/
|
||||
onconnect() {
|
||||
if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false;
|
||||
|
||||
// CLOSED or CONNECTING => CONNECTING
|
||||
this.wsState = WebSocket.CONNECTING;
|
||||
|
||||
this.connectTS = Date.now();
|
||||
|
||||
this.ws = new WebSocket(this.wsURL);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
this.ws.addEventListener('open', (ev) => this.onopen(ev));
|
||||
this.ws.addEventListener('close', (ev) => this.onclose(ev));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
ondisconnect() {
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
if (this.pc) {
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array.<string>} of modes (mse, webrtc, etc.)
|
||||
*/
|
||||
onopen() {
|
||||
// CONNECTING => OPEN
|
||||
this.wsState = WebSocket.OPEN;
|
||||
|
||||
this.ws.addEventListener('message', (ev) => {
|
||||
if (typeof ev.data === 'string') {
|
||||
const msg = JSON.parse(ev.data);
|
||||
for (const mode in this.onmessage) {
|
||||
this.onmessage[mode](msg);
|
||||
}
|
||||
} else {
|
||||
this.ondata(ev.data);
|
||||
}
|
||||
});
|
||||
|
||||
this.ondata = null;
|
||||
this.onmessage = {};
|
||||
|
||||
const modes = [];
|
||||
|
||||
if (this.mode.indexOf('mse') >= 0 && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
|
||||
// iPhone
|
||||
modes.push('mse');
|
||||
this.onmse();
|
||||
} else if (this.mode.indexOf('mp4') >= 0) {
|
||||
modes.push('mp4');
|
||||
this.onmp4();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) {
|
||||
// macOS Desktop app
|
||||
modes.push('webrtc');
|
||||
this.onwebrtc();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf('mjpeg') >= 0) {
|
||||
if (modes.length) {
|
||||
this.onmessage['mjpeg'] = (msg) => {
|
||||
if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return;
|
||||
this.onmjpeg();
|
||||
};
|
||||
} else {
|
||||
modes.push('mjpeg');
|
||||
this.onmjpeg();
|
||||
}
|
||||
}
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} true if reconnection has started.
|
||||
*/
|
||||
onclose() {
|
||||
if (this.wsState === WebSocket.CLOSED) return false;
|
||||
|
||||
// CONNECTING, OPEN => CONNECTING
|
||||
this.wsState = WebSocket.CONNECTING;
|
||||
this.ws = null;
|
||||
|
||||
// reconnect no more than once every X seconds
|
||||
const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
|
||||
|
||||
this.reconnectTID = setTimeout(() => {
|
||||
this.reconnectTID = 0;
|
||||
this.onconnect();
|
||||
}, delay);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onmse() {
|
||||
/** @type {MediaSource} */
|
||||
let ms;
|
||||
|
||||
if ('ManagedMediaSource' in window) {
|
||||
const MediaSource = window.ManagedMediaSource;
|
||||
|
||||
ms = new MediaSource();
|
||||
ms.addEventListener('sourceopen', () => {
|
||||
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
|
||||
}, {once: true});
|
||||
|
||||
this.video.disableRemotePlayback = true;
|
||||
this.video.srcObject = ms;
|
||||
} else {
|
||||
ms = new MediaSource();
|
||||
ms.addEventListener('sourceopen', () => {
|
||||
URL.revokeObjectURL(this.video.src);
|
||||
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
|
||||
}, {once: true});
|
||||
|
||||
this.video.src = URL.createObjectURL(ms);
|
||||
this.video.srcObject = null;
|
||||
}
|
||||
this.play();
|
||||
|
||||
this.mseCodecs = '';
|
||||
|
||||
this.onmessage['mse'] = (msg) => {
|
||||
if (msg.type !== 'mse') return;
|
||||
|
||||
this.mseCodecs = msg.value;
|
||||
|
||||
const sb = ms.addSourceBuffer(msg.value);
|
||||
sb.mode = 'segments'; // segments or sequence
|
||||
sb.addEventListener('updateend', () => {
|
||||
if (sb.updating) return;
|
||||
|
||||
try {
|
||||
if (bufLen > 0) {
|
||||
const data = buf.slice(0, bufLen);
|
||||
bufLen = 0;
|
||||
sb.appendBuffer(data);
|
||||
} else if (sb.buffered && sb.buffered.length) {
|
||||
const end = sb.buffered.end(sb.buffered.length - 1) - 15;
|
||||
const start = sb.buffered.start(0);
|
||||
if (end > start) {
|
||||
sb.remove(start, end);
|
||||
ms.setLiveSeekableRange(end, end + 15);
|
||||
}
|
||||
// console.debug("VideoRTC.buffered", start, end);
|
||||
}
|
||||
} catch (e) {
|
||||
// console.debug(e);
|
||||
}
|
||||
});
|
||||
|
||||
const buf = new Uint8Array(2 * 1024 * 1024);
|
||||
let bufLen = 0;
|
||||
|
||||
this.ondata = (data) => {
|
||||
if (sb.updating || bufLen > 0) {
|
||||
const b = new Uint8Array(data);
|
||||
buf.set(b, bufLen);
|
||||
bufLen += b.byteLength;
|
||||
// console.debug("VideoRTC.buffer", b.byteLength, bufLen);
|
||||
} else {
|
||||
try {
|
||||
sb.appendBuffer(data);
|
||||
} catch (e) {
|
||||
// console.debug(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
onwebrtc() {
|
||||
const pc = new RTCPeerConnection(this.pcConfig);
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = document.createElement('video');
|
||||
video2.addEventListener('loadeddata', (ev) => this.onpcvideo(ev), { once: true });
|
||||
|
||||
pc.addEventListener('icecandidate', (ev) => {
|
||||
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
|
||||
this.send({ type: 'webrtc/candidate', value: candidate });
|
||||
});
|
||||
|
||||
pc.addEventListener('track', (ev) => {
|
||||
// when stream already init
|
||||
if (video2.srcObject !== null) return;
|
||||
|
||||
// when audio track not exist in Chrome
|
||||
if (ev.streams.length === 0) return;
|
||||
|
||||
// when audio track not exist in Firefox
|
||||
if (ev.streams[0].id[0] === '{') return;
|
||||
|
||||
video2.srcObject = ev.streams[0];
|
||||
});
|
||||
|
||||
pc.addEventListener('connectionstatechange', () => {
|
||||
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
|
||||
pc.close(); // stop next events
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
this.pc = null;
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.onmessage['webrtc'] = (msg) => {
|
||||
switch (msg.type) {
|
||||
case 'webrtc/candidate':
|
||||
pc.addIceCandidate({
|
||||
candidate: msg.value,
|
||||
sdpMid: '0',
|
||||
}).catch(() => { });
|
||||
break;
|
||||
case 'webrtc/answer':
|
||||
pc.setRemoteDescription({
|
||||
type: 'answer',
|
||||
sdp: msg.value,
|
||||
}).catch(() => { });
|
||||
break;
|
||||
case 'error':
|
||||
if (msg.value.indexOf('webrtc/offer') < 0) return;
|
||||
pc.close();
|
||||
}
|
||||
};
|
||||
|
||||
// Safari doesn't support "offerToReceiveVideo"
|
||||
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||
|
||||
pc.createOffer().then((offer) => {
|
||||
pc.setLocalDescription(offer).then(() => {
|
||||
this.send({ type: 'webrtc/offer', value: offer.sdp });
|
||||
});
|
||||
});
|
||||
|
||||
this.pcState = WebSocket.CONNECTING;
|
||||
this.pc = pc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ev {Event}
|
||||
*/
|
||||
onpcvideo(ev) {
|
||||
if (!this.pc) return;
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = ev.target;
|
||||
const state = this.pc.connectionState;
|
||||
|
||||
// Firefox doesn't support pc.connectionState
|
||||
if (state === 'connected' || state === 'connecting' || !state) {
|
||||
// Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
|
||||
let rtcPriority = 0,
|
||||
msePriority = 0;
|
||||
|
||||
/** @type {MediaStream} */
|
||||
const ms = video2.srcObject;
|
||||
if (ms.getVideoTracks().length > 0) rtcPriority += 0x220;
|
||||
if (ms.getAudioTracks().length > 0) rtcPriority += 0x102;
|
||||
|
||||
if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230;
|
||||
if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210;
|
||||
if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101;
|
||||
|
||||
if (rtcPriority >= msePriority) {
|
||||
this.video.srcObject = ms;
|
||||
this.play();
|
||||
|
||||
this.pcState = WebSocket.OPEN;
|
||||
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
} else {
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
}
|
||||
|
||||
video2.srcObject = null;
|
||||
}
|
||||
|
||||
onmjpeg() {
|
||||
this.ondata = (data) => {
|
||||
this.video.controls = false;
|
||||
this.video.poster = `data:image/jpeg;base64,${VideoRTC.btoa(data)}`;
|
||||
};
|
||||
|
||||
this.send({ type: 'mjpeg' });
|
||||
}
|
||||
|
||||
onmp4() {
|
||||
/** @type {HTMLCanvasElement} **/
|
||||
const canvas = document.createElement('canvas');
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
let context;
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = document.createElement('video');
|
||||
video2.autoplay = true;
|
||||
video2.playsInline = true;
|
||||
video2.muted = true;
|
||||
|
||||
video2.addEventListener('loadeddata', (_) => {
|
||||
if (!context) {
|
||||
canvas.width = video2.videoWidth;
|
||||
canvas.height = video2.videoHeight;
|
||||
context = canvas.getContext('2d');
|
||||
}
|
||||
|
||||
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
this.video.controls = false;
|
||||
this.video.poster = canvas.toDataURL('image/jpeg');
|
||||
});
|
||||
|
||||
this.ondata = (data) => {
|
||||
video2.src = `data:video/mp4;base64,${VideoRTC.btoa(data)}`;
|
||||
};
|
||||
|
||||
this.send({ type: 'mp4', value: this.codecs(this.video.canPlayType) });
|
||||
}
|
||||
|
||||
static btoa(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const len = bytes.byteLength;
|
||||
let binary = '';
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
}
|
||||
|
||||
class VideoStream extends VideoRTC {
|
||||
|
||||
|
||||
/**
|
||||
* Custom GUI
|
||||
*/
|
||||
oninit() {
|
||||
super.oninit();
|
||||
const info = this.querySelector('.info');
|
||||
this.insertBefore(this.video, info);
|
||||
}
|
||||
|
||||
onconnect() {
|
||||
const result = super.onconnect();
|
||||
if (result) this.divMode = 'loading';
|
||||
return result;
|
||||
}
|
||||
|
||||
ondisconnect() {;
|
||||
super.ondisconnect();
|
||||
}
|
||||
|
||||
onopen() {
|
||||
const result = super.onopen();
|
||||
|
||||
this.onmessage['stream'] = (_) => {
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
onclose() {
|
||||
return super.onclose();
|
||||
}
|
||||
|
||||
onpcvideo(ev) {
|
||||
super.onpcvideo(ev);
|
||||
|
||||
if (this.pcState !== WebSocket.CLOSED) {
|
||||
this.divMode = 'RTC';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('video-stream', VideoStream);
|
||||
@@ -1,70 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { Link } from 'preact-router/match';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
import { useDrawer } from '../context';
|
||||
|
||||
export default function NavigationDrawer({ children, header }) {
|
||||
const { showDrawer, setShowDrawer } = useDrawer();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setShowDrawer(false);
|
||||
}, [setShowDrawer]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{showDrawer ? <div data-testid="scrim" key="scrim" className="fixed inset-0 z-20" onClick={handleDismiss} /> : ''}
|
||||
<div
|
||||
key="drawer"
|
||||
data-testid="drawer"
|
||||
className={`fixed left-0 top-0 bottom-0 lg:sticky max-h-screen flex flex-col w-64 text-gray-700 bg-white dark:text-gray-200 dark:bg-gray-900 flex-shrink-0 border-r border-gray-200 dark:border-gray-700 shadow lg:shadow-none z-20 lg:z-0 transform ${
|
||||
!showDrawer ? '-translate-x-full lg:translate-x-0' : 'translate-x-0'
|
||||
} transition-transform duration-300`}
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
{header ? (
|
||||
<div className="flex-shrink-0 p-2 flex flex-row items-center justify-between border-b border-gray-200 dark:border-gray-700">
|
||||
{header}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<nav className="flex flex-col flex-grow overflow-hidden overflow-y-auto p-2 space-y-2">{children}</nav>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export function Destination({ className = '', href, text, ...other }) {
|
||||
const external = href.startsWith('http');
|
||||
const props = external ? { rel: 'noopener nofollow', target: '_blank' } : {};
|
||||
|
||||
const { setShowDrawer } = useDrawer();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
setShowDrawer(false);
|
||||
}, 250);
|
||||
}, [setShowDrawer]);
|
||||
|
||||
const styleProps = {
|
||||
[external
|
||||
? className
|
||||
: 'class']: 'block p-2 text-sm font-semibold text-gray-900 rounded hover:bg-blue-500 dark:text-gray-200 hover:text-white dark:hover:text-white focus:outline-none ring-opacity-50 focus:ring-2 ring-blue-300',
|
||||
};
|
||||
|
||||
const El = external ? 'a' : Link;
|
||||
|
||||
return (
|
||||
<El activeClassName="bg-blue-500 bg-opacity-50 text-white" {...styleProps} href={href} {...props} {...other}>
|
||||
<div onClick={handleDismiss}>{text}</div>
|
||||
</El>
|
||||
);
|
||||
}
|
||||
|
||||
export function Separator() {
|
||||
return <div className="border-b border-gray-200 dark:border-gray-700 -mx-2" />;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Button from './Button';
|
||||
import Heading from './Heading';
|
||||
import Dialog from './Dialog';
|
||||
|
||||
export default function Prompt({ actions = [], title, text }) {
|
||||
return (
|
||||
<Dialog>
|
||||
<div className='p-4'>
|
||||
<Heading size='lg'>{title}</Heading>
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
<div className='p-2 flex justify-start flex-row-reverse space-x-2'>
|
||||
{actions.map(({ color, text, onClick, ...props }, i) => (
|
||||
<Button className='ml-2' color={color} key={i} onClick={onClick} type='text' {...props}>
|
||||
{text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useMemo } from 'preact/hooks';
|
||||
import {
|
||||
getUnixTime,
|
||||
fromUnixTime,
|
||||
format,
|
||||
parseISO,
|
||||
intervalToDuration,
|
||||
formatDuration,
|
||||
endOfDay,
|
||||
startOfDay,
|
||||
isSameDay,
|
||||
} from 'date-fns';
|
||||
import ArrowDropdown from '../icons/ArrowDropdown';
|
||||
import ArrowDropup from '../icons/ArrowDropup';
|
||||
import Link from '../components/Link';
|
||||
import ActivityIndicator from '../components/ActivityIndicator';
|
||||
import Menu from '../icons/Menu';
|
||||
import MenuOpen from '../icons/MenuOpen';
|
||||
import { useApiHost } from '../api';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export default function RecordingPlaylist({ camera, recordings, selectedDate }) {
|
||||
const [active, setActive] = useState(true);
|
||||
const toggle = () => setActive(!active);
|
||||
|
||||
const result = [];
|
||||
for (const recording of recordings) {
|
||||
const date = parseISO(recording.day);
|
||||
result.push(
|
||||
<ExpandableList
|
||||
title={format(date, 'MMM d, yyyy')}
|
||||
events={recording.events}
|
||||
selected={isSameDay(date, selectedDate)}
|
||||
>
|
||||
<DayOfEvents camera={camera} day={recording.day} hours={recording.hours} />
|
||||
</ExpandableList>
|
||||
);
|
||||
}
|
||||
|
||||
const openClass = active ? '-left-6' : 'right-0';
|
||||
|
||||
return (
|
||||
<div className="flex absolute inset-y-0 right-0 w-9/12 md:w-1/2 lg:w-3/5 max-w-md text-base text-white font-sans">
|
||||
<div
|
||||
onClick={toggle}
|
||||
className={`absolute ${openClass} cursor-pointer items-center self-center rounded-tl-lg rounded-bl-lg border border-r-0 w-6 h-20 py-7 bg-gray-800 bg-opacity-70 z-10`}
|
||||
>
|
||||
{active ? <Menu /> : <MenuOpen />}
|
||||
</div>
|
||||
<div
|
||||
className={`w-full h-full bg-gray-800 bg-opacity-70 border-l overflow-x-hidden overflow-y-auto z-10${
|
||||
active ? '' : ' hidden'
|
||||
}`}
|
||||
>
|
||||
{result}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DayOfEvents({ camera, day, hours }) {
|
||||
const date = parseISO(day);
|
||||
const { data: events } = useSWR([
|
||||
`events`,
|
||||
{
|
||||
before: getUnixTime(endOfDay(date)),
|
||||
after: getUnixTime(startOfDay(date)),
|
||||
camera,
|
||||
has_clip: '1',
|
||||
include_thumbnails: 0,
|
||||
limit: 5000,
|
||||
},
|
||||
]);
|
||||
|
||||
// maps all the events under the keys for the hour by hour recordings view
|
||||
const eventMap = useMemo(() => {
|
||||
const eventMap = {};
|
||||
for (const hour of hours) {
|
||||
eventMap[`${day}-${hour.hour}`] = [];
|
||||
}
|
||||
|
||||
if (!events) {
|
||||
return eventMap;
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
const key = format(fromUnixTime(event.start_time), 'yyyy-MM-dd-HH');
|
||||
// if the hour of recordings is missing for the event start time, skip it
|
||||
if (key in eventMap) {
|
||||
eventMap[key].push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return eventMap;
|
||||
}, [events, day, hours]);
|
||||
|
||||
if (!events) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hours.map((hour, i) => (
|
||||
<div key={i} className="mb-2 w-full">
|
||||
<div
|
||||
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
|
||||
i === 0 ? 'border-t border-white border-opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Link href={`/recording/${camera}/${day}/${hour.hour}`} type="text">
|
||||
{hour.hour}:00
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1 text-right">{hour.events} Events</div>
|
||||
</div>
|
||||
{eventMap[`${day}-${hour.hour}`].map((event) => (
|
||||
<EventCard key={event.id} camera={camera} event={event} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExpandableList({ title, events = 0, children, selected = false }) {
|
||||
const [active, setActive] = useState(selected);
|
||||
const toggle = () => setActive(!active);
|
||||
return (
|
||||
<div className={`w-full text-sm ${active ? 'border-b border-white border-opacity-50' : ''}`}>
|
||||
<div className="flex items-center w-full p-2 cursor-pointer md:text-lg" onClick={toggle}>
|
||||
<div className="flex-1 font-bold">{title}</div>
|
||||
<div className="flex-1 text-right mr-4">{events} Events</div>
|
||||
<div className="w-6 md:w-10 h-6 md:h-10">{active ? <ArrowDropup /> : <ArrowDropdown />}</div>
|
||||
</div>
|
||||
{/* Only render the child when expanded to lazy load events for the day */}
|
||||
{active && <div className={`bg-gray-800 bg-opacity-50`}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EventCard({ camera, event }) {
|
||||
const apiHost = useApiHost();
|
||||
const start = fromUnixTime(event.start_time);
|
||||
const end = fromUnixTime(event.end_time);
|
||||
let duration = 'In Progress';
|
||||
if (event.end_time) {
|
||||
duration = formatDuration(intervalToDuration({ start, end }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className="" href={`/recording/${camera}/${format(start, 'yyyy-MM-dd/HH/mm/ss')}`}>
|
||||
<div className="flex flex-row mb-2">
|
||||
<div className="w-28 mr-4">
|
||||
<img className="antialiased" loading="lazy" src={`${apiHost}api/events/${event.id}/thumbnail.jpg`} />
|
||||
</div>
|
||||
<div className="flex flex-row w-full border-b">
|
||||
<div className="w-full text-gray-700 font-semibold relative pt-0">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex-1">
|
||||
<div className="text-2xl text-white leading-tight capitalize">{event.label}</div>
|
||||
<div className="text-xs md:text-normal text-gray-300">Start: {format(start, 'HH:mm:ss')}</div>
|
||||
<div className="text-xs md:text-normal text-gray-300">Duration: {duration}</div>
|
||||
</div>
|
||||
<div className="text-lg text-white text-right leading-tight">
|
||||
{((event?.data?.top_score || event.top_score) * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-6" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
const WINDOW_PADDING = 20;
|
||||
|
||||
export default function RelativeModal({
|
||||
className,
|
||||
role = 'dialog',
|
||||
children,
|
||||
onDismiss,
|
||||
portalRootID,
|
||||
relativeTo,
|
||||
widthRelative = false,
|
||||
}) {
|
||||
const [position, setPosition] = useState({ top: -9999, left: -9999 });
|
||||
const [show, setShow] = useState(false);
|
||||
const portalRoot = portalRootID && document.getElementById(portalRootID);
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
(event) => {
|
||||
onDismiss && onDismiss(event);
|
||||
},
|
||||
[onDismiss]
|
||||
);
|
||||
|
||||
const handleKeydown = useCallback(
|
||||
(event) => {
|
||||
const focusable = ref.current && ref.current.querySelectorAll('[tabindex]');
|
||||
if (event.key === 'Tab' && focusable.length) {
|
||||
if (event.shiftKey && document.activeElement === focusable[0]) {
|
||||
focusable[focusable.length - 1].focus();
|
||||
event.preventDefault();
|
||||
} else if (document.activeElement === focusable[focusable.length - 1]) {
|
||||
focusable[0].focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
setShow(false);
|
||||
handleDismiss();
|
||||
return;
|
||||
}
|
||||
},
|
||||
[ref, handleDismiss]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (ref && ref.current && relativeTo && relativeTo.current) {
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
const { width: menuWidth, height: menuHeight } = ref.current.getBoundingClientRect();
|
||||
const {
|
||||
x: relativeToX,
|
||||
y: relativeToY,
|
||||
width: relativeToWidth,
|
||||
height: relativeToHeight,
|
||||
} = relativeTo.current.getBoundingClientRect();
|
||||
|
||||
const _width = widthRelative ? relativeToWidth : menuWidth;
|
||||
const width = _width * 1.1;
|
||||
|
||||
const left = relativeToX + window.scrollX;
|
||||
const top = relativeToY + window.scrollY;
|
||||
|
||||
let newTop = top;
|
||||
let newLeft = left;
|
||||
|
||||
// too far left
|
||||
if (left < WINDOW_PADDING) {
|
||||
newLeft = WINDOW_PADDING;
|
||||
}
|
||||
// too far right
|
||||
else if (newLeft + width + WINDOW_PADDING >= windowWidth - WINDOW_PADDING) {
|
||||
newLeft = windowWidth - width - WINDOW_PADDING;
|
||||
}
|
||||
|
||||
// This condition checks if the menu overflows the bottom of the page and
|
||||
// if there's enough space to position the menu above the clicked icon.
|
||||
// If both conditions are met, the menu will be positioned above the clicked icon
|
||||
if (
|
||||
top + menuHeight > windowHeight - WINDOW_PADDING + window.scrollY &&
|
||||
top - menuHeight - relativeToHeight >= WINDOW_PADDING
|
||||
) {
|
||||
newTop = top - menuHeight;
|
||||
}
|
||||
|
||||
if (top <= WINDOW_PADDING + window.scrollY) {
|
||||
newTop = WINDOW_PADDING;
|
||||
}
|
||||
|
||||
// This calculation checks if there's enough space below the clicked icon for the menu to fit.
|
||||
// If there is, it sets the maxHeight to null(meaning no height constraint). If not, it calculates the maxHeight based on the remaining space in the window
|
||||
const maxHeight =
|
||||
windowHeight - WINDOW_PADDING * 2 - top > menuHeight
|
||||
? null
|
||||
: windowHeight - WINDOW_PADDING * 2 - top + window.scrollY;
|
||||
|
||||
const newPosition = { left: newLeft, top: newTop, maxHeight };
|
||||
if (widthRelative) {
|
||||
newPosition.width = relativeToWidth;
|
||||
}
|
||||
setPosition(newPosition);
|
||||
const focusable = ref.current.querySelector('[tabindex]');
|
||||
focusable && focusable.focus();
|
||||
}
|
||||
}, [relativeTo, ref, widthRelative]);
|
||||
|
||||
useEffect(() => {
|
||||
if (position.top >= 0) {
|
||||
window.requestAnimationFrame(() => {
|
||||
setShow(true);
|
||||
});
|
||||
} else {
|
||||
setShow(false);
|
||||
}
|
||||
}, [show, position, ref]);
|
||||
|
||||
const menu = (
|
||||
<Fragment>
|
||||
<div data-testid="scrim" key="scrim" className="fixed inset-0 z-10" onClick={handleDismiss} />
|
||||
<div
|
||||
key="menu"
|
||||
className={`z-10 bg-white dark:bg-gray-700 dark:text-white absolute shadow-lg rounded w-auto h-auto transition-transform duration-75 transform scale-90 opacity-0 overflow-x-hidden overflow-y-auto ${
|
||||
show ? 'scale-100 opacity-100' : ''
|
||||
} ${className}`}
|
||||
onKeyDown={handleKeydown}
|
||||
role={role}
|
||||
ref={ref}
|
||||
style={position}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
return portalRoot ? createPortal(menu, portalRoot) : menu;
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import ArrowDropdown from '../icons/ArrowDropdown';
|
||||
import ArrowDropup from '../icons/ArrowDropup';
|
||||
import Menu, { MenuItem } from './Menu';
|
||||
import TextField from './TextField';
|
||||
import DatePicker from './DatePicker';
|
||||
import Calendar from './Calendar';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
|
||||
export default function Select({
|
||||
type,
|
||||
label,
|
||||
onChange,
|
||||
paramName,
|
||||
options: inputOptions = [],
|
||||
selected: propSelected,
|
||||
}) {
|
||||
const options = useMemo(
|
||||
() =>
|
||||
typeof inputOptions[0] === 'string' ? inputOptions.map((opt) => ({ value: opt, label: opt })) : inputOptions,
|
||||
[inputOptions]
|
||||
);
|
||||
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [selected, setSelected] = useState();
|
||||
const [datePickerValue, setDatePickerValue] = useState();
|
||||
|
||||
// Reset the state if the prop value changes
|
||||
useEffect(() => {
|
||||
const selectedIndex = Math.max(
|
||||
options.findIndex(({ value }) => value === propSelected),
|
||||
0
|
||||
);
|
||||
if (propSelected && selectedIndex !== selected) {
|
||||
setSelected(selectedIndex);
|
||||
setFocused(selectedIndex);
|
||||
}
|
||||
// DO NOT include `selected`
|
||||
}, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'datepicker') {
|
||||
if ('after' && 'before' in propSelected) {
|
||||
if (!propSelected.before || !propSelected.after) return setDatePickerValue('all');
|
||||
|
||||
for (let i = 0; i < inputOptions.length; i++) {
|
||||
if (
|
||||
inputOptions[i].value &&
|
||||
Object.entries(inputOptions[i].value).sort().toString() === Object.entries(propSelected).sort().toString()
|
||||
) {
|
||||
setDatePickerValue(inputOptions[i]?.label);
|
||||
break;
|
||||
} else {
|
||||
setDatePickerValue(
|
||||
`${new Date(propSelected.after * 1000).toLocaleDateString()} -> ${new Date(
|
||||
propSelected.before * 1000 - 1
|
||||
).toLocaleDateString()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (type === 'dropdown') {
|
||||
setSelected(
|
||||
Math.max(
|
||||
options.findIndex(({ value }) => Object.values(propSelected).includes(value)),
|
||||
0
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [type, options, inputOptions, propSelected, setSelected]);
|
||||
|
||||
const [focused, setFocused] = useState(null);
|
||||
const [showCalendar, setShowCalendar] = useState(false);
|
||||
const calendarRef = useRef(null);
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value) => {
|
||||
setSelected(options.findIndex(({ value }) => Object.values(propSelected).includes(value)));
|
||||
setShowMenu(false);
|
||||
|
||||
//show calendar date range picker
|
||||
if (value === 'custom_range') return setShowCalendar(true);
|
||||
onChange && onChange(value);
|
||||
},
|
||||
[onChange, options, propSelected, setSelected]
|
||||
);
|
||||
|
||||
const handleDateRange = useCallback(
|
||||
(range) => {
|
||||
onChange && onChange(range);
|
||||
setShowMenu(false);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setShowMenu(true);
|
||||
}, [setShowMenu]);
|
||||
|
||||
const handleKeydownDatePicker = useCallback(
|
||||
(event) => {
|
||||
switch (event.key) {
|
||||
case 'Enter': {
|
||||
if (!showMenu) {
|
||||
setShowMenu(true);
|
||||
setFocused(selected);
|
||||
} else {
|
||||
setSelected(focused);
|
||||
if (options[focused].value === 'custom_range') {
|
||||
setShowMenu(false);
|
||||
return setShowCalendar(true);
|
||||
}
|
||||
|
||||
onChange && onChange(options[focused].value);
|
||||
setShowMenu(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
const newIndex = focused + 1;
|
||||
newIndex < options.length && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
const newIndex = focused - 1;
|
||||
newIndex > -1 && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
// no default
|
||||
}
|
||||
},
|
||||
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
|
||||
);
|
||||
|
||||
const handleKeydown = useCallback(
|
||||
(event) => {
|
||||
switch (event.key) {
|
||||
case 'Enter': {
|
||||
if (!showMenu) {
|
||||
setShowMenu(true);
|
||||
setFocused(selected);
|
||||
} else {
|
||||
setSelected(focused);
|
||||
onChange && onChange({ [paramName]: options[focused].value });
|
||||
setShowMenu(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
const newIndex = focused + 1;
|
||||
newIndex < options.length && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
const newIndex = focused - 1;
|
||||
newIndex > -1 && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
// no default
|
||||
}
|
||||
},
|
||||
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected, paramName]
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setShowMenu(false);
|
||||
}, [setShowMenu]);
|
||||
|
||||
const findDOMNodes = (component) => {
|
||||
return (component && (component.base || (component.nodeType === 1 && component))) || null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const addBackDrop = (e) => {
|
||||
if (showCalendar && !findDOMNodes(calendarRef.current).contains(e.target)) {
|
||||
setShowCalendar(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', addBackDrop);
|
||||
|
||||
return function cleanup() {
|
||||
window.removeEventListener('click', addBackDrop);
|
||||
};
|
||||
}, [showCalendar]);
|
||||
|
||||
switch (type) {
|
||||
case 'datepicker':
|
||||
return (
|
||||
<Fragment>
|
||||
<DatePicker
|
||||
inputRef={ref}
|
||||
label={label}
|
||||
onchange={onChange}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydownDatePicker}
|
||||
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
||||
value={datePickerValue}
|
||||
/>
|
||||
{showCalendar && (
|
||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref}>
|
||||
<Calendar onChange={handleDateRange} calendarRef={calendarRef} close={() => setShowCalendar(false)} />
|
||||
</Menu>
|
||||
)}
|
||||
{showMenu ? (
|
||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
|
||||
{options.map(({ value, label }, i) => (
|
||||
<MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} />
|
||||
))}
|
||||
</Menu>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
// case 'dropdown':
|
||||
default:
|
||||
return (
|
||||
<Fragment>
|
||||
<TextField
|
||||
inputRef={ref}
|
||||
label={label}
|
||||
onchange={onChange}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydown}
|
||||
readonly
|
||||
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
||||
value={options[selected]?.label}
|
||||
/>
|
||||
{showMenu ? (
|
||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
|
||||
{options.map(({ value, label }, i) => (
|
||||
<MenuItem
|
||||
key={value}
|
||||
label={label}
|
||||
focus={focused === i}
|
||||
onSelect={handleSelect}
|
||||
value={{ [paramName]: value }}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
101
web/src/components/Sidebar.tsx
Normal file
101
web/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { IconType } from "react-icons";
|
||||
import { LuFileUp, LuFilm, LuLayoutDashboard, LuVideo } from "react-icons/lu";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import Logo from "./Logo";
|
||||
|
||||
const navbarLinks = [
|
||||
{
|
||||
id: 1,
|
||||
icon: LuLayoutDashboard,
|
||||
title: "Dashboard",
|
||||
url: "/",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: LuVideo,
|
||||
title: "Live",
|
||||
url: "/live",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: LuFilm,
|
||||
title: "History",
|
||||
url: "/history",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: LuFileUp,
|
||||
title: "Export",
|
||||
url: "/export",
|
||||
},
|
||||
];
|
||||
|
||||
function Sidebar({
|
||||
sheetOpen,
|
||||
setSheetOpen,
|
||||
}: {
|
||||
sheetOpen: boolean;
|
||||
setSheetOpen: (open: boolean) => void;
|
||||
}) {
|
||||
const sidebar = (
|
||||
<aside className="sticky top-0 overflow-y-auto scrollbar-hidden py-4 lg:pt-0 flex flex-col ml-1 lg:w-56 gap-0">
|
||||
{navbarLinks.map((item) => (
|
||||
<SidebarItem
|
||||
key={item.id}
|
||||
Icon={item.icon}
|
||||
title={item.title}
|
||||
url={item.url}
|
||||
onClick={() => setSheetOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hidden md:block">{sidebar}</div>
|
||||
|
||||
<Sheet
|
||||
open={sheetOpen}
|
||||
modal={false}
|
||||
onOpenChange={() => setSheetOpen(false)}
|
||||
>
|
||||
<SheetContent side="left" className="w-[120px]">
|
||||
<div className="w-full flex flex-row justify-center">
|
||||
<div className="w-10">
|
||||
<Logo />
|
||||
</div>
|
||||
</div>
|
||||
{sidebar}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarItemProps = {
|
||||
Icon: IconType;
|
||||
title: string;
|
||||
url: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
function SidebarItem({ Icon, title, url, onClick }: SidebarItemProps) {
|
||||
return (
|
||||
<NavLink
|
||||
to={url}
|
||||
onClick={onClick}
|
||||
className={({ isActive }) =>
|
||||
`py-4 px-2 flex flex-col lg:flex-row items-center rounded-lg gap-2 lg:w-full hover:bg-border ${
|
||||
isActive ? "font-bold bg-popover text-popover-foreground" : ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-6 h-6 mr-1" />
|
||||
<div className="text-sm">{title}</div>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
@@ -1,68 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
|
||||
export default function Switch({ className, checked, id, onChange, label, labelPosition = 'before' }) {
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
|
||||
const handleChange = useCallback(() => {
|
||||
if (onChange) {
|
||||
onChange(id, !checked);
|
||||
}
|
||||
}, [id, onChange, checked]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
onChange && setFocused(true);
|
||||
}, [onChange, setFocused]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
onChange && setFocused(false);
|
||||
}, [onChange, setFocused]);
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={`${className ? className : ''} flex items-center space-x-4 w-full ${onChange ? 'cursor-pointer' : 'cursor-not-allowed'}`}
|
||||
>
|
||||
{label && labelPosition === 'before' ? (
|
||||
<div data-testid={`${id}-label`} className="inline-flex flex-grow">
|
||||
{label}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
onMouseOver={handleFocus}
|
||||
onMouseOut={handleBlur}
|
||||
className={`self-end w-8 h-5 relative ${!onChange ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<input
|
||||
data-testid={`${id}-input`}
|
||||
className="absolute left-48"
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
tabIndex="0"
|
||||
id={id}
|
||||
type="checkbox"
|
||||
onChange={handleChange}
|
||||
checked={checked}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`w-8 h-3 absolute top-1 left-1 ${
|
||||
!checked ? 'bg-gray-300' : 'bg-blue-300'
|
||||
} rounded-full shadow-inner`}
|
||||
/>
|
||||
<div
|
||||
className={`transition-all absolute w-5 h-5 rounded-full shadow-md inset-y-0 left-0 ring-opacity-30 ${
|
||||
isFocused ? 'ring-4 ring-gray-500' : ''
|
||||
} ${checked ? 'bg-blue-600' : 'bg-white'} ${isFocused && checked ? 'ring-blue-500' : ''}`}
|
||||
style={checked ? 'transform: translateX(100%);' : 'transform: translateX(0%);'}
|
||||
/>
|
||||
</div>
|
||||
{label && labelPosition !== 'before' ? (
|
||||
<div data-testid={`${id}-label`} class="inline-flex flex-grow">
|
||||
{label}
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
export function Table({ children, className = '' }) {
|
||||
return (
|
||||
<table className={`table-auto border-collapse text-gray-900 dark:text-gray-200 ${className}`}>{children}</table>
|
||||
);
|
||||
}
|
||||
|
||||
export function Thead({ children, className, ...attrs }) {
|
||||
return (
|
||||
<thead className={className} {...attrs}>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tbody({ children, className, reference, ...attrs }) {
|
||||
return (
|
||||
<tbody ref={reference} className={className} {...attrs}>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tfoot({ children, className = '', ...attrs }) {
|
||||
return (
|
||||
<tfoot className={`${className}`} {...attrs}>
|
||||
{children}
|
||||
</tfoot>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tr({ children, className = '', reference, ...attrs }) {
|
||||
return (
|
||||
<tr
|
||||
ref={reference}
|
||||
className={`border-b border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${className}`}
|
||||
{...attrs}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function Th({ children, className = '', colspan, ...attrs }) {
|
||||
return (
|
||||
<th className={`border-b border-gray-400 p-2 px-1 lg:p-4 text-left ${className}`} colSpan={colspan} {...attrs}>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function Td({ children, className = '', reference, colspan, ...attrs }) {
|
||||
return (
|
||||
<td ref={reference} className={`p-2 px-1 lg:p-4 ${className}`} colSpan={colspan} {...attrs}>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export default function TextField({
|
||||
helpText,
|
||||
keyboardType = 'text',
|
||||
inputRef,
|
||||
label,
|
||||
leadingIcon: LeadingIcon,
|
||||
onBlur,
|
||||
onChangeText,
|
||||
onFocus,
|
||||
readonly,
|
||||
trailingIcon: TrailingIcon,
|
||||
value: propValue = '',
|
||||
...props
|
||||
}) {
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
const [value, setValue] = useState(propValue);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(event) => {
|
||||
setFocused(true);
|
||||
onFocus && onFocus(event);
|
||||
},
|
||||
[onFocus]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event) => {
|
||||
setFocused(false);
|
||||
onBlur && onBlur(event);
|
||||
},
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event) => {
|
||||
const { value } = event.target;
|
||||
setValue(value);
|
||||
onChangeText && onChangeText(value);
|
||||
},
|
||||
[onChangeText, setValue]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (propValue !== value) {
|
||||
setValue(propValue);
|
||||
}
|
||||
// DO NOT include `value`
|
||||
}, [propValue, setValue]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const labelMoved = isFocused || value !== '';
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
className={`bg-gray-100 dark:bg-gray-700 rounded rounded-b-none border-gray-400 border-b p-1 pl-4 pr-3 ${
|
||||
isFocused ? 'border-blue-500 dark:border-blue-500' : ''
|
||||
}`}
|
||||
ref={inputRef}
|
||||
>
|
||||
<label
|
||||
className="flex space-x-2 items-center"
|
||||
data-testid={`label-${label.toLowerCase().replace(/[^\w]+/g, '_')}`}
|
||||
>
|
||||
{LeadingIcon ? (
|
||||
<div className="w-10 h-full">
|
||||
<LeadingIcon />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
className="h-6 mt-6 w-full bg-transparent focus:outline-none focus:ring-0"
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onInput={handleChange}
|
||||
readOnly={readonly}
|
||||
tabIndex="0"
|
||||
type={keyboardType}
|
||||
value={value}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-3 transition transform text-gray-600 dark:text-gray-400 ${
|
||||
labelMoved ? 'text-xs -translate-y-2' : ''
|
||||
} ${isFocused ? 'text-blue-500 dark:text-blue-500' : ''}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
{TrailingIcon ? (
|
||||
<div className="w-10 h-10">
|
||||
<TrailingIcon />
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
{helpText ? <div className="text-xs pl-3 pt-1">{helpText}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { ArrowDropdown } from '../icons/ArrowDropdown';
|
||||
import { ArrowDropup } from '../icons/ArrowDropup';
|
||||
import Heading from './Heading';
|
||||
|
||||
const TimePicker = ({ timeRange, onChange }) => {
|
||||
const times = timeRange.split(',');
|
||||
const [after, setAfter] = useState(times[0]);
|
||||
const [before, setBefore] = useState(times[1]);
|
||||
|
||||
// Create repeating array with the number of hours for 1 day ...23,24,0,1,2...
|
||||
const hoursInDays = Array.from({ length: 24 }, (_, i) => String(i % 24).padStart(2, '0'));
|
||||
|
||||
// background colors for each day
|
||||
function randomGrayTone(shade) {
|
||||
const grayTones = [
|
||||
'bg-[#212529]/50',
|
||||
'bg-[#343a40]/50',
|
||||
'bg-[#495057]/50',
|
||||
'bg-[#666463]/50',
|
||||
'bg-[#817D7C]/50',
|
||||
'bg-[#73706F]/50',
|
||||
'bg-[#585655]/50',
|
||||
'bg-[#4F4D4D]/50',
|
||||
'bg-[#454343]/50',
|
||||
'bg-[#363434]/50',
|
||||
];
|
||||
return grayTones[shade % grayTones.length];
|
||||
}
|
||||
|
||||
const isSelected = (idx, current) => {
|
||||
return current == `${idx}:00`;
|
||||
};
|
||||
|
||||
const isSelectedCss = 'bg-blue-600 transition duration-300 ease-in-out hover:rounded-none';
|
||||
const handleTime = (after, before) => {
|
||||
setAfter(after);
|
||||
setBefore(before);
|
||||
onChange(`${after},${before}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-2 pr-3 hidden xs:block" aria-label="Calendar timepicker, select a time range">
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowDropup className="w-10 text-center" />
|
||||
</div>
|
||||
<div className="px-1 flex justify-between">
|
||||
<div>
|
||||
<Heading className="text-center" size="sm">
|
||||
After
|
||||
</Heading>
|
||||
<div
|
||||
className="w-20 border border-gray-400/50 cursor-pointer hide-scroll shadow-md rounded-md"
|
||||
style={{ maxHeight: '17rem', overflowY: 'scroll' }}
|
||||
>
|
||||
{hoursInDays.map((time, idx) => (
|
||||
<div className={`${isSelected(time, after) ? isSelectedCss : ''}`} key={idx} id={`timeIndex-${idx}`}>
|
||||
<div
|
||||
className={`
|
||||
text-gray-300 w-full font-light border border-transparent hover:border hover:rounded-md hover:border-gray-600 text-center text-sm
|
||||
${randomGrayTone([Math.floor(idx / 24)])}`}
|
||||
onClick={() => handleTime(`${time}:00`, before)}
|
||||
>
|
||||
<span aria-label={`${idx}:00`}>{hoursInDays[idx]}:00</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Heading className="text-center" size="sm">
|
||||
Before
|
||||
</Heading>
|
||||
<div
|
||||
className="w-20 border border-gray-400/50 cursor-pointer hide-scroll shadow-md rounded-md"
|
||||
style={{ maxHeight: '17rem', overflowY: 'scroll' }}
|
||||
>
|
||||
{hoursInDays.map((time, idx) => (
|
||||
<div className={`${isSelected(time, before) ? isSelectedCss : ''}`} key={idx} id={`timeIndex-${idx}`}>
|
||||
<div
|
||||
className={`
|
||||
text-gray-300 w-full font-light border border-transparent hover:border hover:rounded-md hover:border-gray-600 text-center text-sm
|
||||
${randomGrayTone([Math.floor(idx / 24)])}`}
|
||||
onClick={() => handleTime(after, `${time}:00`)}
|
||||
>
|
||||
<span aria-label={`${idx}:00`}>{hoursInDays[idx]}:00</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowDropdown className="w-10 text-center" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimePicker;
|
||||
@@ -1,4 +0,0 @@
|
||||
export interface ScrollPermission {
|
||||
allowed: boolean;
|
||||
resetAfterSeeked: boolean;
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { Fragment, h } from 'preact';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { getTimelineEventBlocksFromTimelineEvents } from '../../utils/Timeline/timelineEventUtils';
|
||||
import type { ScrollPermission } from './ScrollPermission';
|
||||
import { TimelineBlocks } from './TimelineBlocks';
|
||||
import type { TimelineChangeEvent } from './TimelineChangeEvent';
|
||||
import { DisabledControls, TimelineControls } from './TimelineControls';
|
||||
import type { TimelineEvent } from './TimelineEvent';
|
||||
import type { TimelineEventBlock } from './TimelineEventBlock';
|
||||
|
||||
interface TimelineProps {
|
||||
events: TimelineEvent[];
|
||||
isPlaying: boolean;
|
||||
onChange: (event: TimelineChangeEvent) => void;
|
||||
onPlayPause?: (isPlaying: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Timeline({ events, isPlaying, onChange, onPlayPause }: TimelineProps) {
|
||||
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [timeline, setTimeline] = useState<TimelineEventBlock[]>([]);
|
||||
const [disabledControls, setDisabledControls] = useState<DisabledControls>({
|
||||
playPause: false,
|
||||
next: true,
|
||||
previous: false,
|
||||
});
|
||||
const [timelineOffset, setTimelineOffset] = useState<number>(0);
|
||||
const [markerTime, setMarkerTime] = useState<Date>(new Date());
|
||||
const [currentEvent, setCurrentEvent] = useState<TimelineEventBlock | undefined>(undefined);
|
||||
const [scrollTimeout, setScrollTimeout] = useState<NodeJS.Timeout>();
|
||||
const [scrollPermission, setScrollPermission] = useState<ScrollPermission>({
|
||||
allowed: true,
|
||||
resetAfterSeeked: false,
|
||||
});
|
||||
|
||||
const scrollToPosition = useCallback(
|
||||
(positionX: number) => {
|
||||
if (timelineContainerRef.current) {
|
||||
const permission: ScrollPermission = {
|
||||
allowed: true,
|
||||
resetAfterSeeked: true,
|
||||
};
|
||||
setScrollPermission(permission);
|
||||
timelineContainerRef.current.scroll({
|
||||
left: positionX,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
},
|
||||
[timelineContainerRef]
|
||||
);
|
||||
|
||||
const scrollToEvent = useCallback(
|
||||
(event: TimelineEventBlock, offset = 0) => {
|
||||
scrollToPosition(event.positionX + offset - timelineOffset);
|
||||
},
|
||||
[timelineOffset, scrollToPosition]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeline.length > 0 && currentEvent) {
|
||||
const currentIndex = currentEvent.index;
|
||||
if (currentIndex === 0) {
|
||||
setDisabledControls((previous) => ({
|
||||
...previous,
|
||||
next: false,
|
||||
previous: true,
|
||||
}));
|
||||
} else if (currentIndex === timeline.length - 1) {
|
||||
setDisabledControls((previous) => ({
|
||||
...previous,
|
||||
previous: false,
|
||||
next: true,
|
||||
}));
|
||||
} else {
|
||||
setDisabledControls((previous) => ({
|
||||
...previous,
|
||||
previous: false,
|
||||
next: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [timeline, currentEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (events && events.length > 0 && timelineOffset) {
|
||||
const timelineEvents = getTimelineEventBlocksFromTimelineEvents(events, timelineOffset);
|
||||
const lastEventIndex = timelineEvents.length - 1;
|
||||
const recentEvent = timelineEvents[lastEventIndex];
|
||||
|
||||
setTimeline(timelineEvents);
|
||||
setMarkerTime(recentEvent.startTime);
|
||||
setCurrentEvent(recentEvent);
|
||||
scrollToEvent(recentEvent);
|
||||
}
|
||||
}, [events, timelineOffset, scrollToEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
const timelineIsLoaded = timeline.length > 0;
|
||||
if (timelineIsLoaded) {
|
||||
const lastEvent = timeline[timeline.length - 1];
|
||||
scrollToEvent(lastEvent);
|
||||
}
|
||||
}, [timeline, scrollToEvent]);
|
||||
|
||||
const checkMarkerForEvent = (markerTime: Date) => {
|
||||
const adjustedMarkerTime = new Date(markerTime);
|
||||
adjustedMarkerTime.setSeconds(markerTime.getSeconds() + 1);
|
||||
|
||||
return [...timeline]
|
||||
.reverse()
|
||||
.find(
|
||||
(timelineEvent) =>
|
||||
timelineEvent.startTime.getTime() <= adjustedMarkerTime.getTime() &&
|
||||
timelineEvent.endTime.getTime() >= adjustedMarkerTime.getTime()
|
||||
);
|
||||
};
|
||||
|
||||
const seekCompleteHandler = (markerTime: Date) => {
|
||||
if (scrollPermission.allowed) {
|
||||
const markerEvent = checkMarkerForEvent(markerTime);
|
||||
setCurrentEvent(markerEvent);
|
||||
|
||||
onChange({
|
||||
markerTime,
|
||||
timelineEvent: markerEvent,
|
||||
seekComplete: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (scrollPermission.resetAfterSeeked) {
|
||||
setScrollPermission({
|
||||
allowed: true,
|
||||
resetAfterSeeked: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const waitForSeekComplete = (markerTime: Date) => {
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout);
|
||||
}
|
||||
setScrollTimeout(setTimeout(() => seekCompleteHandler(markerTime), 150));
|
||||
};
|
||||
|
||||
const onTimelineScrollHandler = () => {
|
||||
if (timelineContainerRef.current && timeline.length > 0) {
|
||||
const currentMarkerTime = getCurrentMarkerTime();
|
||||
setMarkerTime(currentMarkerTime);
|
||||
waitForSeekComplete(currentMarkerTime);
|
||||
onChange({
|
||||
timelineEvent: currentEvent,
|
||||
markerTime: currentMarkerTime,
|
||||
seekComplete: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentMarkerTime = useCallback(() => {
|
||||
if (timelineContainerRef.current && timeline.length > 0) {
|
||||
const scrollPosition = timelineContainerRef.current.scrollLeft;
|
||||
const firstTimelineEvent = timeline[0] as TimelineEventBlock;
|
||||
const firstTimelineEventStartTime = firstTimelineEvent.startTime.getTime();
|
||||
return new Date(firstTimelineEventStartTime + scrollPosition * 1000);
|
||||
}
|
||||
return new Date();
|
||||
}, [timeline, timelineContainerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timelineContainerRef) {
|
||||
const timelineContainerWidth = timelineContainerRef.current?.offsetWidth || 0;
|
||||
const offset = Math.round(timelineContainerWidth / 2);
|
||||
setTimelineOffset(offset);
|
||||
}
|
||||
}, [timelineContainerRef]);
|
||||
|
||||
const handleViewEvent = useCallback(
|
||||
(event: TimelineEventBlock) => {
|
||||
scrollToEvent(event);
|
||||
setMarkerTime(getCurrentMarkerTime());
|
||||
},
|
||||
[scrollToEvent, getCurrentMarkerTime]
|
||||
);
|
||||
|
||||
const onPlayPauseHandler = (isPlaying: boolean) => {
|
||||
onPlayPause && onPlayPause(isPlaying);
|
||||
};
|
||||
|
||||
const onPreviousHandler = () => {
|
||||
if (currentEvent) {
|
||||
const previousEvent = timeline[currentEvent.index - 1];
|
||||
setCurrentEvent(previousEvent);
|
||||
scrollToEvent(previousEvent);
|
||||
}
|
||||
};
|
||||
const onNextHandler = () => {
|
||||
if (currentEvent) {
|
||||
const nextEvent = timeline[currentEvent.index + 1];
|
||||
setCurrentEvent(nextEvent);
|
||||
scrollToEvent(nextEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const timelineBlocks = useMemo(() => {
|
||||
if (timelineOffset && timeline.length > 0) {
|
||||
return <TimelineBlocks timeline={timeline} firstBlockOffset={timelineOffset} onEventClick={handleViewEvent} />;
|
||||
}
|
||||
}, [timeline, timelineOffset, handleViewEvent]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className='flex-grow-1'>
|
||||
<div className='w-full text-center'>
|
||||
<span className='text-black dark:text-white'>
|
||||
{markerTime && <span>{markerTime.toLocaleTimeString()}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<div className='absolute left-0 top-0 h-full w-full text-center'>
|
||||
<div className='h-full text-center' style={{ margin: '0 auto' }}>
|
||||
<div
|
||||
className='z-20 h-full absolute'
|
||||
style={{
|
||||
left: 'calc(100% / 2)',
|
||||
borderRight: '2px solid rgba(252, 211, 77)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={timelineContainerRef} onScroll={onTimelineScrollHandler} className='overflow-x-auto hide-scroll'>
|
||||
{timelineBlocks}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TimelineControls
|
||||
disabled={disabledControls}
|
||||
isPlaying={isPlaying}
|
||||
onPrevious={onPreviousHandler}
|
||||
onPlayPause={onPlayPauseHandler}
|
||||
onNext={onNextHandler}
|
||||
className='mt-2'
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
import { getColorFromTimelineEvent } from '../../utils/tailwind/twTimelineEventUtil';
|
||||
import type { TimelineEventBlock } from './TimelineEventBlock';
|
||||
|
||||
interface TimelineBlockViewProps {
|
||||
block: TimelineEventBlock;
|
||||
onClick: (block: TimelineEventBlock) => void;
|
||||
}
|
||||
|
||||
export const TimelineBlockView = ({ block, onClick }: TimelineBlockViewProps) => {
|
||||
const onClickHandler = useCallback(() => onClick(block), [block, onClick]);
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
onClick={onClickHandler}
|
||||
className={`absolute z-10 rounded-full ${getColorFromTimelineEvent(block)} h-2`}
|
||||
style={{
|
||||
top: `${block.yOffset}px`,
|
||||
left: `${block.positionX}px`,
|
||||
width: `${block.width}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import { findLargestYOffsetInBlocks, getTimelineWidthFromBlocks } from '../../utils/Timeline/timelineEventUtils';
|
||||
import { convertRemToPixels } from '../../utils/windowUtils';
|
||||
import { TimelineBlockView } from './TimelineBlockView';
|
||||
import type { TimelineEventBlock } from './TimelineEventBlock';
|
||||
|
||||
interface TimelineBlocksProps {
|
||||
timeline: TimelineEventBlock[];
|
||||
firstBlockOffset: number;
|
||||
onEventClick: (block: TimelineEventBlock) => void;
|
||||
}
|
||||
|
||||
export const TimelineBlocks = ({ timeline, firstBlockOffset, onEventClick }: TimelineBlocksProps) => {
|
||||
const timelineEventBlocks = useMemo(() => {
|
||||
if (timeline.length > 0 && firstBlockOffset) {
|
||||
const largestYOffsetInBlocks = findLargestYOffsetInBlocks(timeline);
|
||||
const timelineContainerHeight = largestYOffsetInBlocks + convertRemToPixels(1);
|
||||
const timelineContainerWidth = getTimelineWidthFromBlocks(timeline, firstBlockOffset);
|
||||
const timelineBlockOffset = (timelineContainerHeight - largestYOffsetInBlocks) / 2;
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
height: `${timelineContainerHeight}px`,
|
||||
width: `${timelineContainerWidth}px`,
|
||||
background: "url('/images/marker.png')",
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: '30px',
|
||||
backgroundRepeat: 'repeat',
|
||||
}}
|
||||
>
|
||||
{timeline.map((block) => {
|
||||
const onClickHandler = (block: TimelineEventBlock) => onEventClick(block);
|
||||
const updatedBlock: TimelineEventBlock = {
|
||||
...block,
|
||||
yOffset: block.yOffset + timelineBlockOffset,
|
||||
};
|
||||
return <TimelineBlockView key={block.id} block={updatedBlock} onClick={onClickHandler} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div />;
|
||||
}, [timeline, onEventClick, firstBlockOffset]);
|
||||
|
||||
return timelineEventBlocks;
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { TimelineEvent } from './TimelineEvent';
|
||||
|
||||
export interface TimelineChangeEvent {
|
||||
timelineEvent?: TimelineEvent;
|
||||
markerTime: Date;
|
||||
seekComplete: boolean;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Next from '../../icons/Next';
|
||||
import Pause from '../../icons/Pause';
|
||||
import Play from '../../icons/Play';
|
||||
import Previous from '../../icons/Previous';
|
||||
import { BubbleButton } from '../BubbleButton';
|
||||
|
||||
export interface DisabledControls {
|
||||
playPause: boolean;
|
||||
next: boolean;
|
||||
previous: boolean;
|
||||
}
|
||||
|
||||
interface TimelineControlsProps {
|
||||
disabled: DisabledControls;
|
||||
className?: string;
|
||||
isPlaying: boolean;
|
||||
onPlayPause: (isPlaying: boolean) => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
}
|
||||
|
||||
export const TimelineControls = ({
|
||||
disabled,
|
||||
isPlaying,
|
||||
onPlayPause,
|
||||
onNext,
|
||||
onPrevious,
|
||||
className = '',
|
||||
}: TimelineControlsProps) => {
|
||||
const onPlayClickHandler = () => {
|
||||
onPlayPause(!isPlaying);
|
||||
};
|
||||
return (
|
||||
<div className={`flex space-x-2 self-center ${className}`}>
|
||||
<BubbleButton variant='secondary' onClick={onPrevious} disabled={disabled.previous}>
|
||||
<Previous />
|
||||
</BubbleButton>
|
||||
<BubbleButton onClick={onPlayClickHandler}>{!isPlaying ? <Play /> : <Pause />}</BubbleButton>
|
||||
<BubbleButton variant='secondary' onClick={onNext} disabled={disabled.next}>
|
||||
<Next />
|
||||
</BubbleButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
export interface TimelineEvent {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
id: string;
|
||||
label: 'car' | 'person' | 'dog';
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { TimelineEvent } from './TimelineEvent';
|
||||
|
||||
export interface TimelineEventBlock extends TimelineEvent {
|
||||
index: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
positionX: number;
|
||||
seconds: number;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
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,
|
||||
})}`;
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useRef, useEffect } from 'preact/hooks';
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { baseUrl } from '../api/baseUrl';
|
||||
import { useCallback, useEffect } from 'preact/hooks';
|
||||
|
||||
export default function WebRtcPlayer({ camera, width, height }) {
|
||||
const PeerConnection = useCallback(async (media) => {
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||
});
|
||||
|
||||
const localTracks = [];
|
||||
|
||||
if (/camera|microphone/.test(media)) {
|
||||
const tracks = await getMediaTracks('user', {
|
||||
video: media.indexOf('camera') >= 0,
|
||||
audio: media.indexOf('microphone') >= 0,
|
||||
});
|
||||
tracks.forEach((track) => {
|
||||
pc.addTransceiver(track, { direction: 'sendonly' });
|
||||
if (track.kind === 'video') localTracks.push(track);
|
||||
});
|
||||
}
|
||||
|
||||
if (media.indexOf('display') >= 0) {
|
||||
const tracks = await getMediaTracks('display', {
|
||||
video: true,
|
||||
audio: media.indexOf('speaker') >= 0,
|
||||
});
|
||||
tracks.forEach((track) => {
|
||||
pc.addTransceiver(track, { direction: 'sendonly' });
|
||||
if (track.kind === 'video') localTracks.push(track);
|
||||
});
|
||||
}
|
||||
|
||||
if (/video|audio/.test(media)) {
|
||||
const tracks = ['video', 'audio']
|
||||
.filter((kind) => media.indexOf(kind) >= 0)
|
||||
.map((kind) => pc.addTransceiver(kind, { direction: 'recvonly' }).receiver.track);
|
||||
localTracks.push(...tracks);
|
||||
}
|
||||
|
||||
document.getElementById('video').srcObject = new MediaStream(localTracks);
|
||||
|
||||
return pc;
|
||||
}, []);
|
||||
|
||||
async function getMediaTracks(media, constraints) {
|
||||
try {
|
||||
const stream =
|
||||
media === 'user'
|
||||
? await navigator.mediaDevices.getUserMedia(constraints)
|
||||
: await navigator.mediaDevices.getDisplayMedia(constraints);
|
||||
return stream.getTracks();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const connect = useCallback(async (ws, aPc) => {
|
||||
const pc = await aPc;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
pc.addEventListener('icecandidate', (ev) => {
|
||||
if (!ev.candidate) return;
|
||||
const msg = { type: 'webrtc/candidate', value: ev.candidate.candidate };
|
||||
ws.send(JSON.stringify(msg));
|
||||
});
|
||||
|
||||
pc.createOffer()
|
||||
.then((offer) => pc.setLocalDescription(offer))
|
||||
.then(() => {
|
||||
const msg = { type: 'webrtc/offer', value: pc.localDescription.sdp };
|
||||
ws.send(JSON.stringify(msg));
|
||||
});
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === 'webrtc/candidate') {
|
||||
pc.addIceCandidate({ candidate: msg.value, sdpMid: '0' });
|
||||
} else if (msg.type === 'webrtc/answer') {
|
||||
pc.setRemoteDescription({ type: 'answer', sdp: msg.value });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const url = `${baseUrl.replace(/^http/, 'ws')}live/webrtc/api/ws?src=${camera}`;
|
||||
const ws = new WebSocket(url);
|
||||
const aPc = PeerConnection('video+audio');
|
||||
connect(ws, aPc);
|
||||
|
||||
return async () => {
|
||||
(await aPc).close();
|
||||
}
|
||||
}, [camera, connect, PeerConnection]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<video id="video" autoplay playsinline controls muted width={width} height={height} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
web/src/components/Wrapper.tsx
Normal file
11
web/src/components/Wrapper.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type TWrapperProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const Wrapper = ({ children }: TWrapperProps) => {
|
||||
return <main className="flex flex-col max-h-screen">{children}</main>;
|
||||
};
|
||||
|
||||
export default Wrapper;
|
||||
@@ -1,47 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import ActivityIndicator from '../ActivityIndicator';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
describe('ActivityIndicator', () => {
|
||||
test('renders an ActivityIndicator with default size md', async () => {
|
||||
render(<ActivityIndicator />);
|
||||
expect(screen.getByLabelText('Loading…')).toMatchInlineSnapshot(`
|
||||
<div
|
||||
aria-label="Loading…"
|
||||
class="w-full flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="activityindicator ease-in rounded-full border-gray-200 text-blue-500 h-8 w-8 border-4 border-t-4"
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
test('renders an ActivityIndicator with size sm', async () => {
|
||||
render(<ActivityIndicator size="sm" />);
|
||||
expect(screen.getByLabelText('Loading…')).toMatchInlineSnapshot(`
|
||||
<div
|
||||
aria-label="Loading…"
|
||||
class="w-full flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="activityindicator ease-in rounded-full border-gray-200 text-blue-500 h-4 w-4 border-2 border-t-2"
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
test('renders an ActivityIndicator with size lg', async () => {
|
||||
render(<ActivityIndicator size="lg" />);
|
||||
expect(screen.getByLabelText('Loading…')).toMatchInlineSnapshot(`
|
||||
<div
|
||||
aria-label="Loading…"
|
||||
class="w-full flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="activityindicator ease-in rounded-full border-gray-200 text-blue-500 h-16 w-16 border-8 border-t-8"
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { DrawerProvider } from '../../context';
|
||||
import AppBar from '../AppBar';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
function Title() {
|
||||
return <div>I am the title</div>;
|
||||
}
|
||||
|
||||
describe('AppBar', () => {
|
||||
test('renders the title', async () => {
|
||||
render(
|
||||
<DrawerProvider>
|
||||
<AppBar title={Title} />
|
||||
</DrawerProvider>
|
||||
);
|
||||
expect(screen.getByText('I am the title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('overflow menu', () => {
|
||||
test('is not rendered if a ref is not provided', async () => {
|
||||
const handleOverflow = vi.fn();
|
||||
render(
|
||||
<DrawerProvider>
|
||||
<AppBar title={Title} onOverflowClick={handleOverflow} />
|
||||
</DrawerProvider>
|
||||
);
|
||||
expect(screen.queryByLabelText('More options')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('is not rendered if a click handler is not provided', async () => {
|
||||
function Wrapper() {
|
||||
const ref = useRef(null);
|
||||
return <AppBar title={Title} overflowRef={ref} />;
|
||||
}
|
||||
|
||||
render(
|
||||
<DrawerProvider>
|
||||
<Wrapper />
|
||||
</DrawerProvider>
|
||||
);
|
||||
expect(screen.queryByLabelText('More options')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('is rendered with click handler and ref', async () => {
|
||||
const handleOverflow = vi.fn();
|
||||
|
||||
function Wrapper() {
|
||||
const ref = useRef(null);
|
||||
return <AppBar title={Title} overflowRef={ref} onOverflowClick={handleOverflow} />;
|
||||
}
|
||||
|
||||
render(
|
||||
<DrawerProvider>
|
||||
<Wrapper />
|
||||
</DrawerProvider>
|
||||
);
|
||||
expect(screen.queryByLabelText('More options')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls the handler when clicked', async () => {
|
||||
const handleOverflow = vi.fn();
|
||||
|
||||
function Wrapper() {
|
||||
const ref = useRef(null);
|
||||
return <AppBar title={Title} overflowRef={ref} onOverflowClick={handleOverflow} />;
|
||||
}
|
||||
|
||||
render(
|
||||
<DrawerProvider>
|
||||
<Wrapper />
|
||||
</DrawerProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.queryByLabelText('More options'));
|
||||
|
||||
expect(handleOverflow).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrolling', () => {
|
||||
test('is visible initially', async () => {
|
||||
render(
|
||||
<DrawerProvider>
|
||||
<AppBar title={Title} />
|
||||
</DrawerProvider>
|
||||
);
|
||||
|
||||
const classes = screen.getByTestId('appbar').classList;
|
||||
|
||||
expect(classes.contains('translate-y-0')).toBe(true);
|
||||
expect(classes.contains('-translate-y-full')).toBe(false);
|
||||
});
|
||||
|
||||
test('hides when scrolled downward', async () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb());
|
||||
render(
|
||||
<DrawerProvider>
|
||||
<AppBar title={Title} />
|
||||
</DrawerProvider>
|
||||
);
|
||||
|
||||
window.scrollY = 300;
|
||||
await fireEvent.scroll(document, { target: { scrollY: 300 } });
|
||||
|
||||
const classes = screen.getByTestId('appbar').classList;
|
||||
|
||||
expect(classes.contains('translate-y-0')).toBe(false);
|
||||
expect(classes.contains('-translate-y-full')).toBe(true);
|
||||
});
|
||||
|
||||
test('reappears when scrolled upward', async () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb());
|
||||
render(
|
||||
<DrawerProvider>
|
||||
<AppBar title={Title} />
|
||||
</DrawerProvider>
|
||||
);
|
||||
|
||||
window.scrollY = 300;
|
||||
await fireEvent.scroll(document, { target: { scrollY: 300 } });
|
||||
window.scrollY = 280;
|
||||
await fireEvent.scroll(document, { target: { scrollY: 280 } });
|
||||
|
||||
const classes = screen.getByTestId('appbar').classList;
|
||||
|
||||
expect(classes.contains('translate-y-0')).toBe(true);
|
||||
expect(classes.contains('-translate-y-full')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import AutoUpdatingCameraImage from '../AutoUpdatingCameraImage';
|
||||
import { screen, render } from '@testing-library/preact';
|
||||
|
||||
let mockOnload;
|
||||
vi.mock('../CameraImage', () => {
|
||||
function CameraImage({ onload, searchParams }) {
|
||||
mockOnload = () => {
|
||||
onload();
|
||||
};
|
||||
return <div data-testid="camera-image">{searchParams}</div>;
|
||||
}
|
||||
return {
|
||||
__esModule: true,
|
||||
default: CameraImage,
|
||||
};
|
||||
});
|
||||
|
||||
describe('AutoUpdatingCameraImage', () => {
|
||||
let dateNowSpy;
|
||||
beforeEach(() => {
|
||||
dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(0);
|
||||
});
|
||||
|
||||
test('shows FPS by default', async () => {
|
||||
render(<AutoUpdatingCameraImage camera="tacos" />);
|
||||
expect(screen.queryByText('Displaying at 0fps')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not show FPS if turned off', async () => {
|
||||
render(<AutoUpdatingCameraImage camera="tacos" showFps={false} />);
|
||||
expect(screen.queryByText('Displaying at 0fps')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('on load, sets a new cache key to search params', async () => {
|
||||
dateNowSpy.mockReturnValueOnce(100).mockReturnValueOnce(200).mockReturnValueOnce(300);
|
||||
render(<AutoUpdatingCameraImage camera="front" searchParams="foo" />);
|
||||
mockOnload();
|
||||
await screen.findByText('cache=100&foo');
|
||||
expect(screen.getByText('cache=100&foo')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Button from '../Button';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('Button', () => {
|
||||
test('renders children', async () => {
|
||||
render(
|
||||
<Button>
|
||||
<div>hello</div>
|
||||
<div>hi</div>
|
||||
</Button>
|
||||
);
|
||||
expect(screen.queryByText('hello')).toBeInTheDocument();
|
||||
expect(screen.queryByText('hi')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('includes focus, active, and hover classes when enabled', async () => {
|
||||
render(<Button>click me</Button>);
|
||||
|
||||
const classList = screen.queryByRole('button').classList;
|
||||
expect(classList.contains('focus:outline-none')).toBe(true);
|
||||
expect(classList.contains('focus:ring-2')).toBe(true);
|
||||
expect(classList.contains('hover:shadow-md')).toBe(true);
|
||||
expect(classList.contains('active:bg-blue-600')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not focus, active, and hover classes when enabled', async () => {
|
||||
render(<Button disabled>click me</Button>);
|
||||
|
||||
const classList = screen.queryByRole('button').classList;
|
||||
expect(classList.contains('focus:outline-none')).toBe(false);
|
||||
expect(classList.contains('focus:ring-2')).toBe(false);
|
||||
expect(classList.contains('hover:shadow-md')).toBe(false);
|
||||
expect(classList.contains('active:bg-blue-600')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import * as Hooks from '../../hooks';
|
||||
import CameraImage from '../CameraImage';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('CameraImage', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Hooks, 'useResizeObserver').mockImplementation(() => [{ width: 0 }]);
|
||||
});
|
||||
|
||||
test('renders an activity indicator while loading', async () => {
|
||||
render(<CameraImage camera="front" />);
|
||||
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Card from '../Card';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('Card', () => {
|
||||
test('renders a Card with media', async () => {
|
||||
render(<Card media={<img src="tacos.jpg" alt="tacos" />} />);
|
||||
expect(screen.queryByAltText('tacos')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders a Card with a link around media', async () => {
|
||||
render(<Card href="/tacos" media={<img src="tacos.jpg" alt="tacos" />} />);
|
||||
expect(screen.queryByAltText('tacos')).toBeInTheDocument();
|
||||
expect(screen.getByAltText('tacos').closest('a')).toHaveAttribute('href', '/tacos');
|
||||
});
|
||||
|
||||
test('renders a Card with a header', async () => {
|
||||
render(<Card header="Tacos!" />);
|
||||
expect(screen.queryByText('Tacos!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders a Card with a linked header', async () => {
|
||||
render(<Card href="/tacos" header="Tacos!" />);
|
||||
expect(screen.queryByText('Tacos!')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Tacos!').closest('a')).toHaveAttribute('href', '/tacos');
|
||||
});
|
||||
|
||||
test('renders content', async () => {
|
||||
const content = <div data-testid="content">hello</div>;
|
||||
render(<Card content={content} />);
|
||||
expect(screen.queryByTestId('content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders buttons', async () => {
|
||||
const buttons = [
|
||||
{ name: 'Tacos', href: '/tacos' },
|
||||
{ name: 'Burritos', href: '/burritos' },
|
||||
];
|
||||
render(<Card buttons={buttons} />);
|
||||
expect(screen.queryByText('Tacos')).toHaveAttribute('role', 'button');
|
||||
expect(screen.queryByText('Tacos')).toHaveAttribute('href', '/tacos');
|
||||
|
||||
expect(screen.queryByText('Burritos')).toHaveAttribute('role', 'button');
|
||||
expect(screen.queryByText('Burritos')).toHaveAttribute('href', '/burritos');
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Dialog from '../Dialog';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('Dialog', () => {
|
||||
let portal;
|
||||
|
||||
beforeAll(() => {
|
||||
portal = document.createElement('div');
|
||||
portal.id = 'dialogs';
|
||||
document.body.appendChild(portal);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
document.body.removeChild(portal);
|
||||
});
|
||||
|
||||
test('renders to a portal', async () => {
|
||||
render(<Dialog>Sample</Dialog>);
|
||||
expect(screen.getByText('Sample')).toBeInTheDocument();
|
||||
expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Heading from '../Heading';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('Heading', () => {
|
||||
test('renders content with default size', async () => {
|
||||
render(<Heading>Hello</Heading>);
|
||||
expect(screen.queryByText('Hello')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Hello').classList.contains('text-2xl')).toBe(true);
|
||||
});
|
||||
|
||||
test('renders with custom size', async () => {
|
||||
render(<Heading size="lg">Hello</Heading>);
|
||||
expect(screen.queryByText('Hello')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Hello').classList.contains('text-2xl')).toBe(false);
|
||||
expect(screen.queryByText('Hello').classList.contains('text-lg')).toBe(true);
|
||||
});
|
||||
|
||||
test('renders with custom className', async () => {
|
||||
render(<Heading className="tacos">Hello</Heading>);
|
||||
expect(screen.queryByText('Hello')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Hello').classList.contains('text-2xl')).toBe(true);
|
||||
expect(screen.queryByText('Hello').classList.contains('tacos')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Link from '../Link';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
describe.skip('Link', () => {
|
||||
test('renders a link', async () => {
|
||||
render(<Link href="/tacos">Hello</Link>);
|
||||
expect(screen.queryByText('Hello')).toMatchInlineSnapshot(`
|
||||
<a
|
||||
class="text-blue-500 hover:underline"
|
||||
href="/tacos"
|
||||
>
|
||||
Hello
|
||||
</a>
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Menu, { MenuItem } from '../Menu';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
describe('Menu', () => {
|
||||
test('renders a dialog', async () => {
|
||||
function Test() {
|
||||
const relativeRef = useRef();
|
||||
return (
|
||||
<div>
|
||||
<div ref={relativeRef} />
|
||||
<Menu relativeTo={relativeRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Test />);
|
||||
expect(screen.queryByRole('listbox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MenuItem', () => {
|
||||
test('renders a menu item', async () => {
|
||||
render(<MenuItem label="Tacos" />);
|
||||
expect(screen.queryByRole('option')).toHaveTextContent('Tacos');
|
||||
});
|
||||
|
||||
test('calls onSelect when clicked', async () => {
|
||||
const handleSelect = vi.fn();
|
||||
render(<MenuItem label="Tacos" onSelect={handleSelect} value="tacos-value" />);
|
||||
fireEvent.click(screen.queryByRole('option'));
|
||||
expect(handleSelect).toHaveBeenCalledWith('tacos-value', 'Tacos');
|
||||
});
|
||||
|
||||
test('renders and icon when passed', async () => {
|
||||
function Icon() {
|
||||
return <div data-testid="icon" />;
|
||||
}
|
||||
render(<MenuItem icon={Icon} label="Tacos" />);
|
||||
expect(screen.queryByTestId('icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('applies different styles when focused', async () => {
|
||||
const { rerender } = render(<MenuItem label="Tacos" />);
|
||||
const classes = Array.from(screen.queryByRole('option').classList);
|
||||
rerender(<MenuItem label="Tacos" focus />);
|
||||
const focusClasses = Array.from(screen.queryByRole('option').classList);
|
||||
|
||||
expect(focusClasses.length).toBeGreaterThan(classes.length);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import * as Context from '../../context';
|
||||
import NavigationDrawer, { Destination } from '../NavigationDrawer';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('NavigationDrawer', () => {
|
||||
let useDrawer, setShowDrawer;
|
||||
|
||||
beforeEach(() => {
|
||||
setShowDrawer = vi.fn();
|
||||
useDrawer = vi.spyOn(Context, 'useDrawer').mockImplementation(() => ({ showDrawer: true, setShowDrawer }));
|
||||
});
|
||||
|
||||
test('renders a navigation drawer', async () => {
|
||||
render(
|
||||
<NavigationDrawer>
|
||||
<div data-testid="children">Hello</div>
|
||||
</NavigationDrawer>
|
||||
);
|
||||
expect(screen.queryByTestId('children')).toHaveTextContent('Hello');
|
||||
expect(screen.queryByTestId('drawer').classList.contains('translate-x-full')).toBe(false);
|
||||
expect(screen.queryByTestId('drawer').classList.contains('translate-x-0')).toBe(true);
|
||||
});
|
||||
|
||||
test('is dismissed when the scrim is clicked', async () => {
|
||||
useDrawer
|
||||
.mockReturnValueOnce({ showDrawer: true, setShowDrawer })
|
||||
.mockReturnValueOnce({ showDrawer: false, setShowDrawer });
|
||||
render(<NavigationDrawer />);
|
||||
fireEvent.click(screen.queryByTestId('scrim'));
|
||||
expect(setShowDrawer).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('is not visible when not set to show', async () => {
|
||||
useDrawer.mockReturnValue({ showDrawer: false, setShowDrawer });
|
||||
render(<NavigationDrawer />);
|
||||
expect(screen.queryByTestId('scrim')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('drawer').classList.contains('-translate-x-full')).toBe(true);
|
||||
expect(screen.queryByTestId('drawer').classList.contains('translate-x-0')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Destination', () => {
|
||||
let setShowDrawer;
|
||||
|
||||
beforeEach(() => {
|
||||
setShowDrawer = vi.fn();
|
||||
vi.spyOn(Context, 'useDrawer').mockImplementation(() => ({ showDrawer: true, setShowDrawer }));
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
test.skip('dismisses the drawer moments after being clicked', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<NavigationDrawer>
|
||||
<Destination href="/tacos" text="Tacos" />
|
||||
</NavigationDrawer>
|
||||
);
|
||||
fireEvent.click(screen.queryByText('Tacos'));
|
||||
vi.runAllTimers();
|
||||
expect(setShowDrawer).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Prompt from '../Prompt';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('Prompt', () => {
|
||||
let portal;
|
||||
|
||||
beforeAll(() => {
|
||||
portal = document.createElement('div');
|
||||
portal.id = 'dialogs';
|
||||
document.body.appendChild(portal);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
document.body.removeChild(portal);
|
||||
});
|
||||
|
||||
test('renders to a portal', async () => {
|
||||
render(<Prompt title="Tacos" text="This is the dialog" />);
|
||||
expect(screen.getByText('Tacos')).toBeInTheDocument();
|
||||
expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('renders action buttons', async () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<Prompt
|
||||
actions={[
|
||||
{ color: 'red', text: 'Delete' },
|
||||
{ text: 'Okay', onClick: handleClick },
|
||||
]}
|
||||
title="Tacos"
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Okay' }));
|
||||
expect(handleClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { h, createRef } from 'preact';
|
||||
import RelativeModal from '../RelativeModal';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('RelativeModal', () => {
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
test.skip('keeps tab focus', async () => {
|
||||
const ref = createRef();
|
||||
render(
|
||||
<div>
|
||||
<label for="outside-input">outside</label>
|
||||
<input id="outside-input" tabindex="0" />
|
||||
<div ref={ref} />
|
||||
<RelativeModal relativeTo={ref}>
|
||||
<input data-testid="modal-input-0" tabindex="0" />
|
||||
<input data-testid="modal-input-1" tabindex="0" />
|
||||
</RelativeModal>
|
||||
</div>
|
||||
);
|
||||
|
||||
const inputs = screen.queryAllByTestId(/modal-input/);
|
||||
expect(document.activeElement).toBe(inputs[0]);
|
||||
userEvent.tab();
|
||||
expect(document.activeElement).toBe(inputs[1]);
|
||||
userEvent.tab();
|
||||
expect(document.activeElement).toBe(inputs[0]);
|
||||
});
|
||||
|
||||
test('pressing ESC dismisses', async () => {
|
||||
const handleDismiss = vi.fn();
|
||||
const ref = createRef();
|
||||
render(
|
||||
<div>
|
||||
<div ref={ref} />
|
||||
<RelativeModal onDismiss={handleDismiss} relativeTo={ref}>
|
||||
<input data-testid="modal-input-0" tabindex="0" />
|
||||
</RelativeModal>
|
||||
</div>
|
||||
);
|
||||
|
||||
const dialog = screen.queryByRole('dialog');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(document.activeElement, { key: 'Escape', code: 'Escape' });
|
||||
expect(handleDismiss).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clicking a scrim dismisses', async () => {
|
||||
const handleDismiss = vi.fn();
|
||||
const ref = createRef();
|
||||
render(
|
||||
<div>
|
||||
<div ref={ref} />
|
||||
<RelativeModal onDismiss={handleDismiss} relativeTo={ref}>
|
||||
<input data-testid="modal-input-0" tabindex="0" />
|
||||
</RelativeModal>
|
||||
</div>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.queryByTestId('scrim'));
|
||||
expect(handleDismiss).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Select from '../Select';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('Select', () => {
|
||||
test('on focus, shows a menu', async () => {
|
||||
const handleChange = vi.fn();
|
||||
render(
|
||||
<Select
|
||||
label="Tacos"
|
||||
type="dropdown"
|
||||
onChange={handleChange}
|
||||
options={['all', 'tacos', 'burritos']}
|
||||
paramName={['dinner']}
|
||||
selected=""
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('textbox'));
|
||||
expect(screen.queryByRole('listbox')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'all' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'tacos' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'burritos' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.queryByRole('option', { name: 'tacos' }));
|
||||
expect(handleChange).toHaveBeenCalledWith({ dinner: 'tacos' });
|
||||
});
|
||||
|
||||
test('allows keyboard navigation', async () => {
|
||||
const handleChange = vi.fn();
|
||||
render(
|
||||
<Select
|
||||
label="Tacos"
|
||||
type="dropdown"
|
||||
onChange={handleChange}
|
||||
options={['tacos', 'burritos']}
|
||||
paramName={['dinner']}
|
||||
selected=""
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
expect(screen.queryByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown', code: 'ArrowDown' });
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
expect(handleChange).toHaveBeenCalledWith({ dinner: 'burritos' });
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Switch from '../Switch';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('Switch', () => {
|
||||
test('renders a hidden checkbox', async () => {
|
||||
render(
|
||||
<div>
|
||||
<Switch id="unchecked-switch" />
|
||||
<Switch id="checked-switch" checked={true} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const unchecked = screen.queryByTestId('unchecked-switch-input');
|
||||
expect(unchecked).toHaveAttribute('type', 'checkbox');
|
||||
expect(unchecked).not.toBeChecked();
|
||||
|
||||
const checked = screen.queryByTestId('checked-switch-input');
|
||||
expect(checked).toHaveAttribute('type', 'checkbox');
|
||||
expect(checked).toBeChecked();
|
||||
});
|
||||
|
||||
test('calls onChange callback when checked/unchecked', async () => {
|
||||
const handleChange = vi.fn();
|
||||
const { rerender } = render(<Switch id="check" onChange={handleChange} />);
|
||||
fireEvent.change(screen.queryByTestId('check-input'), { checked: true });
|
||||
expect(handleChange).toHaveBeenCalledWith('check', true);
|
||||
|
||||
rerender(<Switch id="check" onChange={handleChange} checked />);
|
||||
fireEvent.change(screen.queryByTestId('check-input'), { checked: false });
|
||||
expect(handleChange).toHaveBeenCalledWith('check', false);
|
||||
});
|
||||
|
||||
test('renders a label before', async () => {
|
||||
render(<Switch id="check" label="This is the label" />);
|
||||
const items = screen.queryAllByTestId(/check-.+/);
|
||||
expect(items[0]).toHaveTextContent('This is the label');
|
||||
expect(items[1]).toHaveAttribute('data-testid', 'check-input');
|
||||
});
|
||||
|
||||
test('renders a label after', async () => {
|
||||
render(<Switch id="check" label="This is the label" labelPosition="after" />);
|
||||
const items = screen.queryAllByTestId(/check-.+/);
|
||||
expect(items[0]).toHaveAttribute('data-testid', 'check-input');
|
||||
expect(items[1]).toHaveTextContent('This is the label');
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import TextField from '../TextField';
|
||||
import { render, screen, fireEvent } from '@testing-library/preact';
|
||||
|
||||
describe('TextField', () => {
|
||||
test('can render a leading icon', async () => {
|
||||
render(<TextField label="Tacos" leadingIcon={FakeLeadingIcon} />);
|
||||
expect(screen.getByTestId('icon-leading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('can render a trailing icon', async () => {
|
||||
render(<TextField label="Tacos" trailingIcon={FakeTrailingIcon} />);
|
||||
expect(screen.getByTestId('icon-trailing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('can renders icons in correct positions', async () => {
|
||||
render(<TextField label="Tacos" leadingIcon={FakeLeadingIcon} trailingIcon={FakeTrailingIcon} />);
|
||||
const icons = screen.queryAllByTestId(/icon-.+/);
|
||||
expect(icons[0]).toHaveAttribute('data-testid', 'icon-leading');
|
||||
expect(icons[1]).toHaveAttribute('data-testid', 'icon-trailing');
|
||||
});
|
||||
|
||||
test('onChange updates the value', async () => {
|
||||
const handleChangeText = vi.fn();
|
||||
render(<TextField label="Tacos" onChangeText={handleChangeText} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.input(input, { target: { value: 'i like tacos' } });
|
||||
expect(handleChangeText).toHaveBeenCalledWith('i like tacos');
|
||||
expect(input.value).toEqual('i like tacos');
|
||||
});
|
||||
|
||||
test('still updates the value if an original value was given', async () => {
|
||||
render(<TextField label="Tacos" value="no, burritos" />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.input(input, { target: { value: 'i like tacos' } });
|
||||
expect(input.value).toEqual('i like tacos');
|
||||
});
|
||||
|
||||
test('changes the value if the prop value changes', async () => {
|
||||
const { rerender } = render(<TextField key="test" label="Tacos" value="no, burritos" />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.input(input, { target: { value: 'i like tacos' } });
|
||||
expect(input.value).toEqual('i like tacos');
|
||||
|
||||
rerender(<TextField key="test" label="Tacos" value="no, really, burritos" />);
|
||||
expect(input.value).toEqual('no, really, burritos');
|
||||
});
|
||||
});
|
||||
|
||||
function FakeLeadingIcon() {
|
||||
return <div data-testid="icon-leading" />;
|
||||
}
|
||||
|
||||
function FakeTrailingIcon() {
|
||||
return <div data-testid="icon-trailing" />;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { h, createRef } from 'preact';
|
||||
import Tooltip from '../Tooltip';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('Tooltip', () => {
|
||||
test('renders in a relative position', async () => {
|
||||
vi
|
||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||
// relativeTo
|
||||
.mockReturnValueOnce({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 50,
|
||||
height: 10,
|
||||
})
|
||||
// tooltip
|
||||
.mockReturnValueOnce({ width: 40, height: 15 });
|
||||
|
||||
const ref = createRef();
|
||||
render(
|
||||
<div>
|
||||
<div ref={ref} />
|
||||
<Tooltip relativeTo={ref} text="hello" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
const style = window.getComputedStyle(tooltip);
|
||||
expect(style.left).toEqual('103px');
|
||||
expect(style.top).toEqual('68.5px');
|
||||
});
|
||||
|
||||
test('if too far right, renders to the left', async () => {
|
||||
window.innerWidth = 1024;
|
||||
vi
|
||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||
// relativeTo
|
||||
.mockReturnValueOnce({
|
||||
x: 1000,
|
||||
y: 100,
|
||||
width: 24,
|
||||
height: 10,
|
||||
})
|
||||
// tooltip
|
||||
.mockReturnValueOnce({ width: 50, height: 15 });
|
||||
|
||||
const ref = createRef();
|
||||
render(
|
||||
<div>
|
||||
<div ref={ref} />
|
||||
<Tooltip relativeTo={ref} text="hello" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
const style = window.getComputedStyle(tooltip);
|
||||
expect(style.left).toEqual('937px');
|
||||
expect(style.top).toEqual('97px');
|
||||
});
|
||||
|
||||
test('if too far left, renders to the right', async () => {
|
||||
vi
|
||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||
// relativeTo
|
||||
.mockReturnValueOnce({
|
||||
x: 0,
|
||||
y: 100,
|
||||
width: 24,
|
||||
height: 10,
|
||||
})
|
||||
// tooltip
|
||||
.mockReturnValueOnce({ width: 50, height: 15 });
|
||||
|
||||
const ref = createRef();
|
||||
render(
|
||||
<div>
|
||||
<div ref={ref} />
|
||||
<Tooltip relativeTo={ref} text="hello" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
const style = window.getComputedStyle(tooltip);
|
||||
expect(style.left).toEqual('32px');
|
||||
expect(style.top).toEqual('97px');
|
||||
});
|
||||
|
||||
test('if too close to top, renders to the bottom', async () => {
|
||||
window.scrollY = 90;
|
||||
vi
|
||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||
// relativeTo
|
||||
.mockReturnValueOnce({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 24,
|
||||
height: 10,
|
||||
})
|
||||
// tooltip
|
||||
.mockReturnValueOnce({ width: 50, height: 15 });
|
||||
|
||||
const ref = createRef();
|
||||
render(
|
||||
<div>
|
||||
<div ref={ref} />
|
||||
<Tooltip relativeTo={ref} text="hello" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
const style = window.getComputedStyle(tooltip);
|
||||
expect(style.left).toEqual('84px');
|
||||
expect(style.top).toEqual('158.5px');
|
||||
});
|
||||
});
|
||||
47
web/src/components/camera/AutoUpdatingCameraImage.tsx
Normal file
47
web/src/components/camera/AutoUpdatingCameraImage.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import CameraImage from "./CameraImage";
|
||||
|
||||
type AutoUpdatingCameraImageProps = {
|
||||
camera: string;
|
||||
searchParams?: {};
|
||||
showFps?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const MIN_LOAD_TIMEOUT_MS = 200;
|
||||
|
||||
export default function AutoUpdatingCameraImage({
|
||||
camera,
|
||||
searchParams = "",
|
||||
showFps = true,
|
||||
className,
|
||||
}: AutoUpdatingCameraImageProps) {
|
||||
const [key, setKey] = useState(Date.now());
|
||||
const [fps, setFps] = useState<string>("0");
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
const loadTime = Date.now() - key;
|
||||
|
||||
if (showFps) {
|
||||
setFps((1000 / Math.max(loadTime, MIN_LOAD_TIMEOUT_MS)).toFixed(1));
|
||||
}
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
setKey(Date.now());
|
||||
},
|
||||
loadTime > MIN_LOAD_TIMEOUT_MS ? 1 : MIN_LOAD_TIMEOUT_MS
|
||||
);
|
||||
}, [key, setFps]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<CameraImage
|
||||
camera={camera}
|
||||
onload={handleLoad}
|
||||
searchParams={`cache=${key}&${searchParams}`}
|
||||
/>
|
||||
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
web/src/components/camera/CameraImage.tsx
Normal file
116
web/src/components/camera/CameraImage.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useApiHost } from "@/api";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import ActivityIndicator from "../ui/activity-indicator";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
|
||||
type CameraImageProps = {
|
||||
camera: string;
|
||||
onload?: (event: Event) => void;
|
||||
searchParams?: {};
|
||||
stretch?: boolean; // stretch to fit width
|
||||
fitAspect?: number; // shrink to fit height
|
||||
};
|
||||
|
||||
export default function CameraImage({
|
||||
camera,
|
||||
onload,
|
||||
searchParams = "",
|
||||
stretch = false,
|
||||
fitAspect,
|
||||
}: CameraImageProps) {
|
||||
const { data: config } = useSWR("config");
|
||||
const apiHost = useApiHost();
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [{ width: containerWidth, height: containerHeight }] =
|
||||
useResizeObserver(containerRef);
|
||||
|
||||
// Add scrollbar width (when visible) to the available observer width to eliminate screen juddering.
|
||||
// https://github.com/blakeblackshear/frigate/issues/1657
|
||||
let scrollBarWidth = 0;
|
||||
if (window.innerWidth && document.body.offsetWidth) {
|
||||
scrollBarWidth = window.innerWidth - document.body.offsetWidth;
|
||||
}
|
||||
const availableWidth = scrollBarWidth
|
||||
? containerWidth + scrollBarWidth
|
||||
: containerWidth;
|
||||
|
||||
const { name } = config ? config.cameras[camera] : "";
|
||||
const enabled = config ? config.cameras[camera].enabled : "True";
|
||||
const { width, height } = config
|
||||
? config.cameras[camera].detect
|
||||
: { width: 1, height: 1 };
|
||||
const aspectRatio = width / height;
|
||||
|
||||
const scaledHeight = useMemo(() => {
|
||||
const scaledHeight =
|
||||
aspectRatio < (fitAspect ?? 0)
|
||||
? Math.floor(containerHeight)
|
||||
: Math.floor(availableWidth / aspectRatio);
|
||||
const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height);
|
||||
|
||||
if (finalHeight > 0) {
|
||||
return finalHeight;
|
||||
}
|
||||
|
||||
return 100;
|
||||
}, [availableWidth, aspectRatio, height, stretch]);
|
||||
const scaledWidth = useMemo(
|
||||
() => Math.ceil(scaledHeight * aspectRatio - scrollBarWidth),
|
||||
[scaledHeight, aspectRatio, scrollBarWidth]
|
||||
);
|
||||
|
||||
const img = useMemo(() => new Image(), []);
|
||||
img.onload = useCallback(
|
||||
(event: Event) => {
|
||||
setHasLoaded(true);
|
||||
if (canvasRef.current) {
|
||||
const ctx = canvasRef.current.getContext("2d");
|
||||
ctx?.drawImage(img, 0, 0, scaledWidth, scaledHeight);
|
||||
}
|
||||
onload && onload(event);
|
||||
},
|
||||
[img, scaledHeight, scaledWidth, setHasLoaded, onload, canvasRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config || scaledHeight === 0 || !canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
img.src = `${apiHost}api/${name}/latest.jpg?h=${scaledHeight}${
|
||||
searchParams ? `&${searchParams}` : ""
|
||||
}`;
|
||||
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-full ${
|
||||
fitAspect && aspectRatio < fitAspect ? "h-full flex justify-center" : ""
|
||||
}`}
|
||||
ref={containerRef}
|
||||
>
|
||||
{enabled ? (
|
||||
<canvas
|
||||
data-testid="cameraimage-canvas"
|
||||
height={scaledHeight}
|
||||
ref={canvasRef}
|
||||
width={scaledWidth}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center pt-6">
|
||||
Camera is disabled in config, no stream or snapshot available!
|
||||
</div>
|
||||
)}
|
||||
{!hasLoaded && enabled ? (
|
||||
<div
|
||||
className="absolute inset-0 flex justify-center"
|
||||
style={{ height: `${scaledHeight}px` }}
|
||||
>
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
web/src/components/card/ExportCard.tsx
Normal file
50
web/src/components/card/ExportCard.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import ActivityIndicator from "../ui/activity-indicator";
|
||||
import { Card } from "../ui/card";
|
||||
import { LuPlay, LuTrash } from "react-icons/lu";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
type ExportProps = {
|
||||
file: {
|
||||
name: string;
|
||||
};
|
||||
onSelect: (file: string) => void;
|
||||
onDelete: (file: string) => void;
|
||||
};
|
||||
|
||||
export default function ExportCard({ file, onSelect, onDelete }: ExportProps) {
|
||||
return (
|
||||
<Card className="my-4 p-4 bg-secondary flex justify-start text-center items-center">
|
||||
{file.name.startsWith("in_progress") ? (
|
||||
<>
|
||||
<div className="p-2">
|
||||
<ActivityIndicator size={16} />
|
||||
</div>
|
||||
<div className="px-2">
|
||||
{file.name.substring(12, file.name.length - 4)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => onSelect(file.name)}>
|
||||
<LuPlay className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
<a
|
||||
className="text-blue-500 hover:underline overflow-hidden"
|
||||
href={`${baseUrl}exports/${file.name}`}
|
||||
download
|
||||
>
|
||||
{file.name.substring(0, file.name.length - 4)}
|
||||
</a>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
variant="secondary"
|
||||
onClick={() => onDelete(file.name)}
|
||||
>
|
||||
<LuTrash className="h-4 w-4" stroke="#f87171" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
74
web/src/components/card/HistoryCard.tsx
Normal file
74
web/src/components/card/HistoryCard.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import useSWR from "swr";
|
||||
import PreviewThumbnailPlayer from "../player/PreviewThumbnailPlayer";
|
||||
import { Card } from "../ui/card";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import ActivityIndicator from "../ui/activity-indicator";
|
||||
import { LuClock } from "react-icons/lu";
|
||||
import { HiOutlineVideoCamera } from "react-icons/hi";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import {
|
||||
getTimelineIcon,
|
||||
getTimelineItemDescription,
|
||||
} from "@/utils/timelineUtil";
|
||||
|
||||
type HistoryCardProps = {
|
||||
timeline: Card;
|
||||
relevantPreview?: Preview;
|
||||
shouldAutoPlay: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export default function HistoryCard({
|
||||
relevantPreview,
|
||||
timeline,
|
||||
shouldAutoPlay,
|
||||
onClick,
|
||||
}: HistoryCardProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
if (!config) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer my-2 xs:mr-2 w-full xs:w-[48%] sm:w-[284px]"
|
||||
onClick={onClick}
|
||||
>
|
||||
<PreviewThumbnailPlayer
|
||||
camera={timeline.camera}
|
||||
relevantPreview={relevantPreview}
|
||||
startTs={Object.values(timeline.entries)[0].timestamp}
|
||||
eventId={Object.values(timeline.entries)[0].source_id}
|
||||
shouldAutoPlay={shouldAutoPlay}
|
||||
/>
|
||||
<div className="p-2">
|
||||
<div className="text-sm flex">
|
||||
<LuClock className="h-5 w-5 mr-2 inline" />
|
||||
{formatUnixTimestampToDateTime(timeline.time, {
|
||||
strftime_fmt:
|
||||
config.ui.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S",
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
})}
|
||||
</div>
|
||||
<div className="capitalize text-sm flex align-center mt-1">
|
||||
<HiOutlineVideoCamera className="h-5 w-5 mr-2 inline" />
|
||||
{timeline.camera.replaceAll("_", " ")}
|
||||
</div>
|
||||
<div className="my-2 text-sm font-medium">Activity:</div>
|
||||
{Object.entries(timeline.entries).map(([_, entry]) => {
|
||||
return (
|
||||
<div
|
||||
key={entry.timestamp}
|
||||
className="flex text-xs capitalize my-1 items-center"
|
||||
>
|
||||
{getTimelineIcon(entry)}
|
||||
{getTimelineItemDescription(entry)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
83
web/src/components/card/MiniEventCard.tsx
Normal file
83
web/src/components/card/MiniEventCard.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useApiHost } from "@/api";
|
||||
import { Card } from "../ui/card";
|
||||
import { Event as FrigateEvent } from "@/types/event";
|
||||
import { LuClock, LuStar } from "react-icons/lu";
|
||||
import { useCallback } from "react";
|
||||
import TimeAgo from "../dynamic/TimeAgo";
|
||||
import { HiOutlineVideoCamera } from "react-icons/hi";
|
||||
import { MdOutlineLocationOn } from "react-icons/md";
|
||||
import axios from "axios";
|
||||
|
||||
type MiniEventCardProps = {
|
||||
event: FrigateEvent;
|
||||
onUpdate?: () => void;
|
||||
};
|
||||
|
||||
export default function MiniEventCard({ event, onUpdate }: MiniEventCardProps) {
|
||||
const baseUrl = useApiHost();
|
||||
const onSave = useCallback(
|
||||
async (e: Event) => {
|
||||
e.stopPropagation();
|
||||
let response;
|
||||
if (!event.retain_indefinitely) {
|
||||
response = await axios.post(`events/${event.id}/retain`);
|
||||
} else {
|
||||
response = await axios.delete(`events/${event.id}/retain`);
|
||||
}
|
||||
if (response.status === 200 && onUpdate) {
|
||||
onUpdate();
|
||||
}
|
||||
},
|
||||
[event]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="mr-2 min-w-[260px] max-w-[320px]">
|
||||
<div className="flex">
|
||||
<div
|
||||
className="relative rounded-l min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center"
|
||||
style={{
|
||||
backgroundImage: `url(${baseUrl}api/events/${event.id}/thumbnail.jpg)`,
|
||||
}}
|
||||
>
|
||||
<LuStar
|
||||
className="h-6 w-6 text-yellow-300 absolute top-1 right-1 cursor-pointer"
|
||||
onClick={(e: Event) => onSave(e)}
|
||||
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="p-1 flex flex-col justify-between">
|
||||
<div className="capitalize text-lg font-bold">
|
||||
{event.label.replaceAll("_", " ")}
|
||||
{event.sub_label
|
||||
? `: ${event.sub_label.replaceAll("_", " ")}`
|
||||
: null}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm flex">
|
||||
<LuClock className="h-4 w-4 mr-2 inline" />
|
||||
<div className="hidden sm:inline">
|
||||
<TimeAgo time={event.start_time * 1000} dense />
|
||||
</div>
|
||||
</div>
|
||||
<div className="capitalize text-sm flex align-center mt-1 whitespace-nowrap">
|
||||
<HiOutlineVideoCamera className="h-4 w-4 mr-2 inline" />
|
||||
{event.camera.replaceAll("_", " ")}
|
||||
</div>
|
||||
{event.zones.length ? (
|
||||
<div className="capitalize text-sm flex align-center">
|
||||
<MdOutlineLocationOn className="w-4 h-4 mr-2 inline" />
|
||||
{event.zones.join(", ").replaceAll("_", " ")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
262
web/src/components/card/TimelineCardPlayer.tsx
Normal file
262
web/src/components/card/TimelineCardPlayer.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import VideoPlayer from "../player/VideoPlayer";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useApiHost } from "@/api";
|
||||
import TimelineEventOverlay from "../overlay/TimelineDataOverlay";
|
||||
import ActivityIndicator from "../ui/activity-indicator";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
getTimelineIcon,
|
||||
getTimelineItemDescription,
|
||||
} from "@/utils/timelineUtil";
|
||||
import { LuAlertCircle } from "react-icons/lu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../ui/tooltip";
|
||||
import Player from "video.js/dist/types/player";
|
||||
|
||||
type TimelinePlayerCardProps = {
|
||||
timeline?: Card;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
export default function TimelinePlayerCard({
|
||||
timeline,
|
||||
onDismiss,
|
||||
}: TimelinePlayerCardProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const apiHost = useApiHost();
|
||||
const playerRef = useRef<Player | undefined>();
|
||||
|
||||
const annotationOffset = useMemo(() => {
|
||||
if (!config || !timeline) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (
|
||||
(config.cameras[timeline.camera]?.detect?.annotation_offset || 0) / 1000
|
||||
);
|
||||
}, [config, timeline]);
|
||||
const [selectedItem, setSelectedItem] = useState<Timeline | undefined>();
|
||||
|
||||
const recordingParams = useMemo(() => {
|
||||
if (!timeline) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
before: timeline.entries.at(-1)!!.timestamp + 30,
|
||||
after: timeline.entries.at(0)!!.timestamp,
|
||||
};
|
||||
}, [timeline]);
|
||||
|
||||
const { data: recordings } = useSWR<Recording[]>(
|
||||
timeline ? [`${timeline.camera}/recordings`, recordingParams] : null,
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const playbackUri = useMemo(() => {
|
||||
if (!timeline) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const end = timeline.entries.at(-1)!!.timestamp + 30;
|
||||
const start = timeline.entries.at(0)!!.timestamp;
|
||||
return `${apiHost}vod/${timeline?.camera}/start/${
|
||||
Number.isInteger(start) ? start.toFixed(1) : start
|
||||
}/end/${Number.isInteger(end) ? end.toFixed(1) : end}/master.m3u8`;
|
||||
}, [timeline]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={timeline != null}
|
||||
onOpenChange={(_) => {
|
||||
setSelectedItem(undefined);
|
||||
onDismiss();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-5xl"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="capitalize">
|
||||
{`${timeline?.camera?.replaceAll(
|
||||
"_",
|
||||
" "
|
||||
)} @ ${formatUnixTimestampToDateTime(timeline?.time ?? 0, {
|
||||
strftime_fmt:
|
||||
config?.ui?.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S",
|
||||
})}`}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{config && timeline && recordings && recordings.length > 0 && (
|
||||
<>
|
||||
<TimelineSummary
|
||||
timeline={timeline}
|
||||
annotationOffset={annotationOffset}
|
||||
recordings={recordings}
|
||||
onFrameSelected={(selected, seekTime) => {
|
||||
setSelectedItem(selected);
|
||||
playerRef.current?.pause();
|
||||
playerRef.current?.currentTime(seekTime);
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
<VideoPlayer
|
||||
options={{
|
||||
preload: "auto",
|
||||
autoplay: true,
|
||||
sources: [
|
||||
{
|
||||
src: playbackUri,
|
||||
type: "application/vnd.apple.mpegurl",
|
||||
},
|
||||
],
|
||||
}}
|
||||
seekOptions={{ forward: 10, backward: 5 }}
|
||||
onReady={(player) => {
|
||||
playerRef.current = player;
|
||||
player.on("playing", () => {
|
||||
setSelectedItem(undefined);
|
||||
});
|
||||
}}
|
||||
onDispose={() => {
|
||||
playerRef.current = undefined;
|
||||
}}
|
||||
>
|
||||
{selectedItem ? (
|
||||
<TimelineEventOverlay
|
||||
timeline={selectedItem}
|
||||
cameraConfig={config.cameras[timeline.camera]}
|
||||
/>
|
||||
) : undefined}
|
||||
</VideoPlayer>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type TimelineSummaryProps = {
|
||||
timeline: Card;
|
||||
annotationOffset: number;
|
||||
recordings: Recording[];
|
||||
onFrameSelected: (timeline: Timeline, frameTime: number) => void;
|
||||
};
|
||||
|
||||
function TimelineSummary({
|
||||
timeline,
|
||||
annotationOffset,
|
||||
recordings,
|
||||
onFrameSelected,
|
||||
}: TimelineSummaryProps) {
|
||||
const [timeIndex, setTimeIndex] = useState<number>(-1);
|
||||
|
||||
// calculates the seek seconds by adding up all the seconds in the segments prior to the playback time
|
||||
const getSeekSeconds = (seekUnix: number) => {
|
||||
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: number) => {
|
||||
setTimeIndex(index);
|
||||
onFrameSelected(
|
||||
timeline.entries[index],
|
||||
getSeekSeconds(timeline.entries[index].timestamp + annotationOffset)
|
||||
);
|
||||
};
|
||||
|
||||
if (!timeline || !recordings) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="h-12 flex justify-center">
|
||||
<div className="flex flex-row flex-nowrap justify-between overflow-auto">
|
||||
{timeline.entries.map((item, index) => (
|
||||
<TooltipProvider key={item.timestamp}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={`m-1 blue ${
|
||||
index == timeIndex ? "text-blue-500" : "text-gray-500"
|
||||
}`}
|
||||
variant="secondary"
|
||||
autoFocus={false}
|
||||
onClick={() => onSelectMoment(index)}
|
||||
>
|
||||
{getTimelineIcon(item)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{getTimelineItemDescription(item)}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{timeIndex >= 0 ? (
|
||||
<div className="max-w-md self-center">
|
||||
<div className="flex justify-start">
|
||||
<div className="text-sm flex justify-between py-1 items-center">
|
||||
Bounding boxes may not align
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<LuAlertCircle />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Disclaimer: This data comes from the detect feed but is
|
||||
shown on the recordings.
|
||||
</p>
|
||||
<p>
|
||||
It is unlikely that the streams are perfectly in sync so the
|
||||
bounding box and the footage will not line up perfectly.
|
||||
</p>
|
||||
<p>The annotation_offset field can be used to adjust this.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { h, FunctionComponent } from 'preact';
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { FunctionComponent, useEffect, useMemo, useState } from "react";
|
||||
|
||||
interface IProp {
|
||||
/** The time to calculate time-ago from */
|
||||
time: Date;
|
||||
time: number;
|
||||
/** OPTIONAL: overwrite current time */
|
||||
currentTime?: Date;
|
||||
/** OPTIONAL: boolean that determines whether to show the time-ago text in dense format */
|
||||
84
web/src/components/overlay/TimelineDataOverlay.tsx
Normal file
84
web/src/components/overlay/TimelineDataOverlay.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
|
||||
type TimelineEventOverlayProps = {
|
||||
timeline: Timeline;
|
||||
cameraConfig: {
|
||||
detect: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export default function TimelineEventOverlay({
|
||||
timeline,
|
||||
cameraConfig,
|
||||
}: TimelineEventOverlayProps) {
|
||||
const boxLeftEdge = Math.round(timeline.data.box[0] * 100);
|
||||
const boxTopEdge = Math.round(timeline.data.box[1] * 100);
|
||||
const boxRightEdge = Math.round(
|
||||
(1 - timeline.data.box[2] - timeline.data.box[0]) * 100
|
||||
);
|
||||
const boxBottomEdge = Math.round(
|
||||
(1 - timeline.data.box[3] - timeline.data.box[1]) * 100
|
||||
);
|
||||
|
||||
const [isHovering, setIsHovering] = useState<boolean>(false);
|
||||
const getHoverStyle = () => {
|
||||
if (boxLeftEdge < 15) {
|
||||
// show object stats on right side
|
||||
return {
|
||||
left: `${boxLeftEdge + timeline.data.box[2] * 100 + 1}%`,
|
||||
top: `${boxTopEdge}%`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
right: `${boxRightEdge + timeline.data.box[2] * 100 + 1}%`,
|
||||
top: `${boxTopEdge}%`,
|
||||
};
|
||||
};
|
||||
|
||||
const getObjectArea = () => {
|
||||
const width = timeline.data.box[2] * cameraConfig.detect.width;
|
||||
const height = timeline.data.box[3] * cameraConfig.detect.height;
|
||||
return Math.round(width * height);
|
||||
};
|
||||
|
||||
const getObjectRatio = () => {
|
||||
const width = timeline.data.box[2] * cameraConfig.detect.width;
|
||||
const height = timeline.data.box[3] * cameraConfig.detect.height;
|
||||
return Math.round(100 * (width / height)) / 100;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}%`,
|
||||
}}
|
||||
>
|
||||
{timeline.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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
175
web/src/components/player/LivePlayer.tsx
Normal file
175
web/src/components/player/LivePlayer.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import WebRtcPlayer from "./WebRTCPlayer";
|
||||
import { CameraConfig } from "@/types/frigateConfig";
|
||||
import AutoUpdatingCameraImage from "../camera/AutoUpdatingCameraImage";
|
||||
import ActivityIndicator from "../ui/activity-indicator";
|
||||
import { Button } from "../ui/button";
|
||||
import { LuSettings } from "react-icons/lu";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { Label } from "../ui/label";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
|
||||
const emptyObject = Object.freeze({});
|
||||
|
||||
type LivePlayerProps = {
|
||||
cameraConfig: CameraConfig;
|
||||
liveMode: string;
|
||||
};
|
||||
|
||||
type Options = { [key: string]: boolean };
|
||||
|
||||
export default function LivePlayer({
|
||||
cameraConfig,
|
||||
liveMode,
|
||||
}: LivePlayerProps) {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
const [options, setOptions] = usePersistence(
|
||||
`${cameraConfig.name}-feed`,
|
||||
emptyObject
|
||||
);
|
||||
|
||||
const handleSetOption = useCallback(
|
||||
(id: string, value: boolean) => {
|
||||
console.log("Setting " + id + " to " + value);
|
||||
const newOptions = { ...options, [id]: value };
|
||||
setOptions(newOptions);
|
||||
},
|
||||
[options, setOptions]
|
||||
);
|
||||
|
||||
const searchParams = useMemo(
|
||||
() =>
|
||||
new URLSearchParams(
|
||||
Object.keys(options).reduce((memo, key) => {
|
||||
//@ts-ignore we know this is correct
|
||||
memo.push([key, options[key] === true ? "1" : "0"]);
|
||||
return memo;
|
||||
}, [])
|
||||
),
|
||||
[options]
|
||||
);
|
||||
|
||||
const handleToggleSettings = useCallback(() => {
|
||||
setShowSettings(!showSettings);
|
||||
}, [showSettings, setShowSettings]);
|
||||
|
||||
if (liveMode == "webrtc") {
|
||||
return (
|
||||
<div className="max-w-5xl">
|
||||
<WebRtcPlayer camera={cameraConfig.live.stream_name} />
|
||||
</div>
|
||||
);
|
||||
} else if (liveMode == "mse") {
|
||||
return <div className="max-w-5xl">Not yet implemented</div>;
|
||||
} else if (liveMode == "jsmpeg") {
|
||||
return (
|
||||
<div className={`max-w-[${cameraConfig.detect.width}px]`}>
|
||||
Not Yet Implemented
|
||||
</div>
|
||||
);
|
||||
} else if (liveMode == "debug") {
|
||||
return (
|
||||
<>
|
||||
<AutoUpdatingCameraImage
|
||||
camera={cameraConfig.name}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
<Button onClick={handleToggleSettings} variant="link" size="sm">
|
||||
<span className="w-5 h-5">
|
||||
<LuSettings />
|
||||
</span>{" "}
|
||||
<span>{showSettings ? "Hide" : "Show"} Options</span>
|
||||
</Button>
|
||||
{showSettings ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Options</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DebugSettings
|
||||
handleSetOption={handleSetOption}
|
||||
options={options}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
<ActivityIndicator />;
|
||||
}
|
||||
}
|
||||
|
||||
type DebugSettingsProps = {
|
||||
handleSetOption: (id: string, value: boolean) => void;
|
||||
options: Options;
|
||||
};
|
||||
|
||||
function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="bbox"
|
||||
checked={options["bbox"]}
|
||||
onCheckedChange={(isChecked) => {
|
||||
handleSetOption("bbox", isChecked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="bbox">Bounding Box</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="timestamp"
|
||||
checked={options["timestamp"]}
|
||||
onCheckedChange={(isChecked) => {
|
||||
handleSetOption("timestamp", isChecked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="timestamp">Timestamp</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="zones"
|
||||
checked={options["zones"]}
|
||||
onCheckedChange={(isChecked) => {
|
||||
handleSetOption("zones", isChecked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="zones">Zones</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="mask"
|
||||
checked={options["mask"]}
|
||||
onCheckedChange={(isChecked) => {
|
||||
handleSetOption("mask", isChecked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="mask">Mask</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="motion"
|
||||
checked={options["motion"]}
|
||||
onCheckedChange={(isChecked) => {
|
||||
handleSetOption("motion", isChecked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="motion">Motion</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="regions"
|
||||
checked={options["regions"]}
|
||||
onCheckedChange={(isChecked) => {
|
||||
handleSetOption("regions", isChecked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="regions">Regions</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
web/src/components/player/PreviewThumbnailPlayer.tsx
Normal file
178
web/src/components/player/PreviewThumbnailPlayer.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import VideoPlayer from "./VideoPlayer";
|
||||
import useSWR from "swr";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useApiHost } from "@/api";
|
||||
import Player from "video.js/dist/types/player";
|
||||
import { AspectRatio } from "../ui/aspect-ratio";
|
||||
|
||||
type PreviewPlayerProps = {
|
||||
camera: string;
|
||||
relevantPreview?: Preview;
|
||||
startTs: number;
|
||||
eventId: string;
|
||||
shouldAutoPlay: boolean;
|
||||
};
|
||||
|
||||
type Preview = {
|
||||
camera: string;
|
||||
src: string;
|
||||
type: string;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
export default function PreviewThumbnailPlayer({
|
||||
camera,
|
||||
relevantPreview,
|
||||
startTs,
|
||||
eventId,
|
||||
shouldAutoPlay,
|
||||
}: PreviewPlayerProps) {
|
||||
const { data: config } = useSWR("config");
|
||||
const playerRef = useRef<Player | null>(null);
|
||||
const apiHost = useApiHost();
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const onPlayback = useCallback(
|
||||
(isHovered: Boolean) => {
|
||||
if (!relevantPreview || !playerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHovered) {
|
||||
playerRef.current.play();
|
||||
} else {
|
||||
playerRef.current.pause();
|
||||
playerRef.current.currentTime(startTs - relevantPreview.start);
|
||||
}
|
||||
},
|
||||
[relevantPreview, startTs, playerRef]
|
||||
);
|
||||
|
||||
const autoPlayObserver = useRef<IntersectionObserver | null>();
|
||||
const preloadObserver = useRef<IntersectionObserver | null>();
|
||||
const inViewRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (!preloadObserver.current) {
|
||||
try {
|
||||
preloadObserver.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [{ isIntersecting }] = entries;
|
||||
setVisible(isIntersecting);
|
||||
},
|
||||
{
|
||||
threshold: 0,
|
||||
root: document.getElementById("pageRoot"),
|
||||
rootMargin: "10% 0px 25% 0px",
|
||||
}
|
||||
);
|
||||
if (node) preloadObserver.current.observe(node);
|
||||
} catch (e) {
|
||||
// no op
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldAutoPlay && !autoPlayObserver.current) {
|
||||
try {
|
||||
autoPlayObserver.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [{ isIntersecting }] = entries;
|
||||
if (isIntersecting) {
|
||||
onPlayback(true);
|
||||
} else {
|
||||
onPlayback(false);
|
||||
}
|
||||
},
|
||||
{ threshold: 1.0 }
|
||||
);
|
||||
if (node) autoPlayObserver.current.observe(node);
|
||||
} catch (e) {
|
||||
// no op
|
||||
}
|
||||
}
|
||||
},
|
||||
[preloadObserver, autoPlayObserver, onPlayback]
|
||||
);
|
||||
|
||||
let content;
|
||||
if (!relevantPreview || !visible) {
|
||||
if (isCurrentHour(startTs)) {
|
||||
content = (
|
||||
<img
|
||||
className={`${getPreviewWidth(camera, config)}`}
|
||||
src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
content = (
|
||||
<img
|
||||
className="w-[160px]"
|
||||
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<div className={`${getPreviewWidth(camera, config)}`}>
|
||||
<VideoPlayer
|
||||
options={{
|
||||
preload: "auto",
|
||||
autoplay: false,
|
||||
controls: false,
|
||||
muted: true,
|
||||
loadingSpinner: false,
|
||||
sources: [
|
||||
{
|
||||
src: `${relevantPreview.src}`,
|
||||
type: "video/mp4",
|
||||
},
|
||||
],
|
||||
}}
|
||||
seekOptions={{}}
|
||||
onReady={(player) => {
|
||||
playerRef.current = player;
|
||||
player.playbackRate(8);
|
||||
player.currentTime(startTs - relevantPreview.start);
|
||||
}}
|
||||
onDispose={() => {
|
||||
playerRef.current = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AspectRatio
|
||||
ref={relevantPreview ? inViewRef : null}
|
||||
ratio={16 / 9}
|
||||
className="bg-black flex justify-center items-center"
|
||||
onMouseEnter={() => onPlayback(true)}
|
||||
onMouseLeave={() => onPlayback(false)}
|
||||
>
|
||||
{content}
|
||||
</AspectRatio>
|
||||
);
|
||||
}
|
||||
|
||||
function isCurrentHour(timestamp: number) {
|
||||
const now = new Date();
|
||||
now.setMinutes(0, 0, 0);
|
||||
return timestamp > now.getTime() / 1000;
|
||||
}
|
||||
|
||||
function getPreviewWidth(camera: string, config: FrigateConfig) {
|
||||
const detect = config.cameras[camera].detect;
|
||||
|
||||
if (detect.width / detect.height < 1.0) {
|
||||
return "w-[120px]";
|
||||
}
|
||||
|
||||
if (detect.width / detect.height < 1.4) {
|
||||
return "w-[208px]";
|
||||
}
|
||||
|
||||
return "w-full";
|
||||
}
|
||||
89
web/src/components/player/VideoPlayer.tsx
Normal file
89
web/src/components/player/VideoPlayer.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef, ReactElement } from "react";
|
||||
import videojs from "video.js";
|
||||
import "videojs-playlist";
|
||||
import "video.js/dist/video-js.css";
|
||||
import Player from "video.js/dist/types/player";
|
||||
|
||||
type VideoPlayerProps = {
|
||||
children?: ReactElement | ReactElement[];
|
||||
options?: {
|
||||
[key: string]: any;
|
||||
};
|
||||
seekOptions?: {
|
||||
forward?: number;
|
||||
backward?: number;
|
||||
};
|
||||
remotePlayback?: boolean;
|
||||
onReady?: (player: Player) => void;
|
||||
onDispose?: () => void;
|
||||
};
|
||||
|
||||
export default function VideoPlayer({
|
||||
children,
|
||||
options,
|
||||
seekOptions = { forward: 30, backward: 10 },
|
||||
remotePlayback = false,
|
||||
onReady = (_) => {},
|
||||
onDispose = () => {},
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLDivElement | null>(null);
|
||||
const playerRef = useRef<Player | null>(null);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Make sure Video.js player is only initialized once
|
||||
if (!playerRef.current) {
|
||||
// The Video.js player needs to be _inside_ the component el for React 18 Strict Mode.
|
||||
const videoElement = document.createElement(
|
||||
"video-js"
|
||||
) as HTMLVideoElement;
|
||||
videoElement.controls = true;
|
||||
videoElement.playsInline = true;
|
||||
videoElement.disableRemotePlayback = remotePlayback;
|
||||
videoElement.classList.add("small-player");
|
||||
videoElement.classList.add("video-js");
|
||||
videoElement.classList.add("vjs-default-skin");
|
||||
videoRef.current?.appendChild(videoElement);
|
||||
|
||||
const player = (playerRef.current = videojs(
|
||||
videoElement,
|
||||
{ ...defaultOptions, ...options },
|
||||
() => {
|
||||
onReady && onReady(player);
|
||||
}
|
||||
));
|
||||
}
|
||||
}, [options, videoRef]);
|
||||
|
||||
// Dispose the Video.js player when the functional component unmounts
|
||||
useEffect(() => {
|
||||
const player = playerRef.current;
|
||||
|
||||
return () => {
|
||||
if (player && !player.isDisposed()) {
|
||||
player.dispose();
|
||||
playerRef.current = null;
|
||||
onDispose();
|
||||
}
|
||||
};
|
||||
}, [playerRef]);
|
||||
|
||||
return (
|
||||
<div data-vjs-player>
|
||||
<div ref={videoRef} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
web/src/components/player/WebRTCPlayer.tsx
Normal file
161
web/src/components/player/WebRTCPlayer.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
type WebRtcPlayerProps = {
|
||||
camera: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export default function WebRtcPlayer({
|
||||
camera,
|
||||
width,
|
||||
height,
|
||||
}: WebRtcPlayerProps) {
|
||||
const pcRef = useRef<RTCPeerConnection | undefined>();
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const PeerConnection = useCallback(
|
||||
async (media: string) => {
|
||||
if (!videoRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||
});
|
||||
|
||||
const localTracks = [];
|
||||
|
||||
if (/camera|microphone/.test(media)) {
|
||||
const tracks = await getMediaTracks("user", {
|
||||
video: media.indexOf("camera") >= 0,
|
||||
audio: media.indexOf("microphone") >= 0,
|
||||
});
|
||||
tracks.forEach((track) => {
|
||||
pc.addTransceiver(track, { direction: "sendonly" });
|
||||
if (track.kind === "video") localTracks.push(track);
|
||||
});
|
||||
}
|
||||
|
||||
if (media.indexOf("display") >= 0) {
|
||||
const tracks = await getMediaTracks("display", {
|
||||
video: true,
|
||||
audio: media.indexOf("speaker") >= 0,
|
||||
});
|
||||
tracks.forEach((track) => {
|
||||
pc.addTransceiver(track, { direction: "sendonly" });
|
||||
if (track.kind === "video") localTracks.push(track);
|
||||
});
|
||||
}
|
||||
|
||||
if (/video|audio/.test(media)) {
|
||||
const tracks = ["video", "audio"]
|
||||
.filter((kind) => media.indexOf(kind) >= 0)
|
||||
.map(
|
||||
(kind) =>
|
||||
pc.addTransceiver(kind, { direction: "recvonly" }).receiver.track
|
||||
);
|
||||
localTracks.push(...tracks);
|
||||
}
|
||||
|
||||
videoRef.current.srcObject = new MediaStream(localTracks);
|
||||
return pc;
|
||||
},
|
||||
[videoRef]
|
||||
);
|
||||
|
||||
async function getMediaTracks(
|
||||
media: string,
|
||||
constraints: MediaStreamConstraints
|
||||
) {
|
||||
try {
|
||||
const stream =
|
||||
media === "user"
|
||||
? await navigator.mediaDevices.getUserMedia(constraints)
|
||||
: await navigator.mediaDevices.getDisplayMedia(constraints);
|
||||
return stream.getTracks();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const connect = useCallback(
|
||||
async (ws: WebSocket, aPc: Promise<RTCPeerConnection | undefined>) => {
|
||||
if (!aPc) {
|
||||
return;
|
||||
}
|
||||
|
||||
pcRef.current = await aPc;
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
pcRef.current?.addEventListener("icecandidate", (ev) => {
|
||||
if (!ev.candidate) return;
|
||||
const msg = {
|
||||
type: "webrtc/candidate",
|
||||
value: ev.candidate.candidate,
|
||||
};
|
||||
ws.send(JSON.stringify(msg));
|
||||
});
|
||||
|
||||
pcRef.current
|
||||
?.createOffer()
|
||||
.then((offer) => pcRef.current?.setLocalDescription(offer))
|
||||
.then(() => {
|
||||
const msg = {
|
||||
type: "webrtc/offer",
|
||||
value: pcRef.current?.localDescription?.sdp,
|
||||
};
|
||||
ws.send(JSON.stringify(msg));
|
||||
});
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "webrtc/candidate") {
|
||||
pcRef.current?.addIceCandidate({ candidate: msg.value, sdpMid: "0" });
|
||||
} else if (msg.type === "webrtc/answer") {
|
||||
pcRef.current?.setRemoteDescription({
|
||||
type: "answer",
|
||||
sdp: msg.value,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${baseUrl.replace(
|
||||
/^http/,
|
||||
"ws"
|
||||
)}live/webrtc/api/ws?src=${camera}`;
|
||||
const ws = new WebSocket(url);
|
||||
const aPc = PeerConnection("video+audio");
|
||||
connect(ws, aPc);
|
||||
|
||||
return () => {
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
pcRef.current = undefined;
|
||||
}
|
||||
};
|
||||
}, [camera, connect, PeerConnection, pcRef, videoRef]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
controls
|
||||
muted
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
web/src/components/ui/activity-indicator.tsx
Normal file
12
web/src/components/ui/activity-indicator.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { LuLoader2 } from "react-icons/lu";
|
||||
|
||||
export default function ActivityIndicator({ size = 30 }) {
|
||||
return (
|
||||
<div
|
||||
className="w-full flex items-center justify-center"
|
||||
aria-label="Loading…"
|
||||
>
|
||||
<LuLoader2 className="animate-spin" size={size} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
web/src/components/ui/alert-dialog.tsx
Normal file
139
web/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
5
web/src/components/ui/aspect-ratio.tsx
Normal file
5
web/src/components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root
|
||||
|
||||
export { AspectRatio }
|
||||
36
web/src/components/ui/badge.tsx
Normal file
36
web/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
56
web/src/components/ui/button.tsx
Normal file
56
web/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
64
web/src/components/ui/calendar.tsx
Normal file
64
web/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
|
||||
),
|
||||
day_range_end: "day-range-end",
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: () => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: () => <ChevronRight className="h-4 w-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Calendar.displayName = "Calendar";
|
||||
|
||||
export { Calendar };
|
||||
79
web/src/components/ui/card.tsx
Normal file
79
web/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
120
web/src/components/ui/dialog.tsx
Normal file
120
web/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
198
web/src/components/ui/dropdown-menu.tsx
Normal file
198
web/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
176
web/src/components/ui/form.tsx
Normal file
176
web/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
73
web/src/components/ui/heading.tsx
Normal file
73
web/src/components/ui/heading.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type HeadingElements = "h1" | "h2" | "h3" | "h4";
|
||||
|
||||
const Heading = ({
|
||||
children,
|
||||
as,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
as: HeadingElements;
|
||||
className?: string;
|
||||
}) => {
|
||||
switch (as) {
|
||||
case "h1":
|
||||
return (
|
||||
<h1
|
||||
className={cn(
|
||||
"scroll-m-20 text-3xl font-extrabold tracking-tight",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
case "h2":
|
||||
return (
|
||||
<h2
|
||||
className={cn(
|
||||
"scroll-m-20 text-3xl font-semibold tracking-tight transition-colors first:mt-0 mb-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
case "h3":
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
"scroll-m-20 text-2xl font-semibold tracking-tight mb-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
case "h4":
|
||||
return (
|
||||
<h4
|
||||
className={cn(
|
||||
"scroll-m-20 text-xl font-semibold tracking-tight",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<h1
|
||||
className={cn(
|
||||
"scroll-m-20 text-3xl font-extrabold tracking-tight",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Heading;
|
||||
25
web/src/components/ui/input.tsx
Normal file
25
web/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
24
web/src/components/ui/label.tsx
Normal file
24
web/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
29
web/src/components/ui/popover.tsx
Normal file
29
web/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
42
web/src/components/ui/radio-group.tsx
Normal file
42
web/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
46
web/src/components/ui/scroll-area.tsx
Normal file
46
web/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
158
web/src/components/ui/select.tsx
Normal file
158
web/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
138
web/src/components/ui/sheet.tsx
Normal file
138
web/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
26
web/src/components/ui/slider.tsx
Normal file
26
web/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user