Timothy Pomeroy 2 rokov pred
rodič
commit
5cf0d8699c

+ 0 - 0
app/instances/page.js → app/admin/page.js


+ 5 - 0
app/globals.scss

@@ -48,4 +48,9 @@ body {
       }
     }
   }
+  .ty-steps-item.ty-steps-item_label-vertical {
+    .ty-steps-item__content {
+      width: 100%;
+    }
+  }
 }

+ 40 - 1
app/page.js

@@ -33,6 +33,44 @@ export default function Home() {
     setNames(n);
   }, []);
 
+  const handleSync = async (payload) => {
+    if (payload?.clients && payload?.value) {
+      for (let client of payload?.clients) {
+        let indx = clients.findIndex((o) => o === client);
+        if (connections[indx]) {
+          let wled = connections[indx];
+          switch (payload?.value) {
+            case "enable":
+              await wled.enableUDPSync({ send: true, receive: false });
+              break;
+            case "disable":
+              await wled.disableUDPSync();
+              break;
+          }
+        }
+      }
+    }
+  };
+
+  const handlePower = async (payload) => {
+    if (payload?.clients && payload?.value) {
+      for (let client of payload?.clients) {
+        let indx = clients.findIndex((o) => o === client);
+        if (connections[indx]) {
+          let wled = connections[indx];
+          switch (payload?.value) {
+            case "on":
+              await wled.turnOn();
+              break;
+            case "off":
+              await wled.turnOff();
+              break;
+          }
+        }
+      }
+    }
+  };
+
   const handlePreset = async (payload) => {
     if (payload?.clients && payload?.value) {
       for (let client of payload?.clients) {
@@ -40,7 +78,6 @@ export default function Home() {
         if (connections[indx]) {
           let wled = connections[indx];
           await wled.setPreset(payload?.value);
-          await wled.reinit();
         }
       }
     }
@@ -58,6 +95,8 @@ export default function Home() {
               presets={presets}
               automation={automation}
               onPreset={handlePreset}
+              onSync={handleSync}
+              onPower={handlePower}
             />
           )) || <Loader size="lg" />}
         </Col>

+ 119 - 32
components/Automation.js

@@ -1,7 +1,11 @@
 import { useEffect, useState } from "react";
-import { Button, Col, Icon, Row, Steps } from "tiny-ui";
+import { Button, Drawer, Icon, Steps, Typography } from "tiny-ui";
+
+import { useLocalStorage } from "lib/state";
+import { isBlank } from "lib/utils";
 
 const { Step } = Steps;
+const { Heading, Paragraph, Text } = Typography;
 
 const Automation = ({
   automation,
@@ -9,25 +13,13 @@ const Automation = ({
   clients,
   names,
   onPreset,
+  onSync,
+  onPower,
   ...props
 }) => {
-  const [current, setCurrent] = useState(-1);
+  const [current, setCurrent] = useLocalStorage(-1);
   const [total, setTotal] = useState();
-
-  useEffect(() => {
-    setTotal((automation && Object.keys(automation)) || 0);
-  }, [automation]);
-
-  useEffect(() => {
-    if (current > -1) {
-      let indx = current + 1;
-      let payload = {
-        clients: automation?.[indx] || [],
-        value: indx
-      };
-      onPreset(payload);
-    }
-  }, [current]);
+  const [visible, setVisible] = useState(false);
 
   const getTitle = (indx) => presets?.[indx]?.name || indx;
   const getName = (v) => {
@@ -35,25 +27,73 @@ const Automation = ({
     return indx > -1 ? names?.[indx] : v;
   };
   const getDescription = (indx) => {
-    return automation?.[indx]?.map((o) => getName(o))?.join(", ");
+    return (
+      (automation?.[indx] &&
+        Object.keys(automation?.[indx])
+          ?.map((o) => getName(o))
+          ?.join(", ")) ||
+      null
+    );
+  };
+
+  const handleChange = (indx) => {
+    let keys = Object.keys(automation?.[indx]);
+    let preset = { clients: keys || [], value: indx };
+    let power = { on: [], off: [] };
+    let sync = { enable: [], disable: [] };
+    // loop the keys and determine what to do
+    for (let key of keys) {
+      if (automation?.[indx]?.[key]?.on === true) power.on.push(key);
+      else power.off.push(key);
+      if (automation?.[indx]?.[key]?.sync === true) sync.enable.push(key);
+      else sync.disable.push(key);
+    }
+    if (!isBlank(power?.on)) onPower({ clients: power?.on, value: "on" }); // power on
+    if (!isBlank(power?.off)) onPower({ clients: power?.off, value: "off" }); // power off
+    if (!isBlank(sync?.enable))
+      onSync({ clients: sync?.enable, value: "enable" }); // enable sync
+    if (!isBlank(sync?.disable))
+      onSync({ clients: sync?.disable, value: "disable" }); // disable sync
+    onPreset(preset); // apply the preset
   };
 
+  useEffect(() => {
+    setTotal((automation && Object.keys(automation)) || 0);
+  }, [automation]);
+
+  useEffect(() => {
+    if (current > -1 && current < total) handleChange(current + 1);
+  }, [current]);
+
   let steps = Object.keys(automation).map((o, i) => {
-    return <Step key={i} title={getTitle(o)} description={getDescription(o)} />;
+    return (
+      <Step
+        key={i}
+        title={
+          <>
+            {getTitle(o)}
+            {current === o - 1 ? (
+              <div style={{ float: "right" }}>
+                <Button
+                  title="Reapply"
+                  round
+                  icon={<Icon name="loader-circle" />}
+                  onClick={() => {
+                    handleChange(o);
+                  }}
+                />
+              </div>
+            ) : null}
+          </>
+        }
+        description={getDescription(o)}
+      ></Step>
+    );
   });
 
   return (
-    <Row>
-      <Col span="12">
-        <Steps
-          current={current}
-          direction="vertical"
-          onChange={(v) => setCurrent(v)}
-        >
-          {steps}
-        </Steps>
-      </Col>
-      <Col span="12">
+    <>
+      <div style={{ position: "sticky", top: "60px", marginBottom: "2rem" }}>
         <Button.Group size="lg" round>
           <Button
             title="Previous"
@@ -64,6 +104,13 @@ const Automation = ({
               setCurrent(next);
             }}
           />
+          <Button
+            title="Reapply"
+            icon={<Icon name="loader-circle" />}
+            onClick={() => {
+              handleChange(current);
+            }}
+          />
           <Button
             title="Next"
             icon={<Icon name="arrow-right" />}
@@ -74,8 +121,48 @@ const Automation = ({
             }}
           />
         </Button.Group>
-      </Col>
-    </Row>
+        <Button.Group size="lg" round>
+          <Button
+            title="Help"
+            icon={<Icon name="question-fill" />}
+            onClick={() => {
+              setVisible(true);
+            }}
+          />
+        </Button.Group>
+      </div>
+      <Steps
+        current={current}
+        direction="vertical"
+        onChange={(v) => setCurrent(v)}
+      >
+        {steps}
+      </Steps>
+      <Drawer
+        header="Introduction"
+        placement="right"
+        size={480}
+        onClose={() => setVisible(false)}
+        visible={visible}
+      >
+        <>
+          <Paragraph>
+            Select one of the presets from the left to start applying preset
+            automation.
+          </Paragraph>
+          <Paragraph>
+            You can use the navigation buttons above to advance forward{" "}
+            <Icon name="arrow-right" size={16} /> and backward{" "}
+            <Icon name="arrow-left" size={16} /> through the preset list.
+          </Paragraph>
+          <Paragraph>
+            If a preset fails to apply to all of the intended WLED instances
+            then use the Reapply button <Icon name="loader-circle" size={16} />{" "}
+            within the navigation menu bar or within the target step.
+          </Paragraph>
+        </>
+      </Drawer>
+    </>
   );
 };
 

+ 14 - 5
components/Layout.js

@@ -4,16 +4,23 @@ import { Menu } from ".";
 const { Header, Footer, Content } = _Layout;
 
 const layoutStyle = {
-  marginBottom: "2rem",
+  marginBottom: "2rem"
+};
+const headerStyle = {
+  position: "sticky",
+  top: "0"
 };
-const headerStyle = {};
 
 const contentStyle = {
   minHeight: "200px",
-  lineHeight: "1rem",
+  lineHeight: "1rem"
 };
 
-const footerStyle = {};
+const footerStyle = {
+  textAlign: "right",
+  fontSize: ".77rem",
+  color: "#777"
+};
 
 const Layout = ({ page, children, ...rest }) => {
   return (
@@ -22,7 +29,9 @@ const Layout = ({ page, children, ...rest }) => {
         <Menu page={page} />
       </Header>
       <Content style={contentStyle}>{children}</Content>
-      <Footer style={footerStyle}></Footer>
+      <Footer style={footerStyle}>
+        &copy;2023 Hendrickson Hawk Band - Power Line
+      </Footer>
       <BackTop visibilityHeight={100} />
     </_Layout>
   );

+ 10 - 3
components/Menu.js

@@ -1,11 +1,11 @@
 import Link from "next/link";
 
-import { Menu as _Menu } from "tiny-ui";
+import { Image, Menu as _Menu } from "tiny-ui";
 
 const MENU = [
   { url: "/", text: "Hawkband WLED" },
   { url: "/", text: "Automation" },
-  { url: "/instances", text: "Instances" },
+  { url: "/admin", text: "Administration" }
 ];
 
 const Menu = ({ page, ...rest }) => {
@@ -15,7 +15,14 @@ const Menu = ({ page, ...rest }) => {
     </_Menu.Item>
   ));
   if (!items) return null;
-  return <_Menu theme="dark">{items}</_Menu>;
+  return (
+    <_Menu theme="dark">
+      <_Menu.Item>
+        <Image src="/logo.webp" width={52} height={40} alt="Hawk Head logo" />
+      </_Menu.Item>
+      {items}
+    </_Menu>
+  );
 };
 
 export default Menu;

+ 0 - 18
data/automation copy.json

@@ -1,18 +0,0 @@
-{
-  "1": ["10.10.10.100"],
-  "2": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "3": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "4": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "5": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "6": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "7": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "8": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "9": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "10": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "11": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "12": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "13": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "14": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "15": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "16": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"]
-}

+ 96 - 16
data/automation.json

@@ -1,18 +1,98 @@
 {
-  "1": ["10.10.10.100"],
-  "2": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "3": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "4": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "5": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "6": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "7": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "8": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "9": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "10": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "11": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "12": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "13": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "14": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "15": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"],
-  "16": ["10.10.10.100", "10.10.10.101", "10.10.10.102", "10.10.10.103"]
+  "1": {
+    "10.10.10.100": { "on": true, "sync": false },
+    "10.10.10.101": { "on": false, "sync": false },
+    "10.10.10.102": { "on": false, "sync": false },
+    "10.10.10.103": { "on": false, "sync": false }
+  },
+  "2": {
+    "10.10.10.100": { "on": true, "sync": true },
+    "10.10.10.101": { "on": true, "sync": true },
+    "10.10.10.102": { "on": true, "sync": true },
+    "10.10.10.103": { "on": true, "sync": true }
+  },
+  "3": {
+    "10.10.10.100": { "on": true, "sync": true },
+    "10.10.10.101": { "on": true, "sync": true },
+    "10.10.10.102": { "on": true, "sync": true },
+    "10.10.10.103": { "on": true, "sync": true }
+  },
+  "4": {
+    "10.10.10.100": { "on": true, "sync": true },
+    "10.10.10.101": { "on": true, "sync": true },
+    "10.10.10.102": { "on": true, "sync": true },
+    "10.10.10.103": { "on": true, "sync": true }
+  },
+  "5": {
+    "10.10.10.100": { "on": true, "sync": true },
+    "10.10.10.101": { "on": true, "sync": true },
+    "10.10.10.102": { "on": true, "sync": true },
+    "10.10.10.103": { "on": true, "sync": true }
+  },
+  "6": {
+    "10.10.10.100": { "on": true, "sync": true },
+    "10.10.10.101": { "on": true, "sync": true },
+    "10.10.10.102": { "on": true, "sync": true },
+    "10.10.10.103": { "on": true, "sync": true }
+  },
+  "7": {
+    "10.10.10.100": { "on": true, "sync": true },
+    "10.10.10.101": { "on": true, "sync": true },
+    "10.10.10.102": { "on": true, "sync": true },
+    "10.10.10.103": { "on": true, "sync": true }
+  },
+  "8": {
+    "10.10.10.100": { "on": true, "sync": true },
+    "10.10.10.101": { "on": true, "sync": true },
+    "10.10.10.102": { "on": true, "sync": true },
+    "10.10.10.103": { "on": true, "sync": true }
+  },
+  "9": {
+    "10.10.10.100": { "on": true, "sync": true },
+    "10.10.10.101": { "on": true, "sync": true },
+    "10.10.10.102": { "on": true, "sync": true },
+    "10.10.10.103": { "on": true, "sync": true }
+  },
+  "10": {
+    "10.10.10.100": { "on": true, "sync": true },
+    "10.10.10.101": { "on": true, "sync": true },
+    "10.10.10.102": { "on": true, "sync": true },
+    "10.10.10.103": { "on": true, "sync": true }
+  },
+  "11": {
+    "10.10.10.100": { "on": true, "sync": true },
+    "10.10.10.101": { "on": true, "sync": true },
+    "10.10.10.102": { "on": true, "sync": true },
+    "10.10.10.103": { "on": true, "sync": true }
+  },
+  "12": {
+    "10.10.10.100": { "on": true, "sync": true },
+    "10.10.10.101": { "on": true, "sync": true },
+    "10.10.10.102": { "on": true, "sync": true },
+    "10.10.10.103": { "on": true, "sync": true }
+  },
+  "13": {
+    "10.10.10.100": { "on": true, "sync": true },
+    "10.10.10.101": { "on": true, "sync": true },
+    "10.10.10.102": { "on": true, "sync": true },
+    "10.10.10.103": { "on": true, "sync": true }
+  },
+  "14": {
+    "10.10.10.100": { "on": true, "sync": true },
+    "10.10.10.101": { "on": true, "sync": true },
+    "10.10.10.102": { "on": true, "sync": true },
+    "10.10.10.103": { "on": true, "sync": true }
+  },
+  "15": {
+    "10.10.10.100": { "on": true, "sync": true },
+    "10.10.10.101": { "on": true, "sync": true },
+    "10.10.10.102": { "on": true, "sync": true },
+    "10.10.10.103": { "on": true, "sync": true }
+  },
+  "16": {
+    "10.10.10.100": { "on": true, "sync": true },
+    "10.10.10.101": { "on": true, "sync": true },
+    "10.10.10.102": { "on": true, "sync": true },
+    "10.10.10.103": { "on": true, "sync": true }
+  }
 }

+ 32 - 32
data/presets.json

@@ -8,7 +8,7 @@
       {
         "id": 0,
         "start": 0,
-        "stop": 46,
+        "stop": 500,
         "grouping": 1,
         "spacing": 0,
         "of": 0,
@@ -16,7 +16,7 @@
         "freeze": false,
         "brightness": 255,
         "cct": 127,
-        "name": "1 Powerline",
+        "name": "Powerline",
         "colors": [
           [0, 30, 255],
           [0, 0, 0],
@@ -135,7 +135,7 @@
       {
         "id": 0,
         "start": 0,
-        "stop": 46,
+        "stop": 500,
         "grouping": 1,
         "spacing": 0,
         "of": 0,
@@ -143,7 +143,7 @@
         "freeze": false,
         "brightness": 255,
         "cct": 127,
-        "name": "1 Powerline",
+        "name": "Powerline",
         "colors": [
           [0, 0, 255],
           [0, 0, 0],
@@ -262,7 +262,7 @@
       {
         "id": 0,
         "start": 0,
-        "stop": 46,
+        "stop": 500,
         "grouping": 1,
         "spacing": 0,
         "of": 0,
@@ -270,7 +270,7 @@
         "freeze": false,
         "brightness": 255,
         "cct": 127,
-        "name": "1 Powerline",
+        "name": "Powerline",
         "colors": [
           [0, 0, 255],
           [0, 0, 0],
@@ -389,7 +389,7 @@
       {
         "id": 0,
         "start": 0,
-        "stop": 46,
+        "stop": 500,
         "grouping": 1,
         "spacing": 0,
         "of": 0,
@@ -397,7 +397,7 @@
         "freeze": false,
         "brightness": 255,
         "cct": 127,
-        "name": "1 Powerline",
+        "name": "Powerline",
         "colors": [
           [0, 0, 255],
           [0, 0, 0],
@@ -516,7 +516,7 @@
       {
         "id": 0,
         "start": 0,
-        "stop": 46,
+        "stop": 500,
         "grouping": 1,
         "spacing": 0,
         "of": 0,
@@ -524,7 +524,7 @@
         "freeze": false,
         "brightness": 255,
         "cct": 127,
-        "name": "1 Powerline",
+        "name": "Powerline",
         "colors": [
           [0, 0, 255],
           [0, 0, 0],
@@ -643,7 +643,7 @@
       {
         "id": 0,
         "start": 0,
-        "stop": 46,
+        "stop": 500,
         "grouping": 1,
         "spacing": 0,
         "of": 0,
@@ -651,7 +651,7 @@
         "freeze": false,
         "brightness": 255,
         "cct": 127,
-        "name": "1 Powerline",
+        "name": "Powerline",
         "colors": [
           [255, 0, 255],
           [0, 0, 0],
@@ -770,7 +770,7 @@
       {
         "id": 0,
         "start": 0,
-        "stop": 46,
+        "stop": 500,
         "grouping": 1,
         "spacing": 0,
         "of": 0,
@@ -778,7 +778,7 @@
         "freeze": false,
         "brightness": 255,
         "cct": 127,
-        "name": "1 Powerline",
+        "name": "Powerline",
         "colors": [
           [0, 0, 255],
           [183, 38, 255],
@@ -897,7 +897,7 @@
       {
         "id": 0,
         "start": 0,
-        "stop": 46,
+        "stop": 500,
         "grouping": 1,
         "spacing": 0,
         "of": 0,
@@ -905,7 +905,7 @@
         "freeze": false,
         "brightness": 255,
         "cct": 127,
-        "name": "1 Powerline",
+        "name": "Powerline",
         "colors": [
           [0, 0, 255],
           [0, 0, 0],
@@ -1024,7 +1024,7 @@
       {
         "id": 0,
         "start": 0,
-        "stop": 46,
+        "stop": 500,
         "grouping": 1,
         "spacing": 0,
         "of": 0,
@@ -1032,7 +1032,7 @@
         "freeze": false,
         "brightness": 255,
         "cct": 127,
-        "name": "1 Powerline",
+        "name": "Powerline",
         "colors": [
           [0, 30, 255],
           [0, 0, 0],
@@ -1151,7 +1151,7 @@
       {
         "id": 0,
         "start": 0,
-        "stop": 46,
+        "stop": 500,
         "grouping": 1,
         "spacing": 0,
         "of": 0,
@@ -1159,7 +1159,7 @@
         "freeze": false,
         "brightness": 255,
         "cct": 127,
-        "name": "1 Powerline",
+        "name": "Powerline",
         "colors": [
           [0, 0, 255],
           [0, 0, 0],
@@ -1278,7 +1278,7 @@
       {
         "id": 0,
         "start": 0,
-        "stop": 46,
+        "stop": 500,
         "grouping": 1,
         "spacing": 0,
         "of": 0,
@@ -1286,7 +1286,7 @@
         "freeze": false,
         "brightness": 255,
         "cct": 127,
-        "name": "1 Powerline",
+        "name": "Powerline",
         "colors": [
           [255, 0, 0],
           [0, 0, 0],
@@ -1405,7 +1405,7 @@
       {
         "id": 0,
         "start": 0,
-        "stop": 46,
+        "stop": 500,
         "grouping": 1,
         "spacing": 0,
         "of": 0,
@@ -1413,7 +1413,7 @@
         "freeze": false,
         "brightness": 255,
         "cct": 127,
-        "name": "1 Powerline",
+        "name": "Powerline",
         "colors": [
           [0, 0, 0],
           [0, 0, 0],
@@ -1532,7 +1532,7 @@
       {
         "id": 0,
         "start": 0,
-        "stop": 46,
+        "stop": 500,
         "grouping": 1,
         "spacing": 0,
         "of": 0,
@@ -1540,7 +1540,7 @@
         "freeze": false,
         "brightness": 255,
         "cct": 127,
-        "name": "1 Powerline",
+        "name": "Powerline",
         "colors": [
           [8, 255, 0],
           [255, 200, 0],
@@ -1659,7 +1659,7 @@
       {
         "id": 0,
         "start": 0,
-        "stop": 46,
+        "stop": 500,
         "grouping": 1,
         "spacing": 0,
         "of": 0,
@@ -1667,7 +1667,7 @@
         "freeze": false,
         "brightness": 255,
         "cct": 127,
-        "name": "1 Powerline",
+        "name": "Powerline",
         "colors": [
           [8, 255, 0],
           [255, 200, 0],
@@ -1786,7 +1786,7 @@
       {
         "id": 0,
         "start": 0,
-        "stop": 46,
+        "stop": 500,
         "grouping": 1,
         "spacing": 0,
         "of": 0,
@@ -1794,7 +1794,7 @@
         "freeze": false,
         "brightness": 255,
         "cct": 127,
-        "name": "1 Powerline",
+        "name": "Powerline",
         "colors": [
           [8, 255, 0],
           [0, 0, 0],
@@ -1913,7 +1913,7 @@
       {
         "id": 0,
         "start": 0,
-        "stop": 46,
+        "stop": 500,
         "grouping": 1,
         "spacing": 0,
         "of": 0,
@@ -1921,7 +1921,7 @@
         "freeze": false,
         "brightness": 255,
         "cct": 127,
-        "name": "1 Powerline",
+        "name": "Powerline",
         "colors": [
           [8, 255, 0],
           [0, 0, 0],

+ 68 - 0
lib/state.js

@@ -0,0 +1,68 @@
+import { useEffect, useRef, useState } from "react";
+
+import { isFunction, parse, stringify } from "lib/utils";
+
+const hasWindow = () => {
+  return typeof window !== "undefined" ? true : false;
+};
+
+const hasLocalStorage = () => {
+  return hasWindow() && window.localStorage ? true : false;
+};
+
+const getItem = (key) => {
+  return hasLocalStorage() ? window.localStorage.getItem(key) : null;
+};
+
+const setItem = (key, value) => {
+  return hasLocalStorage() ? window.localStorage.setItem(key, value) : false;
+};
+
+const removeItem = (key) => {
+  return hasLocalStorage() ? window.localStorage.removeItem(key) : false;
+};
+
+// use local storage to store state values for persistent values
+export function useLocalStorage(key, initialValue) {
+  const [storedValue, setStoredValue] = useState(() => {
+    try {
+      const item = getItem(key);
+      return item ? parse(item) : initialValue;
+    } catch (err) {
+      log.error("useLocalStorage useState error", err.message || err);
+      return initialValue;
+    }
+  });
+
+  const setValue = (value) => {
+    try {
+      const item = isFunction(value) ? value(storedValue) : value;
+      setStoredValue(item);
+      setItem(key, stringify(item));
+    } catch (err) {
+      log.error("useLocalStorage setValue error", err.message || err);
+    }
+  };
+
+  return [storedValue, setValue];
+}
+
+export function useStateCallback(initialState) {
+  const [state, setState] = useState(initialState);
+  const cbRef = useRef(null); // mutable ref to store current callback
+
+  const setStateCallback = (state, cb) => {
+    cbRef.current = cb; // store passed callback to ref
+    setState(state);
+  };
+
+  useEffect(() => {
+    // cb.current is `null` on initial render, so we only execute cb on state *updates*
+    if (cbRef.current) {
+      cbRef.current(state);
+      cbRef.current = null; // reset callback after execution
+    }
+  }, [state]);
+
+  return [state, setStateCallback];
+}

+ 502 - 0
lib/utils.js

@@ -0,0 +1,502 @@
+const {
+  isEmpty: _isEmpty,
+  isEqual: _isEqual,
+  isNaN: _isNaN,
+  isNumber: _isNumber,
+  merge,
+  omit,
+  omitBy,
+  parseInt: _parseInt,
+  pick
+} = require("lodash");
+
+const parse = (value) => {
+  try {
+    return JSON.parse(value);
+  } catch (_) {
+    return value;
+  }
+};
+
+const stringify = (value, opts) => {
+  try {
+    return opts ? JSON.stringify(value, null, opts) : JSON.stringify(value);
+  } catch (_) {
+    return value;
+  }
+};
+
+const btoa = (value, options = {}) => {
+  const { urlsafe = true } = options;
+  let result = Buffer.from(value, "binary").toString("base64");
+  if (urlsafe)
+    result = result.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+  return result;
+};
+
+const atob = (value, options = {}) => {
+  const { urlsafe = true } = options;
+  if (urlsafe) value = value.replace(/-/g, "+").replace(/_/g, "/");
+  if (urlsafe && value && value.toString().length % 4 !== 0)
+    value += "===".slice(0, 4 - (value.toString().length % 4));
+  let result = Buffer.from(value, "base64").toString("binary");
+  return result.toString();
+};
+
+const querystring = (params = {}, options = {}) => {
+  if (!isObject(params)) return;
+  const { exclude = [], compress = false } = options;
+  const encodeAndCompress = (param) => {
+    const json = stringify(param);
+    return compress ? pako.deflate(json, { to: "string" }) : json;
+    // return compress ? encodeURI(pako.deflate(json, { to: "string" })) : encodeURI(json);
+  };
+  const props = {};
+  for (let key of Object.keys(params)) {
+    props[key] = isObject(params[key])
+      ? encodeAndCompress(params[key])
+      : params[key];
+  }
+  return new URLSearchParams(omit(props, exclude)).toString();
+};
+
+const safeEncodeURIComponent = (v) => {
+  v = parse(v); // try to parse the value
+  try {
+    if (v && (Array.isArray(v) || isObject(v))) v = stringify(v); // is it an array? or object? stringify
+  } catch (err) {
+    // do nothing use valu v
+  }
+  try {
+    return encodeURIComponent(v); // encodeURIComponent the value and resurn it
+  } catch (err) {
+    return v; // fail and return v
+  }
+};
+
+const language = () => {
+  return (
+    (navigator.languages && navigator.languages[0]) ||
+    navigator.language ||
+    navigator.userLanguage
+  );
+};
+
+const uuid = () => Math.random().toString(36).substr(2, 9);
+const uniqueId = (x = 4, j = "-") =>
+  [...Array(x).keys()].map(() => uuid()).join(j);
+
+const normalizeUrl = (url) => {
+  let pattern = /^((http|https|ftp):\/\/)/;
+  if (url && url !== "") {
+    url = url.toString();
+    if (!pattern.test(url) && !url.startsWith("/")) url = "http://" + url;
+  }
+  return url;
+};
+
+const normalizeJSON = (value, fallback = null) => {
+  if (isString(value)) return parse(value) || fallback;
+  else if (isObject(value)) return value || fallback;
+  else return fallback;
+};
+
+const safeSearchName = (text = "") =>
+  encodeURIComponent(text || "")
+    .toString()
+    .trim()
+    .replace(/%20/g, "+");
+
+const kebabCase = (text = "") =>
+  (text || "")
+    .toString() // convert to string to avoid issues
+    .replace(/\s+/, "-") // spaces to dash
+    .replace(/([a-z])([A-Z])/g, "$1-$2") // kebab the string
+    .replace(/[\s_]+/g, "-")
+    .replace(/-+/g, "-") // only one dash per kebab
+    .replace(/^-/g, "") // no starting dash
+    .replace(/-$/g, "") // no ending dash
+    .replace(/[^0-9a-z-_]/gi, "") // only alpha numeric
+    .toLowerCase(); // lowercase only
+
+const safeIdName = (text = "") =>
+  kebabCase((text || "").toString().replace(/\s\s+/g, " "));
+
+const slugify = (text = "") => kebabCase(text || "");
+
+const baseUrl = (path) => {
+  if (!hasWindow()) return `${BASE_URL}${path}`;
+  const { location: { origin } = {} } = window;
+  return `${origin || BASE_URL}${path || ""}`;
+};
+
+const toBoolean = (value) => {
+  switch (value) {
+    case true:
+    case "true":
+    case "True":
+    case "TRUE":
+    case 1:
+    case "1":
+    case "on":
+    case "On":
+    case "ON":
+    case "yes":
+    case "Yes":
+    case "YES":
+      return true;
+    default:
+      return false;
+  }
+};
+
+const isBlank = (value) =>
+  (_isEmpty(value) && !_isNumber(value)) || _isNaN(value);
+
+const isEqual = (v1, v2) => _isEqual(v1, v2);
+
+const isEmpty = (value) => {
+  let result,
+    type = typeof value;
+  switch (type.toLowerCase()) {
+    case "null":
+    case "undefined":
+      result = true;
+      break;
+    case "boolean":
+    case "number":
+    case "bigint":
+      result = value === undefined || value === null ? true : false;
+      break;
+    case "symbol":
+    case "object":
+      try {
+        if (typeofDate(value)) {
+          result = false;
+        } else if (isArray(value)) {
+          result = !value || value.length === 0 ? true : false;
+        } else if (value) {
+          let entries = Object.entries(value);
+          result = !entries || entries.length === 0 ? true : false;
+        } else {
+          result = true;
+        }
+      } catch (err) {
+        log.error("isEmpty", type, err.message || err);
+        result = true;
+      }
+      break;
+    case "date":
+      result = false;
+      break;
+    case "string":
+    default:
+      result =
+        !value || value.trim() === "" || value === undefined || value === null
+          ? true
+          : false;
+      break;
+  }
+  return result;
+};
+
+const isFunction = (o) => {
+  return o && typeof o === "function" ? true : false;
+};
+
+const isJson = (o) => {
+  return isArray(o) || isObject(o) ? true : hasJsonStructure(o);
+};
+
+const isReactElement = (o) => {
+  return o?.["$$typeof"] && o["$$typeof"] === Symbol.for("react.element");
+};
+
+const hasJsonStructure = (o) => {
+  if (typeof o !== "string") return false;
+  try {
+    const result = JSON.parse(o);
+    const type = Object.prototype.toString.call(result);
+    return type === "[object Object]" || type === "[object Array]";
+  } catch (err) {
+    return false;
+  }
+};
+
+const isString = (o) => {
+  return o && typeof o === "string" ? true : false;
+};
+
+const isArray = (o) => {
+  return o && Array.isArray(o) ? true : false;
+};
+
+const isObject = (o) => {
+  return o && typeof o === "object" ? true : false;
+};
+
+const isUndefined = (o) => {
+  return typeof o === "undefined" ? true : false;
+};
+
+const hasWindow = () => {
+  return typeof window !== "undefined" && window ? true : false;
+};
+
+const hasNavigator = () => {
+  return typeof navigator !== "undefined" && navigator ? true : false;
+};
+
+const hasDocument = () => {
+  return typeof document !== "undefined" && document ? true : false;
+};
+
+const typeofDate = (value) => {
+  return (
+    value &&
+    Object.prototype.toString.call(value) === "[object Date]" &&
+    !isNaN(value)
+  );
+};
+
+const isDate = (value) => {
+  const regex = /^\d{1,2}\/\d{1,2}\/\d{4}$/;
+  return value && regex.test(value) ? true : false;
+};
+
+const isHex = (h) => {
+  try {
+    return /^#[0-9A-F]{6}$/i.test(h);
+  } catch (err) {
+    return false;
+  }
+};
+
+const isNullOrFalse = (value) => {
+  return value === null || value === undefined || value === false
+    ? true
+    : false;
+};
+
+const isNullOrZero = (value) => {
+  return value === null || value === undefined || value == 0 ? true : false;
+};
+
+const formatCurrency = (
+  value,
+  formatter = new Intl.NumberFormat("en-US", {
+    style: "currency",
+    currency: "USD"
+  })
+) => {
+  return (formatter && value && formatter.format(value)) || 0;
+};
+
+const isInt = (value) => {
+  if (value === null || value === undefined || value === "") return false;
+  return /^-?[0-9]+$/.test(value);
+};
+
+const isNumber = (value) => {
+  return value % 1 === 0 ? true : false;
+};
+
+const isUrl = (value) => {
+  let url;
+  try {
+    url = new URL(value);
+  } catch (_) {
+    return false;
+  }
+  return (
+    url &&
+    url.protocol &&
+    ["http:", "https:", "ftp:", "ftps:"].includes(url.protocol)
+  );
+};
+
+const clone = (obj = {}) => {
+  let result = {};
+  try {
+    result = JSON.parse(JSON.stringify(obj));
+  } catch (err) {
+    log.error("clone error", err.message || err);
+  }
+  return result;
+};
+
+const isValidUrl = (str) => {
+  let url;
+  try {
+    url = new URL(str);
+  } catch (_) {
+    return false;
+  }
+  return url.protocol === "http:" || url.protocol === "https:";
+};
+
+const asyncSome = async (arr, predicate) => {
+  for (let a of arr) {
+    if (await predicate(a)) return true;
+  }
+  return false;
+};
+
+const asyncEvery = async (arr, predicate) => {
+  for (let a of arr) {
+    if (!(await predicate(a))) return false;
+  }
+  return true;
+};
+
+const objectToString = (value, delim = ",") => {
+  try {
+    if (!value) return null;
+    if (isString(value)) return value;
+    else if (isArray(value)) return value.join(delim);
+    else return stringify(value, null, 2);
+  } catch (err) {
+    return null;
+  }
+};
+
+const stringToArray = (value, delim = ",", clean = true) => {
+  if (!value) return [];
+  if (value && Array.isArray(value)) return value;
+  let result = [];
+  try {
+    result = (value && value.toString().split(delim)) || [];
+    if (clean)
+      result =
+        (result &&
+          result.map((v) => v.trim()).filter((v) => v && !isEmpty(v))) ||
+        [];
+  } catch (err) {
+    result = [];
+  }
+  return result;
+};
+
+const stringToObject = (value, delim = ",", clean = true) => {
+  try {
+    // if (!value) return {};
+    if (isObject(value)) return value;
+    else if (value.includes("[") && value.includes("]")) return parse(value);
+    else if (value.includes("{") && value.includes("}")) return parse(value);
+    else {
+      if (clean) value = stripNewlines(value, delim) || "";
+      return (
+        value &&
+        value
+          .toString()
+          .split(delim)
+          .map((v) => v.trim())
+          .filter((v) => !isEmpty(v))
+      );
+    }
+  } catch (err) {
+    return {};
+  }
+};
+
+const stripNewlines = (value, subs = "") => {
+  if (!value) return;
+  let result;
+  try {
+    result = value.toString().replace(/(\r\n|\r|\n)/g, subs);
+  } catch (err) {
+    // do nothing
+  }
+  return result;
+};
+
+const capitalizeFirstLetter = (s) => s.charAt(0).toUpperCase() + s.slice(1);
+
+const reload = () => {
+  if (hasWindow() && window.location) window.location.reload(false);
+};
+
+const hasOwnProperty = (obj, key) => {
+  return obj && key && Object.prototype.hasOwnProperty.call(obj, key);
+};
+
+const hasKey = (needle = "", haystack = {}) => {
+  if (!needle || isEmpty(haystack)) return false;
+  let keys = Object.keys(haystack) || [];
+  return needle instanceof RegExp
+    ? keys.some((o) => o && needle.test(o))
+    : keys.some((o) => o && ciEquals(needle, o));
+};
+
+const isIterable = (obj) => {
+  if (obj) return typeof obj[Symbol.iterator] === "function";
+  return false;
+};
+
+const hasHtml = (value) => value && /<\/?[^>]*>/.test(value);
+
+const firstOfList = (value) => (isArray(value) ? value[0] : value);
+const truncateArray = (list = [], max = 7) => {
+  if (list.length < max) return list;
+  return [...list.slice(0, max), "..."];
+};
+
+module.exports = {
+  atob,
+  baseUrl,
+  btoa,
+  capitalizeFirstLetter,
+  clone,
+  firstOfList,
+  formatCurrency,
+  hasDocument,
+  hasJsonStructure,
+  hasHtml,
+  hasKey,
+  hasNavigator,
+  hasOwnProperty,
+  hasWindow,
+  isArray,
+  isBlank,
+  isDate,
+  isEqual,
+  isEmpty,
+  isFunction,
+  isHex,
+  isInt,
+  isIterable,
+  isJson,
+  isNumber,
+  isNullOrFalse,
+  isNullOrZero,
+  isObject,
+  isReactElement,
+  isValidUrl,
+  isUrl,
+  isUndefined,
+  isString,
+  language,
+  merge,
+  normalizeUrl,
+  normalizeJSON,
+  objectToString,
+  omit,
+  omitBy,
+  parse,
+  pick,
+  querystring,
+  reload,
+  safeIdName,
+  safeEncodeURIComponent,
+  safeSearchName,
+  slugify,
+  stringify,
+  stringToArray,
+  stringToObject,
+  stripNewlines,
+  toBoolean,
+  truncateArray,
+  typeofDate,
+  uuid,
+  uniqueId
+};

BIN
public/logo.webp