From 5ed7a17f46ea18fccefc53c19d5c6b760d9057fd Mon Sep 17 00:00:00 2001 From: Paul Armstrong Date: Mon, 1 Feb 2021 20:28:25 -0800 Subject: [PATCH] refactor(web): styles and styleguide --- web/public/index.html | 1 + web/src/App.jsx | 42 ++++++----- web/src/Camera.jsx | 60 ++++++++++----- web/src/CameraMap.jsx | 31 ++++---- web/src/Cameras.jsx | 14 +--- web/src/Debug.jsx | 83 ++++++++++----------- web/src/Event.jsx | 14 ++-- web/src/Events.jsx | 39 +++++----- web/src/Sidebar.jsx | 20 ++--- web/src/StyleGuide.jsx | 67 +++++++++++++++++ web/src/components/AppBar.jsx | 55 ++++++++++++++ web/src/components/Box.jsx | 16 ---- web/src/components/Button.jsx | 64 ++++++++++++++-- web/src/components/Card.jsx | 41 +++++++++++ web/src/components/LinkedLogo.jsx | 16 ++++ web/src/components/Logo.jsx | 9 +++ web/src/components/Menu.jsx | 44 +++++++++++ web/src/components/RelativeModal.jsx | 93 +++++++++++++++++++++++ web/src/components/Select.jsx | 106 +++++++++++++++++++++++++++ web/src/components/Switch.jsx | 58 ++++++++++++--- web/src/components/TextField.jsx | 91 +++++++++++++++++++++++ web/src/icons/ArrowDropdown.jsx | 10 +++ web/src/icons/ArrowDropup.jsx | 10 +++ web/src/icons/Menu.jsx | 10 +++ web/src/icons/MenuOpen.jsx | 10 +++ web/src/icons/More.jsx | 10 +++ 26 files changed, 833 insertions(+), 181 deletions(-) create mode 100644 web/src/StyleGuide.jsx create mode 100644 web/src/components/AppBar.jsx delete mode 100644 web/src/components/Box.jsx create mode 100644 web/src/components/Card.jsx create mode 100644 web/src/components/LinkedLogo.jsx create mode 100644 web/src/components/Logo.jsx create mode 100644 web/src/components/Menu.jsx create mode 100644 web/src/components/RelativeModal.jsx create mode 100644 web/src/components/Select.jsx create mode 100644 web/src/components/TextField.jsx create mode 100644 web/src/icons/ArrowDropdown.jsx create mode 100644 web/src/icons/ArrowDropup.jsx create mode 100644 web/src/icons/Menu.jsx create mode 100644 web/src/icons/MenuOpen.jsx create mode 100644 web/src/icons/More.jsx diff --git a/web/public/index.html b/web/public/index.html index fd931730f..99c4894dc 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -15,6 +15,7 @@
+ diff --git a/web/src/App.jsx b/web/src/App.jsx index f12c8eeb4..aba1a4357 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,5 +1,6 @@ import { h } from 'preact'; import ActivityIndicator from './components/ActivityIndicator'; +import AppBar from './components/AppBar'; import Camera from './Camera'; import CameraMap from './CameraMap'; import Cameras from './Cameras'; @@ -8,27 +9,34 @@ import Event from './Event'; import Events from './Events'; import { Router } from 'preact-router'; import Sidebar from './Sidebar'; +import StyleGuide from './StyleGuide'; import Api, { FetchStatus, useConfig } from './api'; export default function App() { const { data, status } = useConfig(); - return status !== FetchStatus.LOADED ? ( -
- -
- ) : ( -
- -
- - - - - - - - -
+ return ( +
+ + {status !== FetchStatus.LOADED ? ( +
+ +
+ ) : ( +
+ +
+ + + + + + + {import.meta.env.SNOWPACK_MODE !== 'development' ? : null} + + +
+
+ )}
); } diff --git a/web/src/Camera.jsx b/web/src/Camera.jsx index 79815e450..43a9bae07 100644 --- a/web/src/Camera.jsx +++ b/web/src/Camera.jsx @@ -1,6 +1,6 @@ import { h } from 'preact'; import AutoUpdatingCameraImage from './components/AutoUpdatingCameraImage'; -import Box from './components/Box'; +import Card from './components/Card'; import Heading from './components/Heading'; import Link from './components/Link'; import Switch from './components/Switch'; @@ -17,6 +17,7 @@ export default function Camera({ camera, url }) { } const cameraConfig = config.cameras[camera]; + const objectCount = cameraConfig.objects.track.length; const { pathname, searchParams } = new URL(`${window.location.protocol}//${window.location.host}${url}`); const searchParamsString = searchParams.toString(); @@ -36,31 +37,50 @@ export default function Camera({ camera, url }) { return (
{camera} - +
- +
- - - - - - - +
+
+ + Bounding box +
+
+ + Timestamp +
+
+ + Zones +
+
+ + Masks +
+
+ + Motion boxes +
+
+ + Regions +
Mask & Zone creator - +
Tracked objects -
- {cameraConfig.objects.track.map((objectType) => { - return ( - - {objectType} - - - ); - })} +
+ {cameraConfig.objects.track.map((objectType) => ( + } + /> + ))}
diff --git a/web/src/CameraMap.jsx b/web/src/CameraMap.jsx index 8a227b08b..ae817548f 100644 --- a/web/src/CameraMap.jsx +++ b/web/src/CameraMap.jsx @@ -1,5 +1,5 @@ import { h } from 'preact'; -import Box from './components/Box'; +import Card from './components/Card'; import Button from './components/Button'; import Heading from './components/Heading'; import Switch from './components/Switch'; @@ -242,15 +242,18 @@ ${Object.keys(objectMaskPoints)
{camera} mask & zone creator - -

- This tool can help you create masks & zones for your {camera} camera. When done, copy each mask configuration - into your config.yml file restart your Frigate instance to save your - changes. -

-
+ + This tool can help you create masks & zones for your {camera} camera. When done, copy each mask + configuration into your config.yml file restart your Frigate instance to + save your changes. +

+ } + header="Warning" + /> - +
- - +
+ Snap to edges +
+
+
{title} @@ -525,7 +530,7 @@ function MaskValues({ } })} - +
); } diff --git a/web/src/Cameras.jsx b/web/src/Cameras.jsx index 04052ba29..53fd2919d 100644 --- a/web/src/Cameras.jsx +++ b/web/src/Cameras.jsx @@ -1,11 +1,12 @@ import { h } from 'preact'; import ActivityIndicator from './components/ActivityIndicator'; -import Box from './components/Box'; +import Card from './components/Card'; import CameraImage from './components/CameraImage'; import Events from './Events'; import Heading from './components/Heading'; import { route } from 'preact-router'; import { useConfig } from './api'; +import { useMemo } from 'preact/hooks'; export default function Cameras() { const { data: config, status } = useConfig(); @@ -25,14 +26,7 @@ export default function Cameras() { function Camera({ name }) { const href = `/cameras/${name}`; + const buttons = useMemo(() => [{ name: 'Events', href: `/events?camera=${name}` }], [name]); - return ( - - {name} - - - ); + return } />; } diff --git a/web/src/Debug.jsx b/web/src/Debug.jsx index dab25d6df..57ea751df 100644 --- a/web/src/Debug.jsx +++ b/web/src/Debug.jsx @@ -1,6 +1,5 @@ import { h } from 'preact'; import ActivityIndicator from './components/ActivityIndicator'; -import Box from './components/Box'; import Button from './components/Button'; import Heading from './components/Heading'; import Link from './components/Link'; @@ -50,63 +49,59 @@ export default function Debug() { Debug {service.version} - - - - - +
detector
+ + + + {detectorDataKeys.map((name) => ( + + ))} + + + + {detectorNames.map((detector, i) => ( + + {detectorDataKeys.map((name) => ( - + ))} - - - {detectorNames.map((detector, i) => ( - - - {detectorDataKeys.map((name) => ( - - ))} - - ))} - -
detector{name.replace('_', ' ')}
{detector}{name.replace('_', ' ')}{detectors[detector][name]}
{detector}{detectors[detector][name]}
-
+ ))} + + - - - - - +
camera
+ + + + {cameraDataKeys.map((name) => ( + + ))} + + + + {cameraNames.map((camera, i) => ( + + {cameraDataKeys.map((name) => ( - + ))} - - - {cameraNames.map((camera, i) => ( - - - {cameraDataKeys.map((name) => ( - - ))} - - ))} - -
camera{name.replace('_', ' ')}
+ {camera} + {name.replace('_', ' ')}{cameras[camera][name]}
- {camera} - {cameras[camera][name]}
-
+ ))} + + - +
Config -
           {JSON.stringify(config, null, 2)}
         
- +
); } diff --git a/web/src/Event.jsx b/web/src/Event.jsx index 7a4d3aff3..1c3d0f069 100644 --- a/web/src/Event.jsx +++ b/web/src/Event.jsx @@ -1,6 +1,6 @@ import { h, Fragment } from 'preact'; import ActivityIndicator from './components/ActivityIndicator'; -import Box from './components/Box'; +import Card from './components/Card'; import Heading from './components/Heading'; import Link from './components/Link'; import { FetchStatus, useApiHost, useEvent } from './api'; @@ -23,7 +23,7 @@ export default function Event({ eventId }) { {data.camera} {data.label} {startime.toLocaleString()} - + {data.has_clip ? ( Clip @@ -32,9 +32,9 @@ export default function Event({ eventId }) { ) : (

No clip available

)} -
+ - + {data.has_snapshot ? 'Best image' : 'Thumbnail'} {`${data.label} - + - + @@ -75,7 +75,7 @@ export default function Event({ eventId }) {
Key
-
+
); } diff --git a/web/src/Events.jsx b/web/src/Events.jsx index c43aee132..35000cc75 100644 --- a/web/src/Events.jsx +++ b/web/src/Events.jsx @@ -1,8 +1,9 @@ import { h } from 'preact'; import ActivityIndicator from './components/ActivityIndicator'; -import Box from './components/Box'; +import Card from './components/Card'; import Heading from './components/Heading'; import Link from './components/Link'; +import Select from './components/Select'; import produce from 'immer'; import { route } from 'preact-router'; import { FetchStatus, useApiHost, useConfig, useEvents } from './api'; @@ -116,7 +117,7 @@ export default function Events({ path: pathname } = {}) { - +
@@ -201,7 +202,7 @@ export default function Events({ path: pathname } = {}) {
- +
); } @@ -258,21 +259,20 @@ function Filters({ onChange, searchParams }) { }, [data]); return ( - +
- +
); } function Filter({ onChange, searchParams, paramName, options }) { const handleSelect = useCallback( - (event) => { + (key) => { const newParams = new URLSearchParams(searchParams.toString()); - const value = event.target.value; - if (value) { - newParams.set(paramName, event.target.value); + if (key !== 'all') { + newParams.set(paramName, key); } else { newParams.delete(paramName); } @@ -282,19 +282,14 @@ function Filter({ onChange, searchParams, paramName, options }) { [searchParams, paramName, onChange] ); + const selectOptions = useMemo(() => ['all', ...options], [options]); + return ( - + +
+ + TextField +
+ + + + + +
+
+ ); +} diff --git a/web/src/components/AppBar.jsx b/web/src/components/AppBar.jsx new file mode 100644 index 000000000..2db5a136e --- /dev/null +++ b/web/src/components/AppBar.jsx @@ -0,0 +1,55 @@ +import { h } from 'preact'; +import Button from './Button'; +import LinkedLogo from './LinkedLogo'; +import MenuIcon from '../icons/Menu'; +import { useLayoutEffect, useCallback, useState } from 'preact/hooks'; + +// We would typically preserve these in component state +// But need to avoid too many re-renders +let ticking = false; +let lastScrollY = window.scrollY; + +export default function AppBar({ title }) { + const [show, setShow] = useState(true); + const [atZero, setAtZero] = useState(window.scrollY === 0); + const [sidebarVisible, setSidebarVisible] = useState(true); + + const scrollListener = useCallback( + (event) => { + const scrollY = window.scrollY; + + // if (!ticking) { + window.requestAnimationFrame(() => { + setShow(scrollY <= 0 || lastScrollY > scrollY); + setAtZero(scrollY === 0); + ticking = false; + lastScrollY = scrollY; + }); + ticking = true; + // } + }, + [setShow] + ); + + useLayoutEffect(() => { + document.addEventListener('scroll', scrollListener); + return () => { + document.removeEventListener('scroll', scrollListener); + }; + }, []); + + return ( +
+
+ +
+ +
+ ); +} diff --git a/web/src/components/Box.jsx b/web/src/components/Box.jsx deleted file mode 100644 index 41dde70e7..000000000 --- a/web/src/components/Box.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import { h } from 'preact'; - -export default function Box({ children, className = '', hover = false, href, ...props }) { - const Element = href ? 'a' : 'div'; - return ( - - {children} - - ); -} diff --git a/web/src/components/Button.jsx b/web/src/components/Button.jsx index 5a194a3c5..c5d4f3832 100644 --- a/web/src/components/Button.jsx +++ b/web/src/components/Button.jsx @@ -2,22 +2,70 @@ import { h } from 'preact'; const noop = () => {}; -const BUTTON_COLORS = { - blue: { normal: 'bg-blue-500', hover: 'hover:bg-blue-400' }, - red: { normal: 'bg-red-500', hover: 'hover:bg-red-400' }, - green: { normal: 'bg-green-500', hover: 'hover:bg-green-400' }, +const ButtonColors = { + blue: { + contained: 'bg-blue-500 focus:bg-blue-400 active:bg-blue-600 ring-blue-300', + outlined: '', + 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', + }, + red: { + contained: 'bg-red-500 focus:bg-red-400 active:bg-red-600 ring-red-300', + outlined: '', + text: '', + }, + green: { + contained: 'bg-green-500 focus:bg-green-400 active:bg-green-600 ring-green-300', + outlined: '', + text: '', + }, + disabled: { + contained: 'bg-gray-400', + outlined: '', + text: '', + }, }; -export default function Button({ children, className, color = 'blue', onClick, size, ...attrs }) { +const ButtonTypes = { + contained: 'text-white shadow focus:shadow-xl hover:shadow-md', + outlined: '', + text: 'transition-opacity', +}; + +export default function Button({ + children, + className = '', + color = 'blue', + disabled = false, + href, + onClick, + size, + type = 'contained', + ...attrs +}) { + let classes = `${className} ${ + ButtonColors[disabled ? 'disabled' : color][type] + } font-sans inline-flex font-bold uppercase text-xs px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${ + disabled ? 'cursor-not-allowed' : 'focus:ring-2 cursor-pointer' + }`; + + if (disabled) { + classes = classes.replace(/(?:focus|active|hover):[^ ]+/g, ''); + } + + const Element = href ? 'a' : 'div'; + return ( -
{children} -
+ ); } diff --git a/web/src/components/Card.jsx b/web/src/components/Card.jsx new file mode 100644 index 000000000..00475526a --- /dev/null +++ b/web/src/components/Card.jsx @@ -0,0 +1,41 @@ +import { h } from 'preact'; +import Button from './Button'; +import Heading from './Heading'; + +export default function Box({ + buttons = [], + className = '', + content, + header, + href, + icons, + media = null, + subheader, + supportingText, + ...props +}) { + const Element = href ? 'a' : 'div'; + + return ( +
+ + {media} +
{header ? {header} : null}
+
+ {buttons.length || content ? ( +
+ {content || null} + {buttons.length ? ( +
+ {buttons.map(({ name, href }) => ( + + ))} +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/web/src/components/LinkedLogo.jsx b/web/src/components/LinkedLogo.jsx new file mode 100644 index 000000000..f0358e7f5 --- /dev/null +++ b/web/src/components/LinkedLogo.jsx @@ -0,0 +1,16 @@ +import { h } from 'preact'; +import Heading from './Heading'; +import Logo from './Logo'; + +export default function LinkedLogo() { + return ( + + +
+ +
+ Frigate +
+
+ ); +} diff --git a/web/src/components/Logo.jsx b/web/src/components/Logo.jsx new file mode 100644 index 000000000..de7f62731 --- /dev/null +++ b/web/src/components/Logo.jsx @@ -0,0 +1,9 @@ +import { h } from 'preact'; + +export default function Logo() { + return ( + + + + ); +} diff --git a/web/src/components/Menu.jsx b/web/src/components/Menu.jsx new file mode 100644 index 000000000..d7c678c38 --- /dev/null +++ b/web/src/components/Menu.jsx @@ -0,0 +1,44 @@ +import { h } from 'preact'; +import RelativeModal from './RelativeModal'; +import { useCallback, useEffect } from 'preact/hooks'; + +export default function Menu({ className, children, onDismiss, relativeTo }) { + return relativeTo ? ( + + ) : null; +} + +export function MenuItem({ focus, icon: Icon, label, onSelect, value }) { + const handleClick = useCallback(() => { + onSelect && onSelect(value, label); + }, [onSelect, value, label]); + + const handleKeydown = useCallback( + (event) => { + if (event.key === 'Enter') { + onSelect && onSelect(value, label); + } + }, + [onSelect, value, label] + ); + + return ( +
+ {Icon ? : null} + {label} +
+ ); +} diff --git a/web/src/components/RelativeModal.jsx b/web/src/components/RelativeModal.jsx new file mode 100644 index 000000000..d82490650 --- /dev/null +++ b/web/src/components/RelativeModal.jsx @@ -0,0 +1,93 @@ +import { h, Fragment } from 'preact'; +import { createPortal } from 'preact/compat'; +import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; + +export default function RelativeModal({ className, role = 'dialog', children, onDismiss, portalRootID, relativeTo }) { + const [position, setPosition] = useState({ top: -999, left: 0, width: 0 }); + 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.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); + return; + } + }, + [ref.current] + ); + + useEffect(() => { + 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, y, width, height } = relativeTo.current.getBoundingClientRect(); + let top = y + height; + let left = x; + // too far right + if (left + menuWidth > windowWidth) { + left = windowWidth - menuWidth; + } + // too far left + else if (left < 0) { + left = 0; + } + // too close to bottom + if (top + menuHeight > windowHeight) { + top = y - menuHeight; + } + setPosition({ left, top, width }); + const focusable = ref.current.querySelector('[tabindex]'); + focusable && console.log('focusing'); + focusable && focusable.focus(); + } + }, [relativeTo && relativeTo.current, ref && ref.current]); + + useEffect(() => { + if (position.width) { + setShow(true); + } else { + setShow(false); + } + }, [show, position.width, ref.current]); + + const menu = ( + +
+
0 ? `width: ${position.width}px; top: ${position.top}px; left: ${position.left}px` : ''} + > + {children} +
+ + ); + + return portalRoot ? createPortal(menu, portalRoot) : menu; +} diff --git a/web/src/components/Select.jsx b/web/src/components/Select.jsx new file mode 100644 index 000000000..ae5e1fe95 --- /dev/null +++ b/web/src/components/Select.jsx @@ -0,0 +1,106 @@ +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 { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; + +export default function Select({ label, onChange, 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( + Math.max( + options.findIndex(({ value }) => value === propSelected), + 0 + ) + ); + const [focused, setFocused] = useState(null); + + const ref = useRef(null); + + const handleSelect = useCallback( + (value, label) => { + setSelected(options.findIndex((opt) => opt.value === value)); + onChange && onChange(value, label); + setShowMenu(false); + }, + [onChange] + ); + + const handleClick = useCallback(() => { + setShowMenu(true); + }, [setShowMenu]); + + const handleKeydown = useCallback( + (event) => { + switch (event.key) { + case 'Enter': { + if (!showMenu) { + setShowMenu(true); + setFocused(selected); + } else { + setSelected(focused); + onChange && onChange(options[focused].value, options[focused].label); + setShowMenu(false); + } + break; + } + + case 'ArrowDown': { + const newIndex = focused + 1; + newIndex < options.length && setFocused(newIndex); + break; + } + + case 'ArrowUp': { + const newIndex = focused - 1; + newIndex > -1 && setFocused(newIndex); + break; + } + } + }, + [setShowMenu, setFocused, focused, selected] + ); + + const handleDismiss = useCallback(() => { + setShowMenu(false); + }, [setShowMenu]); + + // 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); + } + }, [propSelected]); + + return ( + + + {showMenu ? ( + + {options.map(({ value, label }, i) => ( + + ))} + + ) : null} + + ); +} diff --git a/web/src/components/Switch.jsx b/web/src/components/Switch.jsx index 54f57f02f..5e417b06e 100644 --- a/web/src/components/Switch.jsx +++ b/web/src/components/Switch.jsx @@ -1,26 +1,62 @@ import { h } from 'preact'; import { useCallback, useState } from 'preact/hooks'; -export default function Switch({ checked, label, id, onChange }) { - const handleChange = useCallback(() => { - onChange(id, !checked); - }, [id, onChange, checked]); +export default function Switch({ checked, id, onChange }) { + const [internalState, setInternalState] = useState(checked); + const [isFocused, setFocused] = useState(false); + const [isHovered, setHovered] = useState(false); + + const handleChange = useCallback( + (event) => { + 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 ( -