refactor(web): styles and styleguide

This commit is contained in:
Paul Armstrong 2021-02-01 20:28:25 -08:00 committed by Blake Blackshear
parent 01c3b4fa6e
commit 5ed7a17f46
26 changed files with 833 additions and 181 deletions

View File

@ -15,6 +15,7 @@
</head>
<body>
<div id="root"></div>
<div id="menus"></div>
<noscript>You need to enable JavaScript to run this app.</noscript>
<script type="module" src="/dist/index.js"></script>
</body>

View File

@ -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 ? (
<div className="flex flex-grow-1 min-h-screen justify-center items-center">
<ActivityIndicator />
</div>
) : (
<div className="md:flex flex-col md:flex-row md:min-h-screen w-full bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white">
<Sidebar />
<div className="flex-auto p-2 md:p-4 lg:pl-8 lg:pr-8 min-w-0">
<Router>
<CameraMap path="/cameras/:camera/editor" />
<Camera path="/cameras/:camera" />
<Event path="/events/:eventId" />
<Events path="/events" />
<Debug path="/debug" />
<Cameras default path="/" />
</Router>
</div>
return (
<div class="w-full">
<AppBar title="Frigate" />
{status !== FetchStatus.LOADED ? (
<div className="flex flex-grow-1 min-h-screen justify-center items-center">
<ActivityIndicator />
</div>
) : (
<div className="md:flex flex-col md:flex-row md:min-h-screen w-full bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
<Sidebar />
<div className="w-full flex-auto p-2 mt-20 md:p-4 lg:pl-8 lg:pr-8 min-w-0">
<Router>
<CameraMap path="/cameras/:camera/editor" />
<Camera path="/cameras/:camera" />
<Event path="/events/:eventId" />
<Events path="/events" />
<Debug path="/debug" />
{import.meta.env.SNOWPACK_MODE !== 'development' ? <StyleGuide path="/styleguide" /> : null}
<Cameras default path="/" />
</Router>
</div>
</div>
)}
</div>
);
}

View File

@ -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 (
<div className="space-y-4">
<Heading size="2xl">{camera}</Heading>
<Box>
<div>
<AutoUpdatingCameraImage camera={camera} searchParams={searchParamsString} />
</Box>
</div>
<Box className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 p-4">
<Switch checked={getBoolean('bbox')} id="bbox" label="Bounding box" onChange={handleSetOption} />
<Switch checked={getBoolean('timestamp')} id="timestamp" label="Timestamp" onChange={handleSetOption} />
<Switch checked={getBoolean('zones')} id="zones" label="Zones" onChange={handleSetOption} />
<Switch checked={getBoolean('mask')} id="mask" label="Masks" onChange={handleSetOption} />
<Switch checked={getBoolean('motion')} id="motion" label="Motion boxes" onChange={handleSetOption} />
<Switch checked={getBoolean('regions')} id="regions" label="Regions" onChange={handleSetOption} />
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 p-4">
<div className="flex space-x-3">
<Switch checked={getBoolean('bbox')} id="bbox" onChange={handleSetOption} />
<span class="inline-flex">Bounding box</span>
</div>
<div className="flex space-x-3">
<Switch checked={getBoolean('timestamp')} id="timestamp" onChange={handleSetOption} />
<span class="inline-flex">Timestamp</span>
</div>
<div className="flex space-x-3">
<Switch checked={getBoolean('zones')} id="zones" onChange={handleSetOption} />
<span class="inline-flex">Zones</span>
</div>
<div className="flex space-x-3">
<Switch checked={getBoolean('mask')} id="mask" onChange={handleSetOption} />
<span class="inline-flex">Masks</span>
</div>
<div className="flex space-x-3">
<Switch checked={getBoolean('motion')} id="motion" onChange={handleSetOption} />
<span class="inline-flex">Motion boxes</span>
</div>
<div className="flex space-x-3">
<Switch checked={getBoolean('regions')} id="regions" onChange={handleSetOption} />
<span class="inline-flex">Regions</span>
</div>
<Link href={`/cameras/${camera}/editor`}>Mask & Zone creator</Link>
</Box>
</div>
<div className="space-y-4">
<Heading size="sm">Tracked objects</Heading>
<div className="grid grid-cols-3 md:grid-cols-4 gap-4">
{cameraConfig.objects.track.map((objectType) => {
return (
<Box key={objectType} hover href={`/events?camera=${camera}&label=${objectType}`}>
<Heading size="sm">{objectType}</Heading>
<img src={`${apiHost}/api/${camera}/${objectType}/best.jpg?crop=1&h=150`} />
</Box>
);
})}
<div className="flex flex-wrap justify-start">
{cameraConfig.objects.track.map((objectType) => (
<Card
className="mb-4 mr-4"
key={objectType}
header={objectType}
href={`/events?camera=${camera}&label=${objectType}`}
media={<img src={`${apiHost}/api/${camera}/${objectType}/best.jpg?crop=1&h=150`} />}
/>
))}
</div>
</div>
</div>

View File

@ -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)
<div class="flex-col space-y-4">
<Heading size="2xl">{camera} mask & zone creator</Heading>
<Box>
<p>
This tool can help you create masks & zones for your {camera} camera. When done, copy each mask configuration
into your <code className="font-mono">config.yml</code> file restart your Frigate instance to save your
changes.
</p>
</Box>
<Card
content={
<p>
This tool can help you create masks & zones for your {camera} camera. When done, copy each mask
configuration into your <code className="font-mono">config.yml</code> file restart your Frigate instance to
save your changes.
</p>
}
header="Warning"
/>
<Box className="space-y-4">
<div className="space-y-4">
<div className="relative">
<img ref={imageRef} src={`${apiHost}/api/${camera}/latest.jpg`} />
<EditableMask
@ -262,8 +265,10 @@ ${Object.keys(objectMaskPoints)
height={height}
/>
</div>
<Switch checked={snap} label="Snap to edges" onChange={handleChangeSnap} />
</Box>
<div class="flex space-x-4">
<span>Snap to edges</span> <Switch checked={snap} onChange={handleChangeSnap} />
</div>
</div>
<div class="flex-col space-y-4">
<MaskValues
@ -475,7 +480,7 @@ function MaskValues({
);
return (
<Box className="overflow-hidden" onmouseover={handleMousein} onmouseout={handleMouseout}>
<div className="overflow-hidden" onmouseover={handleMousein} onmouseout={handleMouseout}>
<div class="flex space-x-4">
<Heading className="flex-grow self-center" size="base">
{title}
@ -525,7 +530,7 @@ function MaskValues({
}
})}
</pre>
</Box>
</div>
);
}

View File

@ -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 (
<Box
className="bg-white dark:bg-gray-700 shadow-lg rounded-lg p-4 hover:bg-gray-300 hover:dark:bg-gray-500 dark:hover:text-gray-900 dark:hover:text-gray-900"
href={href}
>
<Heading size="base">{name}</Heading>
<CameraImage camera={name} />
</Box>
);
return <Card buttons={buttons} href={href} header={name} media={<CameraImage camera={name} />} />;
}

View File

@ -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 <span className="text-sm">{service.version}</span>
</Heading>
<Box>
<Table className="w-full">
<Thead>
<Tr>
<Th>detector</Th>
<Table className="w-full">
<Thead>
<Tr>
<Th>detector</Th>
{detectorDataKeys.map((name) => (
<Th>{name.replace('_', ' ')}</Th>
))}
</Tr>
</Thead>
<Tbody>
{detectorNames.map((detector, i) => (
<Tr index={i}>
<Td>{detector}</Td>
{detectorDataKeys.map((name) => (
<Th>{name.replace('_', ' ')}</Th>
<Td key={`${name}-${detector}`}>{detectors[detector][name]}</Td>
))}
</Tr>
</Thead>
<Tbody>
{detectorNames.map((detector, i) => (
<Tr index={i}>
<Td>{detector}</Td>
{detectorDataKeys.map((name) => (
<Td key={`${name}-${detector}`}>{detectors[detector][name]}</Td>
))}
</Tr>
))}
</Tbody>
</Table>
</Box>
))}
</Tbody>
</Table>
<Box>
<Table className="w-full">
<Thead>
<Tr>
<Th>camera</Th>
<Table className="w-full">
<Thead>
<Tr>
<Th>camera</Th>
{cameraDataKeys.map((name) => (
<Th>{name.replace('_', ' ')}</Th>
))}
</Tr>
</Thead>
<Tbody>
{cameraNames.map((camera, i) => (
<Tr index={i}>
<Td>
<Link href={`/cameras/${camera}`}>{camera}</Link>
</Td>
{cameraDataKeys.map((name) => (
<Th>{name.replace('_', ' ')}</Th>
<Td key={`${name}-${camera}`}>{cameras[camera][name]}</Td>
))}
</Tr>
</Thead>
<Tbody>
{cameraNames.map((camera, i) => (
<Tr index={i}>
<Td>
<Link href={`/cameras/${camera}`}>{camera}</Link>
</Td>
{cameraDataKeys.map((name) => (
<Td key={`${name}-${camera}`}>{cameras[camera][name]}</Td>
))}
</Tr>
))}
</Tbody>
</Table>
</Box>
))}
</Tbody>
</Table>
<Box className="relative">
<div className="relative">
<Heading size="sm">Config</Heading>
<Button className="absolute top-4 right-8" onClick={handleCopyConfig}>
<Button className="absolute top-8 right-4" onClick={handleCopyConfig}>
Copy to Clipboard
</Button>
<pre className="overflow-auto font-mono text-gray-900 dark:text-gray-100 rounded bg-gray-100 dark:bg-gray-800 p-2 max-h-96">
{JSON.stringify(config, null, 2)}
</pre>
</Box>
</div>
</div>
);
}

View File

@ -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} <span className="text-sm">{startime.toLocaleString()}</span>
</Heading>
<Box>
<Card>
{data.has_clip ? (
<Fragment>
<Heading size="sm">Clip</Heading>
@ -32,9 +32,9 @@ export default function Event({ eventId }) {
) : (
<p>No clip available</p>
)}
</Box>
</Card>
<Box>
<Card>
<Heading size="sm">{data.has_snapshot ? 'Best image' : 'Thumbnail'}</Heading>
<img
src={
@ -44,9 +44,9 @@ export default function Event({ eventId }) {
}
alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
/>
</Box>
</Card>
<Box>
<Card>
<Table>
<Thead>
<Th>Key</Th>
@ -75,7 +75,7 @@ export default function Event({ eventId }) {
</Tr>
</Tbody>
</Table>
</Box>
</Card>
</div>
);
}

View File

@ -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 } = {}) {
<Filters onChange={handleFilter} searchParams={searchParams} />
<Box className="min-w-0 overflow-auto">
<div className="min-w-0 overflow-auto">
<Table className="min-w-full table-fixed">
<Thead>
<Tr>
@ -201,7 +202,7 @@ export default function Events({ path: pathname } = {}) {
</Tr>
</Tfoot>
</Table>
</Box>
</div>
</div>
);
}
@ -258,21 +259,20 @@ function Filters({ onChange, searchParams }) {
}, [data]);
return (
<Box className="flex space-y-0 space-x-8 flex-wrap">
<div className="flex space-x-8">
<Filter onChange={onChange} options={cameras} paramName="camera" searchParams={searchParams} />
<Filter onChange={onChange} options={zones} paramName="zone" searchParams={searchParams} />
<Filter onChange={onChange} options={labels} paramName="label" searchParams={searchParams} />
</Box>
</div>
);
}
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 (
<label>
<span className="block uppercase text-sm">{paramName}</span>
<select className="border-solid border border-gray-500 rounded dark:text-gray-900" onChange={handleSelect}>
<option>All</option>
{options.map((opt) => {
return (
<option value={opt} selected={searchParams.get(paramName) === opt}>
{opt}
</option>
);
})}
</select>
</label>
<Select
label={`${paramName.charAt(0).toUpperCase()}${paramName.substr(1)}`}
onChange={handleSelect}
options={selectOptions}
selected={searchParams.get(paramName) || 'all'}
/>
);
}

View File

@ -1,5 +1,6 @@
import { h } from 'preact';
import Link from './components/Link';
import LinkedLogo from './components/LinkedLogo';
import { Link as RouterLink } from 'preact-router/match';
import { useCallback, useState } from 'preact/hooks';
@ -51,14 +52,11 @@ export default function Sidebar() {
}, [open, setOpen]);
return (
<div className="flex flex-col w-full md:w-64 text-gray-700 bg-white dark:text-gray-200 dark:bg-gray-700 flex-shrink-0">
<div className="flex-shrink-0 px-8 py-4 flex flex-row items-center justify-between">
<a
href="#"
className="text-lg font-semibold tracking-widest text-gray-900 uppercase rounded-lg dark:text-white focus:outline-none focus:shadow-outline"
>
Frigate
</a>
<div className="sticky top-0 max-h-screen flex flex-col w-full md:w-64 text-gray-700 bg-white dark:text-gray-200 dark:bg-gray-700 flex-shrink-0 border-r border-gray-200 shadow lg:shadow-none z-20 lg:z-0">
<div className="flex-shrink-0 p-4 flex flex-row items-center justify-between">
<div class="text-gray-500">
<LinkedLogo />
</div>
<button
className="rounded-lg md:hidden rounded-lg focus:outline-none focus:shadow-outline"
onClick={handleToggle}
@ -75,11 +73,7 @@ export default function Sidebar() {
<NavLink href="/events" text="Events" />
<NavLink href="/debug" text="Debug" />
<hr className="border-solid border-gray-500 mt-2" />
<NavLink
className="self-end"
href="https://blakeblackshear.github.io/frigate"
text="Documentation"
/>
<NavLink className="self-end" href="https://blakeblackshear.github.io/frigate" text="Documentation" />
<NavLink className="self-end" href="https://github.com/blakeblackshear/frigate" text="GitHub" />
</nav>
</div>

67
web/src/StyleGuide.jsx Normal file
View File

@ -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 (
<div>
<Heading size="md">Button</Heading>
<div class="flex space-x-4 mb-4">
<Button>Default</Button>
<Button color="red">Danger</Button>
<Button color="green">Save</Button>
<Button disabled>Disabled</Button>
</div>
<Heading size="md">Switch</Heading>
<div class="flex">
<div>
<p>Disabled, off</p>
<Switch />
</div>
<div>
<p>Disabled, on</p>
<Switch checked />
</div>
<div>
<p>Enabled, (off initial)</p>
<Switch checked={switches[0]} id={0} onChange={handleSwitch} label="Default" />
</div>
<div>
<p>Enabled, (on initial)</p>
<Switch checked={switches[1]} id={1} onChange={handleSwitch} label="Default" />
</div>
</div>
<Heading size="md">Select</Heading>
<div class="flex space-x-4 mb-4 max-w-4xl">
<Select label="Basic select box" options={['All', 'None', 'Other']} selected="None" />
</div>
<Heading size="md">TextField</Heading>
<div class="flex-col space-y-4 max-w-4xl">
<TextField label="Default text field" />
<TextField label="Pre-filled" value="This is my pre-filled value" />
<TextField label="With help" helpText="This is some help text" />
<TextField label="Leading icon" leadingIcon={ArrowDropdown} />
<TextField label="Trailing icon" trailingIcon={ArrowDropup} />
</div>
</div>
);
}

View File

@ -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 (
<div
className={`w-full border-b border-color-gray-100 flex items-center align-middle p-4 space-x-2 fixed left-0 right-0 z-10 bg-white dark:bg-gray-800 transform transition-all duration-200 translate-y-0 ${
!show ? '-translate-y-full' : ''
} ${!atZero ? 'shadow' : ''}`}
>
<div className="lg:hidden">
<Button className="rounded-full w-12 h-12 -ml-4 -mt-4 -mb-4" type="text">
<MenuIcon />
</Button>
</div>
<LinkedLogo />
</div>
);
}

View File

@ -1,16 +0,0 @@
import { h } from 'preact';
export default function Box({ children, className = '', hover = false, href, ...props }) {
const Element = href ? 'a' : 'div';
return (
<Element
className={`bg-white dark:bg-gray-700 shadow-lg rounded-lg p-2 lg:p-4 ${
hover ? 'hover:bg-gray-300 hover:dark:bg-gray-500 dark:hover:text-gray-900 dark:hover:text-gray-900' : ''
} ${className}`}
href={href}
{...props}
>
{children}
</Element>
);
}

View File

@ -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 (
<div
<Element
role="button"
aria-disabled={disabled ? 'true' : 'false'}
tabindex="0"
className={`rounded ${BUTTON_COLORS[color].normal} text-white pl-4 pr-4 pt-2 pb-2 font-bold shadow ${BUTTON_COLORS[color].hover} hover:shadow-lg cursor-pointer ${className}`}
className={classes}
onClick={onClick || noop}
href={href}
{...attrs}
>
{children}
</div>
</Element>
);
}

View File

@ -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 (
<div className={`bg-white dark:bg-gray-800 shadow-md hover:shadow-xl rounded-lg overflow-hidden ${className}`}>
<Element href={href} {...props}>
{media}
<div class="p-2 pb-2 lg:p-4 lg:pb-2">{header ? <Heading size="base">{header}</Heading> : null}</div>
</Element>
{buttons.length || content ? (
<div class="pl-4 pb-2">
{content || null}
{buttons.length ? (
<div class="flex space-x-4 -ml-2">
{buttons.map(({ name, href }) => (
<Button key={name} href={href} type="text">
{name}
</Button>
))}
</div>
) : null}
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,16 @@
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 class="w-10">
<Logo />
</div>
Frigate
</a>
</Heading>
);
}

View File

@ -0,0 +1,9 @@
import { h } from 'preact';
export default function Logo() {
return (
<svg viewBox="0 0 512 512" className="fill-current">
<path d="M130 446.5C131.6 459.3 145 468 137 470C129 472 94 406.5 86 378.5C78 350.5 73.5 319 75.5 301C77.4999 283 181 255 181 247.5C181 240 147.5 247 146 241C144.5 235 171.3 238.6 178.5 229C189.75 214 204 216.5 213 208.5C222 200.5 233 170 235 157C237 144 215 129 209 119C203 109 222 102 268 83C314 64 460 22 462 27C464 32 414 53 379 66C344 79 287 104 287 111C287 118 290 123.5 288 139.5C286 155.5 285.76 162.971 282 173.5C279.5 180.5 277 197 282 212C286 224 299 233 305 235C310 235.333 323.8 235.8 339 235C358 234 385 236 385 241C385 246 344 243 344 250C344 257 386 249 385 256C384 263 350 260 332 260C317.6 260 296.333 259.333 287 256L285 263C281.667 263 274.7 265 267.5 265C258.5 265 258 268 241.5 268C225 268 230 267 215 266C200 265 144 308 134 322C124 336 130 370 130 385.5C130 399.428 128 430.5 130 446.5Z" />
</svg>
);
}

View File

@ -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 ? (
<RelativeModal
children={children}
className={`${className || ''} pt-2 pb-2`}
role="listbox"
onDismiss={onDismiss}
portalRootID="menus"
relativeTo={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 (
<div
className={`flex space-x-2 p-2 pl-4 pr-4 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' : ''
}`}
onclick={handleClick}
role="option"
>
{Icon ? <Icon /> : null}
{label}
</div>
);
}

View File

@ -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 = (
<Fragment>
<div className="absolute inset-0" onClick={handleDismiss} />
<div
className={`bg-white dark:bg-gray-700 dark:text-white absolute shadow-lg rounded w-auto max-h-48 transition-all duration-75 transform scale-90 opacity-0 ${
show ? 'scale-100 opacity-100' : ''
} ${className}`}
onkeydown={handleKeydown}
role={role}
ref={ref}
style={position.width > 0 ? `width: ${position.width}px; top: ${position.top}px; left: ${position.left}px` : ''}
>
{children}
</div>
</Fragment>
);
return portalRoot ? createPortal(menu, portalRoot) : menu;
}

View File

@ -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 (
<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}>
{options.map(({ value, label }, i) => (
<MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} />
))}
</Menu>
) : null}
</Fragment>
);
}

View File

@ -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 for={id} className="flex items-center cursor-pointer">
<div className="relative">
<input id={id} type="checkbox" className="hidden" onChange={handleChange} checked={checked} />
<label
for={id}
className={`flex items-center justify-center ${onChange ? 'cursor-pointer' : 'cursor-not-allowed'}`}
>
<div
onmouseover={handleFocus}
onmouseout={handleBlur}
className={`w-8 h-5 relative ${!onChange ? 'opacity-60' : ''}`}
>
<div className="relative overflow-hidden">
<input
className="absolute left-48"
onBlur={handleBlur}
onFocus={handleFocus}
tabindex="0"
id={id}
type="checkbox"
onChange={handleChange}
checked={checked}
/>
</div>
<div
className={`transition-colors toggle__line w-12 h-6 ${
!checked ? 'bg-gray-400' : 'bg-blue-400'
className={`w-8 h-3 absolute top-1 left-1 ${
!checked ? 'bg-gray-300' : 'bg-blue-300'
} rounded-full shadow-inner`}
/>
<div
className="transition-transform absolute w-6 h-6 bg-white rounded-full shadow-md inset-y-0 left-0"
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>
<div className="ml-3 text-gray-700 font-medium dark:text-gray-200">{label}</div>
</label>
);
}

View File

@ -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 (
<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">
{LeadingIcon ? <LeadingIcon /> : 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 ? <TrailingIcon /> : null}
</label>
</div>
{helpText ? <div className="text-xs pl-3 pt-1">{helpText}</div> : null}
</div>
);
}

View File

@ -0,0 +1,10 @@
import { h } from 'preact';
export default function ArrowDropdown() {
return (
<svg className="w-10 fill-current" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path d="M7 10l5 5 5-5z" />
</svg>
);
}

View File

@ -0,0 +1,10 @@
import { h } from 'preact';
export default function ArrowDropup() {
return (
<svg className="w-10 fill-current" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path d="M7 14l5-5 5 5z" />
</svg>
);
}

10
web/src/icons/Menu.jsx Normal file
View File

@ -0,0 +1,10 @@
import { h } from 'preact';
export default function Menu() {
return (
<svg className="w-10 fill-current" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" />
</svg>
);
}

View File

@ -0,0 +1,10 @@
import { h } from 'preact';
export default function MenuOpen() {
return (
<svg className="w-10 fill-current" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M3 18h13v-2H3v2zm0-5h10v-2H3v2zm0-7v2h13V6H3zm18 9.59L17.42 12 21 8.41 19.59 7l-5 5 5 5L21 15.59z" />
</svg>
);
}

10
web/src/icons/More.jsx Normal file
View File

@ -0,0 +1,10 @@
import { h } from 'preact';
export default function More() {
return (
<svg className="w-10 fill-current" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
</svg>
);
}