mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-02-25 18:55:25 -06:00
refactor(web): styles and styleguide
This commit is contained in:
parent
01c3b4fa6e
commit
5ed7a17f46
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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} />} />;
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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
67
web/src/StyleGuide.jsx
Normal 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>
|
||||
);
|
||||
}
|
55
web/src/components/AppBar.jsx
Normal file
55
web/src/components/AppBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
41
web/src/components/Card.jsx
Normal file
41
web/src/components/Card.jsx
Normal 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>
|
||||
);
|
||||
}
|
16
web/src/components/LinkedLogo.jsx
Normal file
16
web/src/components/LinkedLogo.jsx
Normal 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>
|
||||
);
|
||||
}
|
9
web/src/components/Logo.jsx
Normal file
9
web/src/components/Logo.jsx
Normal 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>
|
||||
);
|
||||
}
|
44
web/src/components/Menu.jsx
Normal file
44
web/src/components/Menu.jsx
Normal 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>
|
||||
);
|
||||
}
|
93
web/src/components/RelativeModal.jsx
Normal file
93
web/src/components/RelativeModal.jsx
Normal 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;
|
||||
}
|
106
web/src/components/Select.jsx
Normal file
106
web/src/components/Select.jsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
91
web/src/components/TextField.jsx
Normal file
91
web/src/components/TextField.jsx
Normal 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>
|
||||
);
|
||||
}
|
10
web/src/icons/ArrowDropdown.jsx
Normal file
10
web/src/icons/ArrowDropdown.jsx
Normal 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>
|
||||
);
|
||||
}
|
10
web/src/icons/ArrowDropup.jsx
Normal file
10
web/src/icons/ArrowDropup.jsx
Normal 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
10
web/src/icons/Menu.jsx
Normal 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>
|
||||
);
|
||||
}
|
10
web/src/icons/MenuOpen.jsx
Normal file
10
web/src/icons/MenuOpen.jsx
Normal 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
10
web/src/icons/More.jsx
Normal 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>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user