Bläddra i källkod

refactor(web): camera view + bugfixes

Paul Armstrong 4 år sedan
förälder
incheckning
96f87caff0

+ 67 - 41
web/src/Camera.jsx

@@ -1,73 +1,99 @@
 import { h } from 'preact';
 import { h } from 'preact';
 import AutoUpdatingCameraImage from './components/AutoUpdatingCameraImage';
 import AutoUpdatingCameraImage from './components/AutoUpdatingCameraImage';
+import Button from './components/Button';
 import Card from './components/Card';
 import Card from './components/Card';
 import Heading from './components/Heading';
 import Heading from './components/Heading';
 import Link from './components/Link';
 import Link from './components/Link';
+import SettingsIcon from './icons/Settings';
 import Switch from './components/Switch';
 import Switch from './components/Switch';
 import { route } from 'preact-router';
 import { route } from 'preact-router';
-import { useCallback, useContext } from 'preact/hooks';
+import { usePersistence } from './context';
+import { useCallback, useContext, useMemo, useState } from 'preact/hooks';
 import { useApiHost, useConfig } from './api';
 import { useApiHost, useConfig } from './api';
 
 
-export default function Camera({ camera, url }) {
+export default function Camera({ camera }) {
   const { data: config } = useConfig();
   const { data: config } = useConfig();
   const apiHost = useApiHost();
   const apiHost = useApiHost();
+  const [showSettings, setShowSettings] = useState(false);
 
 
   if (!config) {
   if (!config) {
     return <div>{`No camera named ${camera}`}</div>;
     return <div>{`No camera named ${camera}`}</div>;
   }
   }
 
 
   const cameraConfig = config.cameras[camera];
   const cameraConfig = config.cameras[camera];
-  const objectCount = cameraConfig.objects.track.length;
+  const [options, setOptions, optionsLoaded] = usePersistence(`${camera}-feed`, Object.freeze({}));
 
 
-  const { pathname, searchParams } = new URL(`${window.location.protocol}//${window.location.host}${url}`);
-  const searchParamsString = searchParams.toString();
+  const objectCount = useMemo(() => cameraConfig.objects.track.length, [cameraConfig]);
 
 
   const handleSetOption = useCallback(
   const handleSetOption = useCallback(
     (id, value) => {
     (id, value) => {
-      searchParams.set(id, value ? 1 : 0);
-      route(`${pathname}?${searchParams.toString()}`, true);
+      const newOptions = { ...options, [id]: value };
+      setOptions(newOptions);
     },
     },
-    [searchParams]
+    [options]
   );
   );
 
 
-  function getBoolean(id) {
-    return Boolean(parseInt(searchParams.get(id), 10));
-  }
+  const searchParams = useMemo(
+    () =>
+      new URLSearchParams(
+        Object.keys(options).reduce((memo, key) => {
+          memo.push([key, options[key] === true ? '1' : '0']);
+          return memo;
+        }, [])
+      ),
+    [camera, options]
+  );
+
+  const handleToggleSettings = useCallback(() => {
+    setShowSettings(!showSettings);
+  }, [showSettings, setShowSettings]);
+
+  const optionContent = showSettings ? (
+    <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
+      <div className="flex space-x-3">
+        <Switch checked={options['bbox']} id="bbox" onChange={handleSetOption} />
+        <span class="inline-flex">Bounding box</span>
+      </div>
+      <div className="flex space-x-3">
+        <Switch checked={options['timestamp']} id="timestamp" onChange={handleSetOption} />
+        <span class="inline-flex">Timestamp</span>
+      </div>
+      <div className="flex space-x-3">
+        <Switch checked={options['zones']} id="zones" onChange={handleSetOption} />
+        <span class="inline-flex">Zones</span>
+      </div>
+      <div className="flex space-x-3">
+        <Switch checked={options['mask']} id="mask" onChange={handleSetOption} />
+        <span class="inline-flex">Masks</span>
+      </div>
+      <div className="flex space-x-3">
+        <Switch checked={options['motion']} id="motion" onChange={handleSetOption} />
+        <span class="inline-flex">Motion boxes</span>
+      </div>
+      <div className="flex space-x-3">
+        <Switch checked={options['regions']} id="regions" onChange={handleSetOption} />
+        <span class="inline-flex">Regions</span>
+      </div>
+      <Link href={`/cameras/${camera}/editor`}>Mask & Zone creator</Link>
+    </div>
+  ) : null;
 
 
   return (
   return (
     <div className="space-y-4">
     <div className="space-y-4">
       <Heading size="2xl">{camera}</Heading>
       <Heading size="2xl">{camera}</Heading>
-      <div>
-        <AutoUpdatingCameraImage camera={camera} searchParams={searchParamsString} />
-      </div>
-
-      <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>
+      {optionsLoaded ? (
+        <div>
+          <AutoUpdatingCameraImage camera={camera} searchParams={searchParams} />
         </div>
         </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>
-      </div>
+      ) : null}
+
+      <Button onClick={handleToggleSettings} type="text">
+        <span class="w-5 h-5">
+          <SettingsIcon />
+        </span>{' '}
+        <span>{showSettings ? 'Hide' : 'Show'} Options</span>
+      </Button>
+      {showSettings ? <Card header="Options" elevated={false} content={optionContent} /> : null}
 
 
       <div className="space-y-4">
       <div className="space-y-4">
         <Heading size="sm">Tracked objects</Heading>
         <Heading size="sm">Tracked objects</Heading>

+ 10 - 2
web/src/CameraMap.jsx

@@ -400,7 +400,10 @@ function EditableMask({ onChange, points, scale, snap, width, height }) {
   const scaledPoints = useMemo(() => scalePolylinePoints(points, scale), [points, scale]);
   const scaledPoints = useMemo(() => scalePolylinePoints(points, scale), [points, scale]);
 
 
   return (
   return (
-    <div className="absolute" style={`inset: -${MaskInset}px`}>
+    <div
+      className="absolute"
+      style={`top: -${MaskInset}px; right: -${MaskInset}px; bottom: -${MaskInset}px; left: -${MaskInset}px`}
+    >
       {!scaledPoints
       {!scaledPoints
         ? null
         ? null
         : scaledPoints.map(([x, y], i) => (
         : scaledPoints.map(([x, y], i) => (
@@ -414,7 +417,12 @@ function EditableMask({ onChange, points, scale, snap, width, height }) {
             />
             />
           ))}
           ))}
       <div className="absolute inset-0 right-0 bottom-0" onclick={handleAddPoint} ref={boundingRef} />
       <div className="absolute inset-0 right-0 bottom-0" onclick={handleAddPoint} ref={boundingRef} />
-      <svg width="100%" height="100%" className="absolute pointer-events-none" style={`inset: ${MaskInset}px`}>
+      <svg
+        width="100%"
+        height="100%"
+        className="absolute pointer-events-none"
+        style={`top: ${MaskInset}px; right: ${MaskInset}px; bottom: ${MaskInset}px; left: ${MaskInset}px`}
+      >
         {!scaledPoints ? null : (
         {!scaledPoints ? null : (
           <g>
           <g>
             <polyline points={polylinePointsToPolyline(scaledPoints)} fill="rgba(244,0,0,0.5)" />
             <polyline points={polylinePointsToPolyline(scaledPoints)} fill="rgba(244,0,0,0.5)" />

+ 1 - 1
web/src/Sidebar.jsx

@@ -13,7 +13,7 @@ export default function Sidebar() {
   return (
   return (
     <NavigationDrawer header={<Header />}>
     <NavigationDrawer header={<Header />}>
       <Destination href="/" text="Cameras" />
       <Destination href="/" text="Cameras" />
-      <Match path="/cameras/:camera">
+      <Match path="/cameras/:camera/:other?">
         {({ matches }) =>
         {({ matches }) =>
           matches ? (
           matches ? (
             <Fragment>
             <Fragment>

+ 1 - 1
web/src/components/Button.jsx

@@ -55,7 +55,7 @@ export default function Button({
   type = 'contained',
   type = 'contained',
   ...attrs
   ...attrs
 }) {
 }) {
-  let classes = `${className} ${ButtonTypes[type]} ${
+  let classes = `whitespace-nowrap flex items-center space-x-1 ${className} ${ButtonTypes[type]} ${
     ButtonColors[disabled ? 'disabled' : color][type]
     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 ${
   } 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'
     disabled ? 'cursor-not-allowed' : 'focus:ring-2 cursor-pointer'

+ 10 - 7
web/src/components/Card.jsx

@@ -6,6 +6,7 @@ export default function Box({
   buttons = [],
   buttons = [],
   className = '',
   className = '',
   content,
   content,
+  elevated = true,
   header,
   header,
   href,
   href,
   icons,
   icons,
@@ -16,14 +17,16 @@ export default function Box({
 }) {
 }) {
   const Element = href ? 'a' : 'div';
   const Element = href ? 'a' : 'div';
 
 
+  const typeClasses = elevated ? 'shadow-md hover:shadow-lg transition-shadow' : 'border border-gray-200';
+
   return (
   return (
-    <div
-      className={`bg-white dark:bg-gray-800 shadow-md hover:shadow-lg transition-shadow rounded-lg overflow-hidden ${className}`}
-    >
-      <Element href={href} {...props}>
-        {media}
-        <div class="p-4 pb-2">{header ? <Heading size="base">{header}</Heading> : null}</div>
-      </Element>
+    <div className={`bg-white dark:bg-gray-800 rounded-lg overflow-hidden ${typeClasses} ${className}`}>
+      {media || header ? (
+        <Element href={href} {...props}>
+          {media}
+          <div class="p-4 pb-2">{header ? <Heading size="base">{header}</Heading> : null}</div>
+        </Element>
+      ) : null}
       {buttons.length || content ? (
       {buttons.length || content ? (
         <div class="pl-4 pb-2">
         <div class="pl-4 pb-2">
           {content || null}
           {content || null}

+ 4 - 2
web/src/components/NavigationDrawer.jsx

@@ -49,10 +49,12 @@ export function Destination({ className = '', href, text, ...other }) {
       : 'class']: 'block p-2 text-sm font-semibold text-gray-900 rounded hover:bg-blue-500 dark:text-gray-200 hover:text-white dark:hover:text-white focus:outline-none ring-opacity-50 focus:ring-2 ring-blue-300',
       : 'class']: 'block p-2 text-sm font-semibold text-gray-900 rounded hover:bg-blue-500 dark:text-gray-200 hover:text-white dark:hover:text-white focus:outline-none ring-opacity-50 focus:ring-2 ring-blue-300',
   };
   };
 
 
+  const El = external ? 'a' : Link;
+
   return (
   return (
-    <Link activeClassName="bg-blue-500 bg-opacity-50 text-white" {...styleProps} href={href} {...props} {...other}>
+    <El activeClassName="bg-blue-500 bg-opacity-50 text-white" {...styleProps} href={href} {...props} {...other}>
       <div onClick={handleDismiss}>{text}</div>
       <div onClick={handleDismiss}>{text}</div>
-    </Link>
+    </El>
   );
   );
 }
 }
 
 

+ 11 - 3
web/src/components/TextField.jsx

@@ -60,8 +60,12 @@ export default function TextField({
         }`}
         }`}
         ref={inputRef}
         ref={inputRef}
       >
       >
-        <label className="flex space-x-2">
-          {LeadingIcon ? <LeadingIcon /> : null}
+        <label className="flex space-x-2 items-center">
+          {LeadingIcon ? (
+            <div class="w-10 h-full">
+              <LeadingIcon />
+            </div>
+          ) : null}
           <div className="relative w-full">
           <div className="relative w-full">
             <input
             <input
               className="h-6 mt-6 w-full bg-transparent focus:outline-none focus:ring-0"
               className="h-6 mt-6 w-full bg-transparent focus:outline-none focus:ring-0"
@@ -82,7 +86,11 @@ export default function TextField({
               {label}
               {label}
             </div>
             </div>
           </div>
           </div>
-          {TrailingIcon ? <TrailingIcon /> : null}
+          {TrailingIcon ? (
+            <div class="w-10 h-10">
+              <TrailingIcon />
+            </div>
+          ) : null}
         </label>
         </label>
       </div>
       </div>
       {helpText ? <div className="text-xs pl-3 pt-1">{helpText}</div> : null}
       {helpText ? <div className="text-xs pl-3 pt-1">{helpText}</div> : null}

+ 34 - 0
web/src/context/index.jsx

@@ -80,3 +80,37 @@ export function DrawerProvider({ children }) {
 export function useDrawer() {
 export function useDrawer() {
   return useContext(Drawer);
   return useContext(Drawer);
 }
 }
+
+export function usePersistence(key, defaultValue = undefined) {
+  const [value, setInternalValue] = useState(defaultValue);
+  const [loaded, setLoaded] = useState(false);
+
+  const setValue = useCallback(
+    (value) => {
+      setInternalValue(value);
+      async function update() {
+        await setData(key, value);
+      }
+
+      update();
+    },
+    [key]
+  );
+
+  useEffect(() => {
+    setLoaded(false);
+    setInternalValue(defaultValue);
+
+    async function load() {
+      const value = await getData(key);
+      if (typeof value !== 'undefined') {
+        setValue(value);
+      }
+      setLoaded(true);
+    }
+
+    load();
+  }, [key]);
+
+  return [value, setValue, loaded];
+}

+ 1 - 1
web/src/icons/ArrowDropdown.jsx

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

+ 1 - 1
web/src/icons/ArrowDropup.jsx

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

+ 1 - 1
web/src/icons/DarkMode.jsx

@@ -2,7 +2,7 @@ import { h } from 'preact';
 
 
 export default function DarkMode() {
 export default function DarkMode() {
   return (
   return (
-    <svg className=" fill-current" viewBox="0 0 24 24">
+    <svg className="fill-current" viewBox="0 0 24 24">
       <rect fill="none" height="24" width="24" />
       <rect fill="none" height="24" width="24" />
       <path d="M12,3c-4.97,0-9,4.03-9,9s4.03,9,9,9s9-4.03,9-9c0-0.46-0.04-0.92-0.1-1.36c-0.98,1.37-2.58,2.26-4.4,2.26 c-2.98,0-5.4-2.42-5.4-5.4c0-1.81,0.89-3.42,2.26-4.4C12.92,3.04,12.46,3,12,3L12,3z" />
       <path d="M12,3c-4.97,0-9,4.03-9,9s4.03,9,9,9s9-4.03,9-9c0-0.46-0.04-0.92-0.1-1.36c-0.98,1.37-2.58,2.26-4.4,2.26 c-2.98,0-5.4-2.42-5.4-5.4c0-1.81,0.89-3.42,2.26-4.4C12.92,3.04,12.46,3,12,3L12,3z" />
     </svg>
     </svg>

+ 1 - 1
web/src/icons/Menu.jsx

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

+ 1 - 1
web/src/icons/MenuOpen.jsx

@@ -2,7 +2,7 @@ import { h } from 'preact';
 
 
 export default function MenuOpen() {
 export default function MenuOpen() {
   return (
   return (
-    <svg className="w-10 fill-current" viewBox="0 0 24 24">
+    <svg className="fill-current" viewBox="0 0 24 24">
       <path d="M0 0h24v24H0V0z" fill="none" />
       <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" />
       <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>
     </svg>

+ 1 - 1
web/src/icons/More.jsx

@@ -2,7 +2,7 @@ import { h } from 'preact';
 
 
 export default function More() {
 export default function More() {
   return (
   return (
-    <svg className="w-10 fill-current" viewBox="0 0 24 24">
+    <svg className="fill-current" viewBox="0 0 24 24">
       <path d="M0 0h24v24H0z" fill="none" />
       <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" />
       <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>
     </svg>

+ 12 - 0
web/src/icons/Settings.jsx

@@ -0,0 +1,12 @@
+import { h } from 'preact';
+
+export default function DarkMode() {
+  return (
+    <svg className="fill-current" viewBox="0 0 24 24">
+      <g>
+        <path d="M0,0h24v24H0V0z" fill="none" />
+        <path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z" />
+      </g>
+    </svg>
+  );
+}