+
diff --git a/web/src/StyleGuide.jsx b/web/src/StyleGuide.jsx
new file mode 100644
index 000000000..0e93eef70
--- /dev/null
+++ b/web/src/StyleGuide.jsx
@@ -0,0 +1,67 @@
+import { h } from 'preact';
+import ArrowDropdown from './icons/ArrowDropdown';
+import ArrowDropup from './icons/ArrowDropup';
+import Card from './components/Card';
+import Button from './components/Button';
+import Heading from './components/Heading';
+import Select from './components/Select';
+import Switch from './components/Switch';
+import TextField from './components/TextField';
+import { useCallback, useState } from 'preact/hooks';
+
+export default function StyleGuide() {
+ const [switches, setSwitches] = useState({ 0: false, 1: true });
+
+ const handleSwitch = useCallback(
+ (id, checked) => {
+ setSwitches({ ...switches, [id]: checked });
+ },
+ [switches]
+ );
+
+ return (
+
+
Button
+
+ Default
+ Danger
+ Save
+ Disabled
+
+
+
Switch
+
+
+
+
+
Enabled, (off initial)
+
+
+
+
Enabled, (on initial)
+
+
+
+
+
Select
+
+
+
+
+
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 }) => (
+
+ {name}
+
+ ))}
+
+ ) : 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 (
-
-
-
+
+
- {label}
);
}
diff --git a/web/src/components/TextField.jsx b/web/src/components/TextField.jsx
new file mode 100644
index 000000000..a66159dcc
--- /dev/null
+++ b/web/src/components/TextField.jsx
@@ -0,0 +1,91 @@
+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]
+ );
+
+ // Reset the state if the prop value changes
+ useEffect(() => {
+ if (propValue !== value) {
+ setValue(propValue);
+ }
+ }, [propValue, setValue]);
+
+ const labelMoved = isFocused || value !== '';
+
+ return (
+
+
+
+ {LeadingIcon ? : null}
+
+ {TrailingIcon ? : null}
+
+
+ {helpText ?
{helpText}
: null}
+
+ );
+}
diff --git a/web/src/icons/ArrowDropdown.jsx b/web/src/icons/ArrowDropdown.jsx
new file mode 100644
index 000000000..f053006d6
--- /dev/null
+++ b/web/src/icons/ArrowDropdown.jsx
@@ -0,0 +1,10 @@
+import { h } from 'preact';
+
+export default function ArrowDropdown() {
+ return (
+
+
+
+
+ );
+}
diff --git a/web/src/icons/ArrowDropup.jsx b/web/src/icons/ArrowDropup.jsx
new file mode 100644
index 000000000..3dcb9ecc1
--- /dev/null
+++ b/web/src/icons/ArrowDropup.jsx
@@ -0,0 +1,10 @@
+import { h } from 'preact';
+
+export default function ArrowDropup() {
+ return (
+
+
+
+
+ );
+}
diff --git a/web/src/icons/Menu.jsx b/web/src/icons/Menu.jsx
new file mode 100644
index 000000000..5c5a922d5
--- /dev/null
+++ b/web/src/icons/Menu.jsx
@@ -0,0 +1,10 @@
+import { h } from 'preact';
+
+export default function Menu() {
+ return (
+
+
+
+
+ );
+}
diff --git a/web/src/icons/MenuOpen.jsx b/web/src/icons/MenuOpen.jsx
new file mode 100644
index 000000000..852f24c10
--- /dev/null
+++ b/web/src/icons/MenuOpen.jsx
@@ -0,0 +1,10 @@
+import { h } from 'preact';
+
+export default function MenuOpen() {
+ return (
+
+
+
+
+ );
+}
diff --git a/web/src/icons/More.jsx b/web/src/icons/More.jsx
new file mode 100644
index 000000000..ccd5d573f
--- /dev/null
+++ b/web/src/icons/More.jsx
@@ -0,0 +1,10 @@
+import { h } from 'preact';
+
+export default function More() {
+ return (
+
+
+
+
+ );
+}