Use new UI (#8983)

* fixup build

* swap frontends
This commit is contained in:
Blake Blackshear
2023-12-16 16:20:59 +00:00
parent a2c6f45454
commit bdebb99b5a
286 changed files with 20010 additions and 20007 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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;

View File

@@ -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>;
}

View File

@@ -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()} &middot;
</span>
);
}
return (
<div className={`text-center ${className}`}>
<Heading size='lg'>{title}</Heading>
<div>{subtitle}</div>
</div>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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`} />
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,5 +1,3 @@
import { h } from 'preact';
export default function Logo() {
return (
<svg viewBox="0 0 512 512" className="fill-current">

View File

@@ -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" />;
}

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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" />;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}
}

View 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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -1,4 +0,0 @@
export interface ScrollPermission {
allowed: boolean;
resetAfterSeeked: boolean;
}

View File

@@ -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>
);
}

View File

@@ -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`,
}}
/>
);
};

View File

@@ -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;
};

View File

@@ -1,7 +0,0 @@
import type { TimelineEvent } from './TimelineEvent';
export interface TimelineChangeEvent {
timelineEvent?: TimelineEvent;
markerTime: Date;
seekComplete: boolean;
}

View File

@@ -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>
);
};

View File

@@ -1,8 +0,0 @@
export interface TimelineEvent {
start_time: number;
end_time: number;
startTime: Date;
endTime: Date;
id: string;
label: 'car' | 'person' | 'dog';
}

View File

@@ -1,9 +0,0 @@
import type { TimelineEvent } from './TimelineEvent';
export interface TimelineEventBlock extends TimelineEvent {
index: number;
yOffset: number;
width: number;
positionX: number;
seconds: number;
}

View File

@@ -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>
);
}

View File

@@ -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,
})}`;
}
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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;

View File

@@ -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>
`);
});
});

View File

@@ -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);
});
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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');
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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>
`);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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' });
});
});

View File

@@ -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');
});
});

View File

@@ -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" />;
}

View File

@@ -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');
});
});

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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 */

View 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>
)}
</>
);
}

View 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>
);
}

View 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";
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
}

View File

@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View 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 }

View 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 };

View 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 };

View 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 }

View 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,
}

View 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,
}

View 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,
}

View 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;

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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,
}

View 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,
}

View 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