Browse Source

Working with new sync board

Jeremie Pardou-Piquemal 2 years ago
parent
commit
70048f5856
55 changed files with 3342 additions and 525 deletions
  1. 1 1
      backend/src/test.js
  2. 5 6
      cypress/integration/studio.spec.js
  3. 163 3
      package-lock.json
  4. 2 1
      package.json
  5. 1 1
      public/exec/test.js
  6. 5 5
      src/MainRoute.jsx
  7. 61 0
      src/components/AddItemButton.jsx
  8. 67 0
      src/components/DownloadLink.jsx
  9. 79 0
      src/components/EditInfoButton.jsx
  10. 192 0
      src/components/ItemLibrary.jsx
  11. 90 0
      src/components/mediaLibrary/ImageDropNPaste.jsx
  12. 113 0
      src/components/mediaLibrary/ImageField.jsx
  13. 25 0
      src/components/mediaLibrary/MediaLibraryButton.jsx
  14. 199 0
      src/components/mediaLibrary/MediaLibraryModal.jsx
  15. 41 0
      src/components/mediaLibrary/MediaLibraryProvider.jsx
  16. 25 0
      src/components/mediaLibrary/index.js
  17. 44 0
      src/components/messages/Composer.jsx
  18. 52 0
      src/components/messages/Message.jsx
  19. 95 0
      src/components/messages/MessageButton.jsx
  20. 95 0
      src/components/messages/MessageList.jsx
  21. 39 0
      src/components/users/UserCircle.jsx
  22. 78 0
      src/components/users/UserConfig.jsx
  23. 93 0
      src/components/users/UserList.jsx
  24. 1 1
      src/gameComponents/Image.jsx
  25. 29 13
      src/gameComponents/ItemForm.jsx
  26. 12 11
      src/gameComponents/Zone.jsx
  27. 542 0
      src/gameComponents/actionMap.js
  28. 1 2
      src/gameComponents/forms/ZoneFormFields.jsx
  29. 2 291
      src/gameComponents/index.jsx
  30. 300 0
      src/gameComponents/itemTemplates.js
  31. 2 2
      src/gameComponents/useGameItemActionMap.jsx
  32. 15 30
      src/hooks/useGame.jsx
  33. 17 0
      src/hooks/useNotify.js
  34. 67 74
      src/hooks/useSession.jsx
  35. 6 0
      src/index.jsx
  36. 1 1
      src/utils/api.js
  37. 22 26
      src/views/AutoSaveSession.jsx
  38. 91 0
      src/views/BoardView/ActionBar.jsx
  39. 1 3
      src/views/BoardView/BoardForm.jsx
  40. 53 11
      src/views/BoardView/BoardView.jsx
  41. 67 0
      src/views/BoardView/DownloadLink.jsx
  42. 18 2
      src/views/BoardView/ExportModal.jsx
  43. 5 4
      src/views/BoardView/InfoModal.jsx
  44. 53 0
      src/views/BoardView/ItemFormFactory.jsx
  45. 3 4
      src/views/BoardView/LoadSessionModal.jsx
  46. 4 6
      src/views/BoardView/NavBar.jsx
  47. 248 0
      src/views/BoardView/SelectedItemsPane.jsx
  48. 2 2
      src/views/BoardView/WelcomeModal.jsx
  49. 1 1
      src/views/GameListItem.jsx
  50. 156 0
      src/views/Session.jsx
  51. 0 0
      src/views/SessionView_old.jsx
  52. 42 15
      src/views/utils.js
  53. 2 4
      src/views/webconf/StreamList.jsx
  54. 4 4
      src/views/webconf/WebConference.jsx
  55. 10 1
      src/views/webconf/WebConferenceButton.jsx

+ 1 - 1
backend/src/test.js

@@ -1,5 +1,5 @@
 const callAnother = () => {
-  console.log("titi");
+  console.log("Foo");
 };
 
 const main = async ({ query, body, test, store }) => {

+ 5 - 6
cypress/integration/studio.spec.js

@@ -105,7 +105,7 @@ describe("Studio", () => {
 
     // Edit title
     cy.get("[title^='Configuration']").click();
-    cy.get("input[name=\"defaultName\"]").clear().type("ChangeGameName");
+    cy.get('input[name="defaultName"]').clear().type("ChangeGameName");
     cy.get(".side-panel.open [alt^='Close']").click();
 
     cy.contains("ChangeGameName");
@@ -144,15 +144,14 @@ describe("Studio", () => {
       cy.get(".item").click({ force: true });
       cy.get("button img[alt^='Edit']").click({ force: true });
 
-      cy.get("input[name=\"width\"]").clear().type("100");
-      cy.get("input[name=\"height\"]").clear().type("75");
-      cy.get("input[name=\"text\"]").clear().type("myCube");
+      cy.get('input[name="width"]').clear().type("100");
+      cy.get('input[name="height"]').clear().type("75");
+      cy.get('input[name="text"]').clear().type("myCube");
 
       cy.get(".item")
         .children()
         .first()
         .should((elt) => {
-          console.log(elt[0]);
           const { clientWidth, clientHeight } = elt[0];
           expect(clientWidth).to.equal(100);
           expect(clientHeight).to.equal(75);
@@ -165,7 +164,7 @@ describe("Studio", () => {
       cy.get(".item").click({ force: true });
       cy.get("button img[alt^='Edit']").click({ force: true });
 
-      cy.get("input[name=\"locked\"]").click();
+      cy.get('input[name="locked"]').click();
 
       cy.get(".item").should("have.class", "locked");
 

+ 163 - 3
package-lock.json

@@ -6526,6 +6526,21 @@
         "parse-passwd": "^1.0.0"
       }
     },
+    "html-parse-stringify": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+      "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+      "requires": {
+        "void-elements": "3.1.0"
+      },
+      "dependencies": {
+        "void-elements": {
+          "version": "3.1.0",
+          "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+          "integrity": "sha1-YU9/v42AHwu18GYfWy9XhXUOTwk="
+        }
+      }
+    },
     "html-parse-stringify2": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/html-parse-stringify2/-/html-parse-stringify2-2.0.1.tgz",
@@ -9193,6 +9208,151 @@
         "tiny-warning": "^1.0.0"
       }
     },
+    "react-sync-board": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/react-sync-board/-/react-sync-board-0.0.2.tgz",
+      "integrity": "sha512-p0LZDvcnMcBj7gukKjJEGCCa4uxKqE8kdp7LlFazz9LQzZBjqPG+Q0PiP1GtswNRSncIHoVVWzm4KtCPe51+jg==",
+      "requires": {
+        "chota": "^0.8.0",
+        "client2client.io": "^2.0.3",
+        "color2k": "^1.2.4",
+        "dayjs": "^1.10.5",
+        "diacritic": "0.0.2",
+        "final-form": "^4.20.2",
+        "i18next": "^20.3.2",
+        "lodash.debounce": "^4.0.8",
+        "lodash.findlast": "^4.6.0",
+        "lodash.intersection": "^4.4.0",
+        "lodash.throttle": "^4.1.1",
+        "nanoid": "^3.1.23",
+        "platform": "^1.3.6",
+        "randomcolor": "^0.6.2",
+        "rc-slider": "^9.7.2",
+        "react-color": "^2.19.3",
+        "react-confirm-alert": "^2.7.0",
+        "react-dropzone": "^11.3.4",
+        "react-final-form": "^6.5.3",
+        "react-i18next": "^11.11.0",
+        "react-query": "^3.17.3",
+        "react-toastify": "^7.0.4",
+        "react-useportal": "^1.0.14",
+        "recoil": "^0.3.1"
+      },
+      "dependencies": {
+        "client2client.io": {
+          "version": "2.0.3",
+          "resolved": "https://registry.npmjs.org/client2client.io/-/client2client.io-2.0.3.tgz",
+          "integrity": "sha512-BjOSHQhq83t//5iqtaDC9HJf8PWESbAsjQYncE+yZABkxFeqq4UZlesIvlI7x4gpFSBbgaPf7acNbAl4C391nw==",
+          "requires": {
+            "nanoid": "^3.1.20"
+          }
+        },
+        "color2k": {
+          "version": "1.2.4",
+          "resolved": "https://registry.npmjs.org/color2k/-/color2k-1.2.4.tgz",
+          "integrity": "sha512-DiwdBwc0BryPFFXoCrW8XQGXl2rEtMToODybxZjKnN5IJXt/tV/FsN02pCK/b7KcWvJEioz3c74lQSmayFvS4Q=="
+        },
+        "dayjs": {
+          "version": "1.10.6",
+          "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.6.tgz",
+          "integrity": "sha512-AztC/IOW4L1Q41A86phW5Thhcrco3xuAA+YX/BLpLWWjRcTj5TOt/QImBLmCKlrF7u7k47arTnOyL6GnbG8Hvw=="
+        },
+        "file-selector": {
+          "version": "0.2.4",
+          "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.2.4.tgz",
+          "integrity": "sha512-ZDsQNbrv6qRi1YTDOEWzf5J2KjZ9KMI1Q2SGeTkCJmNNW25Jg4TW4UMcmoqcg4WrAyKRcpBXdbWRxkfrOzVRbA==",
+          "requires": {
+            "tslib": "^2.0.3"
+          }
+        },
+        "final-form": {
+          "version": "4.20.2",
+          "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.2.tgz",
+          "integrity": "sha512-5i0IxqwjjPG1nUNCjWhqPCvQJJ2R+QwTwaAnjPmFnLbyjIHWuBPU8u+Ps4G3TcX2Sjno+O5xCZJzYcMJEzzfCQ==",
+          "requires": {
+            "@babel/runtime": "^7.10.0"
+          }
+        },
+        "i18next": {
+          "version": "20.3.2",
+          "resolved": "https://registry.npmjs.org/i18next/-/i18next-20.3.2.tgz",
+          "integrity": "sha512-e8CML2R9Ng2sSQOM80wb/PrM2j8mDm84o/T4Amzn9ArVyNX5/ENWxxAXkRpZdTQNDaxKImF93Wep4mAoozFrKw==",
+          "requires": {
+            "@babel/runtime": "^7.12.0"
+          }
+        },
+        "nanoid": {
+          "version": "3.1.23",
+          "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
+          "integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw=="
+        },
+        "randomcolor": {
+          "version": "0.6.2",
+          "resolved": "https://registry.npmjs.org/randomcolor/-/randomcolor-0.6.2.tgz",
+          "integrity": "sha512-Mn6TbyYpFgwFuQ8KJKqf3bqqY9O1y37/0jgSK/61PUxV4QfIMv0+K2ioq8DfOjkBslcjwSzRfIDEXfzA9aCx7A=="
+        },
+        "react-confirm-alert": {
+          "version": "2.7.0",
+          "resolved": "https://registry.npmjs.org/react-confirm-alert/-/react-confirm-alert-2.7.0.tgz",
+          "integrity": "sha512-21NWtGK/e85+ZX3TLRpMc3IsU5Kj6Z9ElCOrkTIlwMzV9EancyXNlkqHGbtKP63a2iS6g5hOxROokmJOqKQiXA=="
+        },
+        "react-dropzone": {
+          "version": "11.3.4",
+          "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-11.3.4.tgz",
+          "integrity": "sha512-B1nzNRZ4F1cnrfEC0T6KXeBN1mCPinu4JCoTrp7NjB+442KSPxqfDrw41QIA2kAwlYs1+wj/0BTedeM5hc2+xw==",
+          "requires": {
+            "attr-accept": "^2.2.1",
+            "file-selector": "^0.2.2",
+            "prop-types": "^15.7.2"
+          }
+        },
+        "react-final-form": {
+          "version": "6.5.3",
+          "resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.3.tgz",
+          "integrity": "sha512-FCs6GC0AMWJl2p6YX7kM+a0AvuSLAZUgbVNtRBskOs4g984t/It0qGtx51O+9vgqnqk6JyoxmIzxKMq+7ch/vg==",
+          "requires": {
+            "@babel/runtime": "^7.12.1"
+          }
+        },
+        "react-i18next": {
+          "version": "11.11.1",
+          "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.11.1.tgz",
+          "integrity": "sha512-MtfzPEOvx0ev2cz0HmrEcduuqkktSa5bfeN8Flp0cvN0xP3H3MRSTa8P6pxPtqstHbRe1cD9QBOr5T/FEV2gOw==",
+          "requires": {
+            "@babel/runtime": "^7.14.5",
+            "html-parse-stringify": "^3.0.1"
+          },
+          "dependencies": {
+            "@babel/runtime": {
+              "version": "7.14.6",
+              "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.14.6.tgz",
+              "integrity": "sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg==",
+              "requires": {
+                "regenerator-runtime": "^0.13.4"
+              }
+            }
+          }
+        },
+        "react-query": {
+          "version": "3.18.1",
+          "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.18.1.tgz",
+          "integrity": "sha512-17lv3pQxU9n+cB5acUv0/cxNTjo9q8G+RsedC6Ax4V9D8xEM7Q5xf9xAbCPdEhDrrnzPjTls9fQEABKRSi7OJA==",
+          "requires": {
+            "@babel/runtime": "^7.5.5",
+            "broadcast-channel": "^3.4.1",
+            "match-sorter": "^6.0.2"
+          }
+        },
+        "react-toastify": {
+          "version": "7.0.4",
+          "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-7.0.4.tgz",
+          "integrity": "sha512-Rol7+Cn39hZp5hQ/k6CbMNE2CKYV9E5OQdC/hBLtIQU2xz7DdAm7xil4NITQTHR6zEbE5RVFbpgSwTD7xRGLeQ==",
+          "requires": {
+            "clsx": "^1.1.1"
+          }
+        }
+      }
+    },
     "react-toastify": {
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-6.1.0.tgz",
@@ -9268,9 +9428,9 @@
       }
     },
     "recoil": {
-      "version": "0.2.0",
-      "resolved": "https://registry.npmjs.org/recoil/-/recoil-0.2.0.tgz",
-      "integrity": "sha512-VOJfYVQ3VgmfS7L5tV9QdOR+AJhvll8yGr1+3nJPCqADulImuScGZ2sJtejPps3zfTu/o98y5kO4lje8Tx6XHw==",
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/recoil/-/recoil-0.3.1.tgz",
+      "integrity": "sha512-KNA3DRqgxX4rRC8E7fc6uIw7BACmMPuraIYy+ejhE8tsw7w32CetMm8w7AMZa34wzanKKkev3vl3H7Z4s0QSiA==",
       "requires": {
         "hamt_plus": "1.0.2"
       }

+ 2 - 1
package.json

@@ -37,11 +37,12 @@
     "react-query": "^3.13.4",
     "react-router": "^5.2.0",
     "react-router-dom": "^5.2.0",
+    "react-sync-board": "0.0.2",
     "react-toastify": "^6.1.0",
     "react-use-localstorage": "^3.4.1",
     "react-useportal": "^1.0.14",
     "react-zoom-pan-pinch": "^1.6.1",
-    "recoil": "^0.2.0",
+    "recoil": "^0.3.1",
     "socket.io-client": "^4.1.2",
     "styled-components": "^5.3.0",
     "use-async-effect": "^2.2.3"

+ 1 - 1
public/exec/test.js

@@ -1,5 +1,5 @@
 const callAnother = () => {
-  console.log("titi");
+  console.log("Foo");
 };
 
 const main = async () => {

+ 5 - 5
src/MainRoute.jsx

@@ -9,7 +9,7 @@ import "./react-confirm-alert.css";
 
 import Home from "./views/Home";
 // import GameView from "./views/GameView";
-// import RoomWrapperView from "./views/RoomWrapperView";
+import Session from "./views/Session";
 import AuthView from "./views/AuthView";
 // import RoomView from "./views/RoomView";
 
@@ -65,7 +65,7 @@ const MainRoute = () => {
           );
         }}
       </Route>
-      {/*<Route path="/session/:sessionId">
+      <Route path="/session/:sessionId">
         {({
           location: { search },
           match: {
@@ -78,12 +78,12 @@ const MainRoute = () => {
           // Redirect to new session id
           return (
             <WithSocketIO>
-              <RoomWrapperView sessionId={sessionId} fromGame={fromGame} />
+              <Session sessionId={sessionId} fromGame={fromGame} />
             </WithSocketIO>
           );
         }}
-      </Route>*/}
-      {/* Game edition/}
+      </Route>
+      {/* Game edition }
       <Route path="/game/:gameId?">
         <WithSocketIO>
           <GameView />

+ 61 - 0
src/components/AddItemButton.jsx

@@ -0,0 +1,61 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import ItemLibrary from "./ItemLibrary";
+
+import Touch from "./ui/Touch";
+import SidePanel from "./ui/SidePanel";
+
+const AddItemPanel = ({ itemLibraries, open, onClose }) => {
+  const [tab, setTab] = React.useState(itemLibraries[0]?.key || "standard");
+
+  React.useEffect(() => {
+    setTab(itemLibraries[0]?.key || "standard");
+  }, [itemLibraries]);
+
+  return (
+    <SidePanel open={open} onClose={onClose} position="right" width="33%">
+      <nav className="tabs">
+        {itemLibraries.map(({ name, key }) => (
+          <a
+            onClick={() => setTab(key)}
+            className={tab === key ? "active" : ""}
+            style={{ cursor: "pointer" }}
+            key={key}
+          >
+            {name}
+          </a>
+        ))}
+      </nav>
+      <section className="content">
+        {itemLibraries.map(({ key, items }) =>
+          tab === key ? <ItemLibrary items={items} key={key} /> : null
+        )}
+      </section>
+    </SidePanel>
+  );
+};
+
+const AddItemButton = ({ itemLibraries }) => {
+  const { t } = useTranslation();
+
+  const [showAddPanel, setShowAddPanel] = React.useState(false);
+
+  return (
+    <>
+      <Touch
+        onClick={() => setShowAddPanel((prev) => !prev)}
+        alt={t("Add item")}
+        title={t("Add item")}
+        label={t("Add")}
+        icon={showAddPanel ? "cross" : "plus"}
+      />
+      <AddItemPanel
+        itemLibraries={itemLibraries}
+        open={showAddPanel}
+        onClose={() => setShowAddPanel(false)}
+      />
+    </>
+  );
+};
+
+export default AddItemButton;

+ 67 - 0
src/components/DownloadLink.jsx

@@ -0,0 +1,67 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+
+const generateDownloadURI = (data) => {
+  return (
+    "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data))
+  );
+};
+
+export const DownloadLink = ({ getData = () => {} }) => {
+  const { t } = useTranslation();
+
+  const [downloadURI, setDownloadURI] = React.useState("");
+  const [date, setDate] = React.useState(Date.now());
+  const [genOnce, setGenOnce] = React.useState(false);
+
+  const updateSaveLink = React.useCallback(async () => {
+    const data = await getData();
+    if (data.items.length) {
+      setDownloadURI(generateDownloadURI(data));
+      setDate(Date.now());
+      setGenOnce(true);
+    }
+  }, [getData]);
+
+  React.useEffect(() => {
+    let mounted = true;
+
+    const cancel = setInterval(() => {
+      if (!mounted) return;
+      updateSaveLink();
+    }, 2000);
+
+    updateSaveLink();
+
+    return () => {
+      mounted = false;
+      setGenOnce(false);
+      clearInterval(cancel);
+    };
+  }, [updateSaveLink]);
+
+  return (
+    <>
+      {genOnce && (
+        <a
+          className="button success icon"
+          href={downloadURI}
+          download={`airboardgame_${date}.json`}
+        >
+          {t("Export")}
+          <img
+            src={"https://icongr.am/entypo/download.svg?size=20&color=f9fbfa"}
+            alt="icon"
+          />
+        </a>
+      )}
+      {!genOnce && (
+        <button className="button" disabled>
+          {t("Generating export")}...
+        </button>
+      )}
+    </>
+  );
+};
+
+export default DownloadLink;

+ 79 - 0
src/components/EditInfoButton.jsx

@@ -0,0 +1,79 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import styled from "styled-components";
+import { Form } from "react-final-form";
+
+import Touch from "./ui/Touch";
+import Modal from "./ui/Modal";
+import AutoSave from "./ui/formUtils/AutoSave";
+
+import { useBoardConfig } from "react-sync-board";
+
+const BoardConfigForm = styled.div`
+  display: flex;
+  flex-direction: column;
+  & .trash {
+    float: right;
+  }
+`;
+
+const BoardConfigModal = ({ BoardFormComponent, show, setShow }) => {
+  const { t } = useTranslation();
+  const [, setBoardConfig] = useBoardConfig();
+
+  const onSubmitHandler = React.useCallback(
+    (data) => {
+      setBoardConfig((prev) => ({
+        ...prev,
+        ...data,
+      }));
+    },
+    [setBoardConfig]
+  );
+
+  return (
+    <Modal
+      title={t("Edit game information")}
+      setShow={setShow}
+      show={show}
+      position="left"
+    >
+      <section>
+        <Form
+          onSubmit={onSubmitHandler}
+          render={() => (
+            <BoardConfigForm>
+              <AutoSave save={onSubmitHandler} />
+              <BoardFormComponent />
+            </BoardConfigForm>
+          )}
+        />
+      </section>
+    </Modal>
+  );
+};
+
+const EditInfoButton = ({ BoardFormComponent }) => {
+  const { t } = useTranslation();
+
+  const [show, setShow] = React.useState(false);
+
+  return (
+    <>
+      <Touch
+        onClick={() => setShow((prev) => !prev)}
+        alt={t("Edit game info")}
+        title={t("Edit game info")}
+        label={t("Edit game info")}
+        icon={"cog"}
+      />
+      <BoardConfigModal
+        BoardFormComponent={BoardFormComponent}
+        show={show}
+        setShow={setShow}
+      />
+    </>
+  );
+};
+
+export default EditInfoButton;

+ 192 - 0
src/components/ItemLibrary.jsx

@@ -0,0 +1,192 @@
+import React, { memo } from "react";
+import { useTranslation } from "react-i18next";
+import { nanoid } from "nanoid";
+import styled from "styled-components";
+import { useRecoilCallback } from "recoil";
+import debounce from "lodash.debounce";
+
+import { useItemBaseActions } from "react-sync-board";
+
+import { search } from "../views/utils";
+
+import Chevron from "./ui/Chevron";
+
+const StyledItemList = styled.ul`
+  display: flex;
+  flex-flow: row wrap;
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  & li.group {
+    background-color: rgba(0, 0, 0, 0.1);
+    padding: 0 0.5em;
+    flex-basis: 100%;
+  }
+  overflow: visible;
+`;
+
+const StyledItem = styled.li`
+  display: block;
+  padding: 0.5em;
+  margin: 0.2em;
+  cursor: pointer;
+  opacity: 0.7;
+
+  &:hover {
+    opacity: 1;
+  }
+  & > div {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    pointer-events: none;
+    max-width: 80px;
+    & > span {
+      margin-top: 0.2em;
+      text-align: center;
+      max-width: 80px;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      padding: 0.2em 0.5em;
+    }
+  }
+  &:hover > div > span {
+    z-index: 2;
+    max-width: none;
+    overflow: visible;
+    background-color: #222;
+    box-shadow: 0px 3px 6px #00000029;
+  }
+`;
+
+const size = 60;
+
+const NewItem = memo(({ type, template, component: Component, name }) => {
+  const { pushItem } = useItemBaseActions();
+
+  const addItem = React.useCallback(async () => {
+    pushItem({
+      ...template,
+      id: nanoid(),
+      type,
+    });
+  }, [pushItem, template, type]);
+
+  return (
+    <StyledItem onClick={addItem}>
+      <div>
+        <Component {...template} width={size} height={size} size={size} />
+        <span>{name}</span>
+      </div>
+    </StyledItem>
+  );
+});
+
+NewItem.displayName = "NewItem";
+
+const SubItemList = ({ name, items }) => {
+  const { t } = useTranslation();
+  const [open, setOpen] = React.useState(false);
+  const { pushItems } = useItemBaseActions();
+
+  const addItems = useRecoilCallback(
+    async (itemsToAdd) => {
+      pushItems(
+        itemsToAdd.map(({ template }) => ({ ...template, id: nanoid() }))
+      );
+    },
+    [pushItems]
+  );
+
+  return (
+    <>
+      <h3
+        onClick={() => setOpen((prev) => !prev)}
+        style={{ cursor: "pointer" }}
+      >
+        {open ? (
+          <Chevron orientation="bottom" color="#8c8c8c" />
+        ) : (
+          <Chevron color="#8c8c8c" />
+        )}{" "}
+        {name}{" "}
+        <span
+          style={{ fontSize: "0.6em" }}
+          onClick={(e) => {
+            e.preventDefault();
+            e.stopPropagation();
+            addItems(items);
+          }}
+        >
+          [{t("Add all")}]
+        </span>
+      </h3>
+      {open && <ItemList items={items} />}
+    </>
+  );
+};
+
+const ItemList = ({ items }) => (
+  <StyledItemList>
+    {items.map((node) => {
+      if (node.type) {
+        return <NewItem {...node} key={node.uid} />;
+      }
+      // it's a group
+      return (
+        <li key={`group_${node.name}`} className="group">
+          <SubItemList {...node} />
+        </li>
+      );
+    })}
+  </StyledItemList>
+);
+
+const MemoizedItemList = memo(ItemList);
+
+const filterItems = (filter, nodes) =>
+  nodes.reduce((acc, node) => {
+    if (node.type) {
+      if (search(filter, node.name)) {
+        acc.push(node);
+      }
+      return acc;
+    }
+    const filteredItems = filterItems(filter, node.items);
+    if (filteredItems.length) {
+      acc.push({ ...node, items: filteredItems });
+    }
+    return acc;
+  }, []);
+
+const ItemLibrary = ({ items }) => {
+  const { t } = useTranslation();
+  const [filter, setFilter] = React.useState("");
+  const [filteredItems, setFilteredItems] = React.useState(items);
+
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  const debouncedFilterItems = React.useCallback(
+    debounce((filterToApply, itemsToFilter) => {
+      setFilteredItems(filterItems(filterToApply, itemsToFilter));
+    }, 500),
+    []
+  );
+
+  React.useEffect(() => {
+    debouncedFilterItems(filter, items);
+  }, [debouncedFilterItems, filter, items]);
+
+  return (
+    <>
+      <input
+        onChange={(e) => setFilter(e.target.value)}
+        style={{ marginBottom: "1em" }}
+        placeholder={t("Search...")}
+      />
+      <MemoizedItemList items={filteredItems} />
+    </>
+  );
+};
+
+export default memo(ItemLibrary);

+ 90 - 0
src/components/mediaLibrary/ImageDropNPaste.jsx

@@ -0,0 +1,90 @@
+import React from "react";
+import { nanoid } from "nanoid";
+import { useDropzone } from "react-dropzone";
+import { useTranslation } from "react-i18next";
+import { useItemBaseActions } from "react-sync-board";
+
+import { useMediaLibrary } from "./MediaLibraryProvider";
+import Waiter from "../ui/Waiter";
+
+const ImageDropNPaste = ({ children }) => {
+  const { t } = useTranslation();
+  const [uploading, setUploading] = React.useState(false);
+  const { pushItem } = useItemBaseActions();
+
+  const { addMedia, libraries } = useMediaLibrary();
+
+  const addImageItem = React.useCallback(
+    async (media) => {
+      pushItem({
+        type: "image",
+        id: nanoid(),
+        content: media,
+      });
+    },
+    [pushItem]
+  );
+
+  const onDrop = React.useCallback(
+    async (acceptedFiles) => {
+      setUploading(true);
+      await Promise.all(
+        acceptedFiles.map(async (file) => {
+          const media = await addMedia(libraries[0], file);
+          await addImageItem(media);
+        })
+      );
+      setUploading(false);
+    },
+    [addImageItem, addMedia, libraries]
+  );
+
+  const { getRootProps } = useDropzone({ onDrop });
+
+  const onPaste = React.useCallback(
+    async (e) => {
+      const { items } = e.clipboardData;
+      setUploading(true);
+      // eslint-disable-next-line no-plusplus
+      for (let i = 0; i < items.length; i++) {
+        const item = items[i];
+        if (item.type.indexOf("image") !== -1) {
+          const file = item.getAsFile();
+          // eslint-disable-next-line no-await-in-loop
+          const location = await addMedia(libraries[0], file);
+          // eslint-disable-next-line no-await-in-loop
+          await addImageItem(location);
+        }
+      }
+      setUploading(false);
+    },
+    [addImageItem, addMedia, libraries]
+  );
+
+  React.useEffect(() => {
+    window.addEventListener("paste", onPaste, false);
+
+    return () => {
+      window.removeEventListener("paste", onPaste);
+    };
+  }, [onPaste]);
+
+  return (
+    <div
+      {...getRootProps()}
+      sstyle={{
+        position: "absolute",
+        top: "0",
+        left: "0",
+        right: "0",
+        bottom: "0",
+        pointerEvents: "none",
+      }}
+    >
+      {children}
+      {uploading && <Waiter message={t("Uploading image(s)...")} />}
+    </div>
+  );
+};
+
+export default ImageDropNPaste;

+ 113 - 0
src/components/mediaLibrary/ImageField.jsx

@@ -0,0 +1,113 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import styled from "styled-components";
+import { MediaLibraryButton, media2Url } from "./";
+import backgroundGrid from "../../images/background-grid.png";
+
+const StyledImageField = styled.div`
+  & .typeSelect {
+    padding: 0.5em;
+  }
+  & .imgContainer {
+    margin: 0 1em;
+    position: relative;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    flex-direction: column;
+    gap: 0.5em;
+  }
+`;
+
+const Thumbnail = styled.img`
+  max-height: 100px;
+  display: block;
+  background-image: url(${backgroundGrid});
+`;
+
+const ImageField = ({ value, onChange }) => {
+  const { t } = useTranslation();
+  let type, content;
+
+  // Manage compat with old version
+  if (typeof value === "object") {
+    type = value.type;
+    content = value.content;
+  } else {
+    if (value) {
+      type = "external";
+      content = value;
+    } else {
+      type = "empty";
+      content = null;
+    }
+  }
+
+  const handleInputChange = (e) => {
+    onChange({ type, content: e.target.value });
+  };
+
+  const handleTypeChange = (e) => {
+    onChange({ type: e.target.value, content: "" });
+  };
+
+  const handleMediaSelect = (key) => {
+    onChange({ type: "local", content: key });
+  };
+
+  const url = media2Url(value);
+
+  return (
+    <StyledImageField>
+      <form className="typeSelect">
+        <label>
+          <input
+            type="radio"
+            value="empty"
+            onChange={handleTypeChange}
+            checked={type === "empty"}
+          />
+          {t("No image")}
+        </label>
+        <label>
+          <input
+            type="radio"
+            value="local"
+            onChange={handleTypeChange}
+            checked={type === "local"}
+          />
+          {t("Library")}
+        </label>
+        <label>
+          <input
+            type="radio"
+            value="external"
+            checked={type === "external"}
+            onChange={handleTypeChange}
+          />
+          {t("External")}
+        </label>
+      </form>
+
+      <div className="imgContainer" onClick={(e) => e.preventDefault()}>
+        {type !== "empty" && content && <Thumbnail src={url} />}
+
+        {type === "external" && (
+          <input
+            value={content}
+            placeholder={t("Enter an image url...")}
+            onChange={handleInputChange}
+          />
+        )}
+        {type === "local" && (
+          <MediaLibraryButton
+            onSelect={handleMediaSelect}
+            label={content ? t("Change image") : t("Select image")}
+          />
+        )}
+      </div>
+    </StyledImageField>
+  );
+};
+
+export default ImageField;

+ 25 - 0
src/components/mediaLibrary/MediaLibraryButton.jsx

@@ -0,0 +1,25 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import MediaLibraryModal from "./MediaLibraryModal";
+
+const MediaLibraryButton = ({ onSelect, label }) => {
+  const { t } = useTranslation();
+
+  const [showLibrary, setShowLibrary] = React.useState(false);
+
+  return (
+    <>
+      <button onClick={() => setShowLibrary((prev) => !prev)}>
+        {label || t("Select media")}
+      </button>
+
+      <MediaLibraryModal
+        show={showLibrary}
+        setShow={setShowLibrary}
+        onSelect={onSelect}
+      />
+    </>
+  );
+};
+
+export default MediaLibraryButton;

+ 199 - 0
src/components/mediaLibrary/MediaLibraryModal.jsx

@@ -0,0 +1,199 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { useQuery, useMutation, useQueryClient } from "react-query";
+import styled from "styled-components";
+
+import { API_BASE } from "../../utils/settings";
+import backgroundGrid from "../../images/background-grid.png";
+
+import { confirmAlert } from "react-confirm-alert";
+import { toast } from "react-toastify";
+
+import Modal from "../ui/Modal";
+
+import { useMediaLibrary } from ".";
+import { useDropzone } from "react-dropzone";
+
+const ImageGrid = styled.div`
+  display: flex;
+  flex-wrap: wrap;
+  & > div {
+    position: relative;
+  }
+  & img {
+    height: 100px;
+    margin: 0.5em;
+    cursor: pointer;
+    border: 1px solid transparent;
+    background-image: url(${backgroundGrid});
+    &:hover {
+      filter: brightness(1.2);
+      border: 1px dotted var(--color-primary);
+    }
+  }
+  & .remove {
+    position: absolute;
+    top: 15px;
+    right: 5px;
+  }
+`;
+
+const MediaLibraryModal = ({ show, setShow, onSelect }) => {
+  const { t } = useTranslation();
+
+  const {
+    getLibraryMedia,
+    addMedia,
+    removeMedia,
+    libraries,
+  } = useMediaLibrary();
+
+  const queryClient = useQueryClient();
+  const [tab, setTab] = React.useState(libraries[0].id);
+
+  const currentLibrary = libraries.find(({ id }) => id === tab);
+
+  const { isLoading, data = [] } = useQuery(
+    `media__${tab}`,
+    () => getLibraryMedia(currentLibrary),
+    {
+      enabled: show,
+    }
+  );
+
+  const handleSelect = React.useCallback(
+    (media) => {
+      onSelect(media);
+      setShow(false);
+    },
+    [onSelect, setShow]
+  );
+
+  const uploadMediaMutation = useMutation(
+    async (files) => {
+      if (files.length === 1) {
+        return [await addMedia(currentLibrary, files[0])];
+      } else {
+        return Promise.all(files.map((file) => addMedia(currentLibrary, file)));
+      }
+    },
+    {
+      onSuccess: (result) => {
+        if (result.length === 1) {
+          // If only one file is processed
+          handleSelect(result[0].content);
+        }
+        queryClient.invalidateQueries(`media__${tab}`);
+      },
+    }
+  );
+
+  const onRemove = React.useCallback((key) => {
+    confirmAlert({
+      title: t("Confirmation"),
+      message: t("Do you really want to remove this media?"),
+      buttons: [
+        {
+          label: t("Yes"),
+          onClick: async () => {
+            try {
+              await removeMedia(key);
+              toast.success(t("Media deleted"), { autoClose: 1500 });
+            } catch (e) {
+              if (e.message === "Forbidden") {
+                toast.error(t("Action forbidden. Try logging in again."));
+              } else {
+                console.log(e);
+                toast.error(
+                  t("Error while deleting media. Try again later...")
+                );
+              }
+            }
+          },
+        },
+        {
+          label: t("No"),
+          onClick: () => {},
+        },
+      ],
+    });
+  }, []);
+
+  const { getRootProps, getInputProps } = useDropzone({
+    onDrop: uploadMediaMutation.mutate,
+  });
+
+  return (
+    <Modal
+      title={t("Media library")}
+      show={show}
+      setShow={setShow}
+      position="left"
+    >
+      <nav className="tabs">
+        {libraries.map(({ id, name }) => (
+          <a
+            onClick={() => setTab(id)}
+            className={tab === id ? "active" : ""}
+            style={{ cursor: "pointer" }}
+            key={id}
+          >
+            {name}
+          </a>
+        ))}
+      </nav>
+
+      <section>
+        {libraries.map(({ id, name }, index) => {
+          if (tab === id) {
+            return (
+              <div key={id}>
+                {index === 0 && (
+                  <>
+                    <h3>{t("Add file")}</h3>
+                    <div
+                      {...getRootProps()}
+                      style={{
+                        border: "3px dashed white",
+                        margin: "0.5em",
+                        padding: "0.5em",
+                        textAlign: "center",
+                      }}
+                    >
+                      <input {...getInputProps()} />
+                      <p>{t("Click or drag'n'drop file here")}</p>
+                    </div>
+                  </>
+                )}
+                <h3>{name}</h3>
+                {!isLoading && (
+                  <ImageGrid>
+                    {data.map((key) => (
+                      <div key={key}>
+                        <img
+                          src={`${API_BASE}/${key}`}
+                          onClick={() => handleSelect(key)}
+                        />
+                        <button
+                          onClick={() => onRemove(key)}
+                          className="button icon-only remove"
+                          title={t("Remove")}
+                        >
+                          X
+                        </button>
+                      </div>
+                    ))}
+                  </ImageGrid>
+                )}
+              </div>
+            );
+          } else {
+            return null;
+          }
+        })}
+      </section>
+    </Modal>
+  );
+};
+
+export default MediaLibraryModal;

+ 41 - 0
src/components/mediaLibrary/MediaLibraryProvider.jsx

@@ -0,0 +1,41 @@
+import React, { useContext } from "react";
+import {
+  uploadResourceImage as uploadMedia,
+  deleteResourceImage as deleteMedia,
+  listResourceImage as listMedia,
+} from "../../utils/api";
+
+export const MediaLibraryContext = React.createContext({});
+
+export const MediaLibraryProvider = ({ children, libraries = [] }) => {
+  const addMedia = React.useCallback(async ({ boxId, resourceId }, file) => {
+    const filePath = await uploadMedia(boxId, resourceId, file);
+    return {
+      type: "local",
+      content: filePath,
+    };
+  }, []);
+
+  const removeMedia = React.useCallback(async (key) => {
+    return await deleteMedia(key);
+  }, []);
+
+  const getLibraryMedia = React.useCallback(
+    async ({ boxId, resourceId }) => listMedia(boxId, resourceId),
+    []
+  );
+
+  return (
+    <MediaLibraryContext.Provider
+      value={{ addMedia, getLibraryMedia, libraries, removeMedia }}
+    >
+      {children}
+    </MediaLibraryContext.Provider>
+  );
+};
+
+export const useMediaLibrary = () => {
+  return useContext(MediaLibraryContext);
+};
+
+export default MediaLibraryProvider;

+ 25 - 0
src/components/mediaLibrary/index.js

@@ -0,0 +1,25 @@
+import { API_BASE } from "../../utils/settings";
+
+export { default as MediaLibraryProvider } from "./MediaLibraryProvider";
+export { useMediaLibrary } from "./MediaLibraryProvider";
+export { default as MediaLibraryButton } from "./MediaLibraryButton";
+export { default as ImageField } from "./ImageField";
+export { default as ImageDropNPaste } from "./ImageDropNPaste";
+
+export const media2Url = (value) => {
+  if (value && typeof value === "object") {
+    switch (value.type) {
+      case "local":
+        return `${API_BASE}/${value.content}`;
+      case "external":
+        return value.content;
+      case "dataUrl":
+        return value.content;
+      case "empty":
+        return null;
+      default:
+      // do nothing
+    }
+  }
+  return value;
+};

+ 44 - 0
src/components/messages/Composer.jsx

@@ -0,0 +1,44 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import styled from "styled-components";
+
+const StyledComposer = styled.div`
+  padding: 0.5em;
+  flex: 0;
+  & form {
+    display: flex;
+  }
+  overflow: none;
+`;
+
+const Composer = ({ sendMessage }) => {
+  const { t } = useTranslation();
+  const [text, setText] = React.useState("");
+
+  const onSubmit = (e) => {
+    e.preventDefault();
+    if (text) {
+      setText("");
+      sendMessage(text);
+    }
+  };
+  const onChange = (e) => {
+    setText(e.target.value);
+  };
+  return (
+    <StyledComposer>
+      <form onSubmit={(e) => onSubmit(e)}>
+        <input
+          onChange={(e) => onChange(e)}
+          value={text}
+          type="text"
+          placeholder={t("Enter your message and press ENTER")}
+          autoFocus={true}
+        />
+        <button>{t("Send")}</button>
+      </form>
+    </StyledComposer>
+  );
+};
+
+export default Composer;

+ 52 - 0
src/components/messages/Message.jsx

@@ -0,0 +1,52 @@
+import React from "react";
+import styled from "styled-components";
+
+const StyledMessage = styled.div`
+  font-size: 1.2em;
+  & .line {
+    display: flex;
+    flex-direction: row;
+    padding: 0rem 0.2rem;
+    padding: 0.5rem 0;
+    & p {
+      margin: 0;
+    }
+  }
+  & .name {
+    padding-left: 0.5em;
+    font-size: 1.3em;
+    ${({ color }) => `color: ${color};`}
+    text-shadow: 0px 0px 1px var(--color-grey);
+  }
+  & .left-block {
+    padding: 0 1rem;
+    opacity: 0.05;
+  }
+  &:hover {
+    & .line {
+      background-color: var(--color-midGrey);
+    }
+    & .left-block {
+      opacity: 0.8;
+    }
+  }
+`;
+
+const Message = ({
+  first,
+  user: { name, color = "#dddddd" },
+  timestamp,
+  content,
+}) => (
+  <StyledMessage color={color}>
+    {first && <div className="name">{name}</div>}
+    <div className="line">
+      <div className="left-block">
+        <span>{timestamp.format("HH:mm")}</span>
+      </div>
+      <p>{content}</p>
+    </div>
+  </StyledMessage>
+);
+
+export default Message;

+ 95 - 0
src/components/messages/MessageButton.jsx

@@ -0,0 +1,95 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import styled from "styled-components";
+import { useMessage } from "react-sync-board";
+
+import useNotify from "../../hooks/useNotify";
+import Touch from "../ui/Touch";
+import SidePanel from "../ui/SidePanel";
+
+import Composer from "./Composer";
+import MessageList from "./MessageList";
+
+const StyledChat = styled.div`
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+`;
+
+const NotifCount = styled.div`
+  width: 2em;
+  height: 2em;
+  border-radius: 100%;
+  position: absolute;
+  top: -5px;
+  right: 1.8em;
+  background-color: var(--color-success);
+  text-align: center;
+  line-height: 2em;
+  box-shadow: 3px 3px 6px #000000c0;
+`;
+
+export const MessagePanel = ({ onNewMessage, show, setShow }) => {
+  const { t } = useTranslation();
+  const { messages, sendMessage } = useMessage(onNewMessage, true);
+
+  return (
+    <SidePanel
+      open={show}
+      onClose={() => {
+        setShow(false);
+      }}
+      position="left"
+      noMargin
+      title={t("Chat")}
+    >
+      <StyledChat>
+        <MessageList messages={messages} />
+        <Composer sendMessage={sendMessage} />
+      </StyledChat>
+    </SidePanel>
+  );
+};
+
+export const MessageButton = () => {
+  const { t } = useTranslation();
+  const { add, reset, count } = useNotify();
+  const [showPanel, setShowPanel] = React.useState(false);
+
+  const onNewMessage = React.useCallback(() => {
+    if (!showPanel) {
+      add();
+    }
+  }, [add, showPanel]);
+
+  React.useEffect(() => {
+    if (showPanel) {
+      reset();
+    }
+  }, [reset, showPanel]);
+
+  const countStr = count > 9 ? "9+" : `${count}`;
+
+  return (
+    <>
+      <div style={{ position: "relative" }}>
+        <Touch
+          onClick={() => setShowPanel((prev) => !prev)}
+          alt={t("Chat")}
+          title={t("Chat")}
+          label={t("Chat")}
+          icon={"message"}
+          active={showPanel}
+        />
+        {count > 0 && <NotifCount>{countStr}</NotifCount>}
+      </div>
+      <MessagePanel
+        onNewMessage={onNewMessage}
+        show={showPanel}
+        setShow={setShowPanel}
+      />
+    </>
+  );
+};
+
+export default MessageButton;

+ 95 - 0
src/components/messages/MessageList.jsx

@@ -0,0 +1,95 @@
+import React from "react";
+import styled from "styled-components";
+import { useUsers } from "react-sync-board";
+
+import Message from "./Message";
+
+const computeMessageGroup = (messages, userMap, maxTimeDiff = 30000) => {
+  if (!messages || messages.length === 0) return [];
+
+  const messageGroups = [];
+  let previousUser = messages[0].user.uid;
+  let previousUserName = messages[0].user.name;
+  let previousTime = messages[0].timestamp;
+  let currentGroup = [];
+
+  messages.forEach((message, index) => {
+    if (
+      message.user.uid !== previousUser ||
+      message.user.name !== previousUserName ||
+      message.timestamp.diff(previousTime) > maxTimeDiff
+    ) {
+      previousUser = message.user.uid;
+      previousUserName = message.user.name;
+      messageGroups.push({
+        id: currentGroup[0].uid,
+        group: currentGroup,
+      });
+      currentGroup = [];
+    }
+    previousTime = message.timestamp;
+
+    // Get user from current session
+    const messageWithUser = { ...message };
+    if (message.user.uid in userMap) {
+      messageWithUser.user = userMap[message.user.uid];
+    }
+
+    currentGroup.push(messageWithUser);
+    if (index === messages.length - 1) {
+      messageGroups.push({
+        id: currentGroup[0].uid,
+        group: currentGroup,
+      });
+    }
+  });
+  return messageGroups;
+};
+
+const StyledMessageList = styled.div`
+  height: 100%;
+  overflow: auto;
+`;
+
+const MessageList = ({ messages }) => {
+  const messageList = React.useRef(null);
+  const { users } = useUsers();
+
+  const userMap = React.useMemo(
+    () =>
+      users.reduce((acc, user) => {
+        acc[user.uid] = user;
+        return acc;
+      }, {}),
+    [users]
+  );
+
+  const messageGroups = React.useMemo(
+    () => computeMessageGroup(messages, userMap),
+    [messages, userMap]
+  );
+
+  React.useEffect(() => {
+    messageList.current.scrollTop = messageList.current.scrollHeight;
+  });
+
+  return (
+    <StyledMessageList ref={messageList}>
+      {messageGroups.map(({ id: groupUid, group }) => (
+        <div key={groupUid}>
+          {group.map(({ uid: msgUid, user, timestamp, content }, index) => (
+            <Message
+              first={index === 0}
+              user={user}
+              timestamp={timestamp}
+              content={content}
+              key={msgUid}
+            />
+          ))}
+        </div>
+      ))}
+    </StyledMessageList>
+  );
+};
+
+export default MessageList;

+ 39 - 0
src/components/users/UserCircle.jsx

@@ -0,0 +1,39 @@
+import React from "react";
+
+import styled from "styled-components";
+
+const StyledUserCircle = styled.div`
+  background-color: ${({ color }) => color};
+  width: 38px;
+  min-width: 38px;
+  height: 38px;
+  margin: 2px;
+  border-radius: 100%;
+  text-align: center;
+  line-height: 38px;
+  text-transform: capitalize;
+  ${({ isSelf }) => (isSelf ? "text-decoration: underline;" : "")};
+  ${({ isSelf }) =>
+    isSelf ? "border: 3px solid #777; line-height: 32px;" : ""};
+  cursor: ${({ isSelf }) => (isSelf ? "pointer" : "default")};
+
+  &:hover {
+    ${({ isSelf }) => (isSelf ? "filter: brightness(125%);" : "")}
+  }
+
+  @media screen and (max-width: 640px) {
+    & {
+      font-size: 0.5em;
+      width: 20px;
+      height: 20px;
+      line-height: 20px;
+    }
+  }
+`;
+
+const UserCircle = ({ name, ...rest }) => {
+  let pre = name.slice(0, 2).toLowerCase();
+  return <StyledUserCircle {...rest}>{pre}</StyledUserCircle>;
+};
+
+export default UserCircle;

+ 78 - 0
src/components/users/UserConfig.jsx

@@ -0,0 +1,78 @@
+import React from "react";
+import { SketchPicker } from "react-color";
+import { useTranslation } from "react-i18next";
+import styled from "styled-components";
+
+import Modal from "../ui/Modal";
+
+import UserCircle from "./UserCircle";
+
+const StyledInputName = styled.input`
+  &:not([type="checkbox"]):not([type="radio"]):not([type="submit"]):not([type="color"]):not([type="button"]):not([type="reset"]) {
+    width: 12em;
+  }
+`;
+
+const emptyStyle = {};
+const emptyColors = [];
+
+const UserConfig = ({ user, setUser, editable, index }) => {
+  const { t } = useTranslation();
+
+  const [name, setName] = React.useState(user.name);
+  const [color, setColor] = React.useState(user.color);
+  const [showDetails, setShowDetails] = React.useState(false);
+
+  const handleChange = React.useCallback(
+    (e) => {
+      setName(e.target.value);
+      setUser((prevUser) => ({ ...prevUser, name: e.target.value }));
+    },
+    [setUser]
+  );
+
+  const handleChangecolor = React.useCallback((newColor) => {
+    setColor(newColor.hex);
+  }, []);
+
+  const handleChangecolorComplete = React.useCallback(
+    (newColor) => {
+      setColor(newColor.hex);
+      setUser((prevUser) => ({ ...prevUser, color: newColor.hex }));
+    },
+    [setUser]
+  );
+
+  return (
+    <>
+      <UserCircle
+        color={user.color}
+        onClick={() => editable && setShowDetails(true)}
+        title={user.name}
+        name={user.name || `${index}`}
+        isSelf={editable}
+      />
+      <Modal
+        title={t("User details")}
+        show={showDetails}
+        setShow={setShowDetails}
+      >
+        <label>{t("Username")}</label>
+        <StyledInputName value={name} onChange={handleChange} />
+
+        <label>{t("Color")}</label>
+        <SketchPicker
+          disableAlpha
+          presetColors={emptyColors}
+          color={color}
+          onChange={handleChangecolor}
+          onChangeComplete={handleChangecolorComplete}
+          styles={emptyStyle}
+          width={160}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default UserConfig;

+ 93 - 0
src/components/users/UserList.jsx

@@ -0,0 +1,93 @@
+import React from "react";
+import { useUsers } from "react-sync-board";
+import styled from "styled-components";
+
+import { useTranslation } from "react-i18next";
+
+import Touch from "../ui/Touch";
+import DropDown from "../ui/DropDown";
+import UserConfig from "./UserConfig";
+
+const InlineUserList = styled.ul.attrs(() => ({ className: "uk-card" }))`
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  display: flex;
+`;
+
+const InlineUserListItem = styled.li`
+  display: flex;
+  align-items: center;
+  position: relative;
+  margin-right: 3px;
+`;
+
+const UserList = styled.ul.attrs(() => {})`
+  list-style: none;
+  margin: 0;
+  padding: 1em;
+`;
+
+const UserListItem = styled.li`
+  display: flex;
+  max-width: 300px;
+  align-items: center;
+  padding: 0.4em 0;
+  & .name {
+    flex: 1;
+    margin-left: 1em;
+  }
+`;
+
+export const Users = () => {
+  const { t } = useTranslation();
+  const { currentUser, setCurrentUser, localUsers: users } = useUsers();
+  const [openUserlist, setOpenUserList] = React.useState(false);
+
+  const firstUsers = users.slice(0, 3);
+
+  return (
+    <InlineUserList>
+      {firstUsers.map((u, index) => (
+        <InlineUserListItem key={u.id}>
+          <UserConfig
+            index={index + 1}
+            user={u}
+            setUser={setCurrentUser}
+            editable={currentUser.id === u.id}
+          />
+        </InlineUserListItem>
+      ))}
+      {users.length > 3 && (
+        <InlineUserListItem key="last">
+          <div style={{ display: "inline" }}>
+            <Touch
+              onClick={() => {
+                setOpenUserList((prev) => !prev);
+              }}
+              icon="dots-three-horizontal"
+              title={t("All players")}
+            />
+            <DropDown open={openUserlist}>
+              <UserList>
+                {users.map((u, index) => (
+                  <UserListItem key={u.id}>
+                    <UserConfig
+                      index={index + 1}
+                      user={u}
+                      setUser={setCurrentUser}
+                      editable={currentUser.id === u.id}
+                    />
+                    <div className="name">{u.name}</div>
+                  </UserListItem>
+                ))}
+              </UserList>
+            </DropDown>
+          </div>
+        </InlineUserListItem>
+      )}
+    </InlineUserList>
+  );
+};
+
+export default Users;

+ 1 - 1
src/gameComponents/Image.jsx

@@ -1,5 +1,5 @@
 import React, { memo } from "react";
-import { useUsers } from "../components/users";
+import { useUsers } from "react-sync-board";
 import styled from "styled-components";
 import { media2Url } from "../components/mediaLibrary";
 

+ 29 - 13
src/gameComponents/ItemForm.jsx

@@ -8,31 +8,47 @@ import Slider from "../components/ui/Slider";
 
 import ActionsField from "./forms/ActionsField";
 
-import { itemMap } from "./";
-
-import {
-  getDefaultActionsFromItem,
-  getAvailableActionsFromItem,
-} from "../components/board/Items/useItemActions";
-import useGameItemActionMap from "./useGameItemActionMap";
+import { itemTemplates, actionMap } from "./";
 
 export const getFormFieldComponent = (type) => {
-  if (type in itemMap) {
-    return itemMap[type].form;
+  if (type in itemTemplates) {
+    return itemTemplates[type].form;
   }
   return () => null;
 };
 
+const getDefaultActionsFromItem = (item) => {
+  if (item.type in itemTemplates) {
+    const actions = itemTemplates[item.type].defaultActions;
+    if (typeof actions === "function") {
+      return actions(item);
+    }
+    return actions;
+  }
+
+  return [];
+};
+
+const getAvailableActionsFromItem = (item) => {
+  if (item.type in itemTemplates) {
+    const actions = itemTemplates[item.type].availableActions;
+    if (typeof actions === "function") {
+      return actions(item);
+    }
+    return actions;
+  }
+
+  return [];
+};
+
 const ItemForm = ({ items }) => {
   const { t } = useTranslation();
 
-  const { actionMap } = useGameItemActionMap();
-
   const [defaultActions] = React.useState(() =>
-    getDefaultActionsFromItem(items[0], itemMap)
+    getDefaultActionsFromItem(items[0])
   );
   const [availableActions] = React.useState(() =>
-    getAvailableActionsFromItem(items[0], itemMap)
+    getAvailableActionsFromItem(items[0])
   );
 
   let FieldsComponent;

+ 12 - 11
src/gameComponents/Zone.jsx

@@ -1,9 +1,10 @@
 import React from "react";
 import { memo } from "react";
 import styled, { css } from "styled-components";
-import { isItemInsideElement } from "../components/utils";
-import useItemInteraction from "../components/board/Items/useItemInteraction";
-import useGameItemActionMap from "./useGameItemActionMap";
+import { useItemInteraction } from "react-sync-board";
+
+import { isItemInsideElement } from "../views/utils";
+import { useItemActions } from "react-sync-board";
 
 const ZoneWrapper = styled.div`
   ${({ width = 200, height = 200 }) => css`
@@ -32,8 +33,8 @@ const ZoneWrapper = styled.div`
 
 const Zone = ({ width, height, label, onItem }) => {
   const { register } = useItemInteraction("place");
-  const { setFlip, setFlipSelf, stack } = useGameItemActionMap();
   const zoneRef = React.useRef(null);
+  const actionMap = useItemActions();
 
   const onInsideItem = React.useCallback(
     (itemIds) => {
@@ -44,24 +45,24 @@ const Zone = ({ width, height, label, onItem }) => {
       onItem.forEach((action) => {
         switch (action) {
           case "reveal":
-            setFlip(insideItems, { flip: false });
+            actionMap["reveal"].action(insideItems);
             break;
           case "hide":
-            setFlip(insideItems, { flip: true });
+            actionMap["hide"].action(insideItems);
             break;
           case "revealSelf":
-            setFlipSelf(insideItems, { flipSelf: true });
+            actionMap["revealSelf"].action(insideItems);
             break;
           case "hideSelf":
-            setFlipSelf(insideItems, { flipSelf: false });
+            actionMap["sideSelf"].action(insideItems);
             break;
           case "stack":
-            stack(insideItems);
+            actionMap["stack"].action(insideItems);
             break;
         }
       });
     },
-    [onItem, setFlip, setFlipSelf, stack]
+    [actionMap, onItem]
   );
 
   React.useEffect(() => {
@@ -72,7 +73,7 @@ const Zone = ({ width, height, label, onItem }) => {
     return () => {
       unregisterList.forEach((callback) => callback());
     };
-  }, [onInsideItem, onItem, register, setFlip]);
+  }, [onInsideItem, onItem, register]);
 
   return (
     <ZoneWrapper width={width} height={height} ref={zoneRef}>

+ 542 - 0
src/gameComponents/actionMap.js

@@ -0,0 +1,542 @@
+// import React from "react";
+// import { useTranslation } from "react-i18next";
+import i18n from "../i18n";
+import { nanoid } from "nanoid";
+import { toast } from "react-toastify";
+
+import { shuffle as shuffleArray, randInt } from "../views/utils";
+
+import deleteIcon from "../images/delete.svg";
+import stackToCenterIcon from "../images/stackToCenter.svg";
+import stackToTopLeftIcon from "../images/stackToTopLeft.svg";
+import alignAsLineIcon from "../images/alignAsLine.svg";
+import alignAsSquareIcon from "../images/alignAsSquare.svg";
+import duplicateIcon from "../images/duplicate.svg";
+import seeIcon from "../images/see.svg";
+import flipIcon from "../images/flip.svg";
+import lockIcon from "../images/lock.svg";
+import rotateIcon from "../images/rotate.svg";
+import shuffleIcon from "../images/shuffle.svg";
+import tapIcon from "../images/tap.svg";
+
+const stackToCenter = async (
+  itemIds,
+  {
+    stackThicknessMin = 0.5,
+    stackThicknessMax = 1,
+    limitCardsNumber = 32,
+  } = {},
+  { getItem, batchUpdateItems }
+) => {
+  const items = itemIds.map(getItem);
+
+  // Rule to manage thickness of the stack.
+  let stackThickness = stackThicknessMax;
+  if (items.length >= limitCardsNumber) {
+    stackThickness = stackThicknessMin;
+  }
+
+  // To avoid displacement effects.
+  let isSameGap = true;
+  for (let i = 1; i < items.length; i++) {
+    if (Math.abs(items[i].x - items[i - 1].x) != stackThickness) {
+      isSameGap = false;
+      break;
+    }
+    if (Math.abs(items[i].y - items[i - 1].y) != stackThickness) {
+      isSameGap = false;
+      break;
+    }
+  }
+  if (isSameGap == true) {
+    return;
+  }
+
+  // Compute middle position
+  const minMax = { min: {}, max: {} };
+  minMax.min.x = Math.min(...items.map(({ x }) => x));
+  minMax.min.y = Math.min(...items.map(({ y }) => y));
+  minMax.max.x = Math.max(
+    ...items.map(({ x, id }) => x + document.getElementById(id).clientWidth)
+  );
+  minMax.max.y = Math.max(
+    ...items.map(({ y, id }) => y + document.getElementById(id).clientHeight)
+  );
+  const { clientWidth, clientHeight } = document.getElementById(items[0].id);
+  let newX = minMax.min.x + (minMax.max.x - minMax.min.x) / 2 - clientWidth / 2;
+  let newY =
+    minMax.min.y + (minMax.max.y - minMax.min.y) / 2 - clientHeight / 2;
+
+  batchUpdateItems(itemIds, (item) => {
+    const newItem = {
+      ...item,
+      x: newX,
+      y: newY,
+    };
+    newX += stackThickness;
+    newY -= stackThickness;
+    return newItem;
+  });
+};
+
+// Stack selection to Top Left
+export const stackToTopLeft = async (
+  itemIds,
+  {
+    stackThicknessMin = 0.5,
+    stackThicknessMax = 1,
+    limitCardsNumber = 32,
+  } = {},
+  { getItem, batchUpdateItems }
+) => {
+  const items = await Promise.all(itemIds.map(getItem));
+
+  let { x: newX, y: newY } = items[0];
+
+  // Rule to manage thickness of the stack.
+  let stackThickness = stackThicknessMax;
+  if (items.length >= limitCardsNumber) {
+    stackThickness = stackThicknessMin;
+  }
+
+  batchUpdateItems(itemIds, (item) => {
+    const newItem = {
+      ...item,
+      x: newX,
+      y: newY,
+    };
+    newX += stackThickness;
+    newY -= stackThickness;
+    return newItem;
+  });
+};
+
+// Align selection to a line
+const alignAsLine = async (
+  itemIds,
+  { gapBetweenItems = 5 } = {},
+  { getItem, batchUpdateItems }
+) => {
+  // Negative value is possible for 'gapBetweenItems'.
+  let { x: newX, y: newY } = await getItem(itemIds[0]);
+
+  batchUpdateItems(itemIds, (item) => {
+    const { clientWidth } = document.getElementById(item.id);
+    const newItem = {
+      ...item,
+      x: newX,
+      y: newY,
+    };
+    newX += clientWidth + gapBetweenItems;
+    return newItem;
+  });
+};
+
+// Align selection to an array
+const alignAsSquare = async (
+  itemIds,
+  { gapBetweenItems = 5 } = {},
+  { batchUpdateItems, getItem }
+) => {
+  // Negative value is possible for 'gapBetweenItems'.
+  const items = await Promise.all(itemIds.map(getItem));
+
+  // Count number of elements
+  const numberOfElements = items.length;
+  const numberOfColumns = Math.ceil(Math.sqrt(numberOfElements));
+
+  let { x: newX, y: newY } = items[0];
+
+  let currentColumn = 1;
+
+  batchUpdateItems(itemIds, (item) => {
+    const { clientWidth, clientHeight } = document.getElementById(item.id);
+    const newItem = {
+      ...item,
+      x: newX,
+      y: newY,
+    };
+    newX += clientWidth + gapBetweenItems;
+    currentColumn += 1;
+    if (currentColumn > numberOfColumns) {
+      currentColumn = 1;
+      newX = items[0].x;
+      newY += clientHeight + gapBetweenItems;
+    }
+    return newItem;
+  });
+};
+
+const shuffleItems = async (itemIds, _, { swapItems }) => {
+  itemIds.forEach((itemId) => {
+    const elem = document.getElementById(itemId);
+    elem.firstChild.className = "hvr-wobble-horizontal";
+  });
+  const shuffledItems = shuffleArray([...itemIds]);
+  swapItems(itemIds, shuffledItems);
+};
+
+const randomlyRotateSelectedItems = async (
+  itemIds,
+  { angle, maxRotateCount },
+  { batchUpdateItems }
+) => {
+  batchUpdateItems(itemIds, (item) => {
+    const rotation =
+      ((item.rotation || 0) + angle * randInt(0, maxRotateCount)) % 360;
+    return { ...item, rotation };
+  });
+};
+
+// Tap/Untap elements
+const toggleTap = async (itemIds, _, { getItem, batchUpdateItems }) => {
+  const items = await Promise.all(itemIds.map(getItem));
+
+  const tappedCount = items.filter(({ rotation }) => rotation === 90).length;
+
+  let untap = false;
+  if (tappedCount > itemIds.length / 2) {
+    untap = true;
+  }
+
+  batchUpdateItems(itemIds, (item) => ({
+    ...item,
+    rotation: untap ? 0 : 90,
+  }));
+};
+
+// Lock / unlock elements
+const toggleLock = async (itemIds, _, { batchUpdateItems }) => {
+  batchUpdateItems(itemIds, (item) => ({
+    ...item,
+    locked: !item.locked,
+  }));
+
+  const isFirstLock = !window.localStorage.getItem("isFirstLock");
+
+  // Help user on first lock
+  if (isFirstLock) {
+    toast.info(
+      i18n.t(
+        "You've locked your first element. Long click to select it again."
+      ),
+      { autoClose: false }
+    );
+    window.localStorage.setItem("isFirstLock", "false");
+  }
+};
+
+// Flip or reveal items
+export const setFlip = async (
+  itemIds,
+  { flip = true, reverseOrder = true } = {},
+  { batchUpdateItems, reverseItemsOrder }
+) => {
+  batchUpdateItems(itemIds, (item) => ({
+    ...item,
+    flipped: flip,
+    unflippedFor:
+      !Array.isArray(item.unflippedFor) || item.unflippedFor.length > 0
+        ? null
+        : item.unflippedFor,
+  }));
+  if (reverseOrder) {
+    reverseItemsOrder(itemIds);
+  }
+};
+
+// Toggle flip state
+const toggleFlip = async (
+  itemIds,
+  { reverseOrder = true } = {},
+  baseActions
+) => {
+  const items = await Promise.all(itemIds.map(baseActions.getItem));
+
+  const flippedCount = items.filter(({ flipped }) => flipped).length;
+
+  setFlip(
+    itemIds,
+    {
+      flip: flippedCount < itemIds.length / 2,
+      reverseOrder,
+    },
+    baseActions
+  );
+};
+
+// Rotate element
+const rotate = async (itemIds, { angle }, { batchUpdateItems }) => {
+  batchUpdateItems(itemIds, (item) => ({
+    ...item,
+    rotation: (item.rotation || 0) + angle,
+  }));
+};
+
+// Reveal for player only
+export const setFlipSelf = async (
+  itemIds,
+  { flipSelf = true } = {},
+  { batchUpdateItems }
+) => {
+  batchUpdateItems(itemIds, (item) => {
+    let { unflippedFor = [] } = item;
+
+    if (!Array.isArray(item.unflippedFor)) {
+      unflippedFor = [];
+    }
+
+    if (flipSelf && !unflippedFor.includes(currentUser.uid)) {
+      unflippedFor = [...unflippedFor, currentUser.uid];
+    }
+    if (!flipSelf && unflippedFor.includes(currentUser.uid)) {
+      unflippedFor = unflippedFor.filter((id) => id !== currentUser.uid);
+    }
+    return {
+      ...item,
+      flipped: true,
+      unflippedFor,
+    };
+  });
+};
+
+// Reveal for player only
+const toggleFlipSelf = async (itemIds, params, actions) => {
+  const items = await Promise.all(itemIds.map(actions.getItem));
+
+  const flippedSelfCount = items.filter(
+    ({ unflippedFor }) =>
+      Array.isArray(unflippedFor) && unflippedFor.includes(currentUser.uid)
+  ).length;
+
+  let flipSelf = true;
+  if (flippedSelfCount > itemIds.length / 2) {
+    flipSelf = false;
+  }
+
+  setFlipSelf(itemIds, { ...params, flipSelf }, actions);
+};
+
+const remove = async (itemIds, _, { removeItems }) => {
+  removeItems(itemIds);
+};
+
+const cloneItem = async (itemIds, _, { getItem, insertItemBefore }) => {
+  const items = await Promise.all(itemIds.map(getItem));
+
+  items.forEach((itemToClone) => {
+    const newItem = JSON.parse(JSON.stringify(itemToClone));
+    newItem.id = nanoid();
+    delete newItem.move;
+    insertItemBefore(newItem, itemToClone.id);
+  });
+};
+
+const actionMap = {
+  flip: {
+    action: toggleFlip,
+    label: i18n.t("Reveal") + "/" + i18n.t("Hide"),
+    shortcut: "f",
+    icon: flipIcon,
+  },
+  reveal: {
+    action: (itemIds, _, baseActions) =>
+      setFlip(itemIds, { flip: false }, baseActions),
+    label: i18n.t("Reveal"),
+    icon: flipIcon,
+  },
+  hide: {
+    action: (itemIds, _, baseActions) =>
+      setFlip(itemIds, { flip: true }, baseActions),
+    label: i18n.t("Hide"),
+    icon: flipIcon,
+  },
+  flipSelf: {
+    action: toggleFlipSelf,
+    label: i18n.t("Reveal for me"),
+    shortcut: "o",
+    icon: seeIcon,
+  },
+  revealSelf: {
+    action: (itemIds, _, baseActions) =>
+      setFlipSelf(itemIds, { flipSelf: true }, baseActions),
+    label: i18n.t("Reveal for me"),
+    icon: seeIcon,
+  },
+  hideSelf: {
+    action: (itemIds, _, baseActions) =>
+      setFlipSelf(itemIds, { flipSelf: false }, baseActions),
+    label: i18n.t("Hide for me"),
+    icon: seeIcon,
+  },
+  tap: {
+    action: toggleTap,
+    label: i18n.t("Tap") + "/" + i18n.t("Untap"),
+    shortcut: "t",
+    icon: tapIcon,
+  },
+  stackToCenter: {
+    action: stackToCenter,
+    label: i18n.t("Stack To Center"),
+    shortcut: "",
+    multiple: true,
+    icon: stackToCenterIcon,
+  },
+  stack: {
+    action: stackToTopLeft,
+    label: i18n.t("Stack To Top Left"),
+    multiple: true,
+    icon: stackToTopLeftIcon,
+  },
+  alignAsLine: {
+    action: alignAsLine,
+    label: i18n.t("Align as line"),
+    multiple: true,
+    icon: alignAsLineIcon,
+  },
+  alignAsSquare: {
+    action: alignAsSquare,
+    label: i18n.t("Align as square"),
+    multiple: true,
+    icon: alignAsSquareIcon,
+  },
+  shuffle: {
+    action: shuffleItems,
+    label: i18n.t("Shuffle"),
+    multiple: true,
+    icon: shuffleIcon,
+  },
+  randomlyRotate30: {
+    action: (itemIds, _, baseActions) =>
+      randomlyRotateSelectedItems(
+        itemIds,
+        {
+          angle: 30,
+          maxRotateCount: 11,
+        },
+        baseActions
+      ),
+    label: i18n.t("Rotate randomly 30"),
+    multiple: false,
+    icon: rotateIcon,
+  },
+  randomlyRotate45: {
+    action: (itemIds, _, baseActions) =>
+      randomlyRotateSelectedItems(
+        itemIds,
+        {
+          angle: 45,
+          maxRotateCount: 7,
+        },
+        baseActions
+      ),
+    label: i18n.t("Rotate randomly 45"),
+    shortcut: "",
+    multiple: false,
+    icon: rotateIcon,
+  },
+  randomlyRotate60: {
+    action: (itemIds, _, baseActions) =>
+      randomlyRotateSelectedItems(
+        itemIds,
+        {
+          angle: 60,
+          maxRotateCount: 5,
+        },
+        baseActions
+      ),
+    label: i18n.t("Rotate randomly 60"),
+    shortcut: "",
+    multiple: false,
+    icon: rotateIcon,
+  },
+  randomlyRotate90: {
+    action: (itemIds, _, baseActions) =>
+      randomlyRotateSelectedItems(
+        itemIds,
+        {
+          angle: 90,
+          maxRotateCount: 3,
+        },
+        baseActions
+      ),
+    label: i18n.t("Rotate randomly 90"),
+    shortcut: "",
+    multiple: false,
+    icon: rotateIcon,
+  },
+  randomlyRotate180: {
+    action: (itemIds, _, baseActions) =>
+      randomlyRotateSelectedItems(
+        itemIds,
+        {
+          angle: 180,
+          maxRotateCount: 1,
+        },
+        baseActions
+      ),
+    label: i18n.t("Rotate randomly 180"),
+    shortcut: "",
+    multiple: false,
+    icon: rotateIcon,
+  },
+  rotate30: {
+    action: (itemIds, _, baseActions) =>
+      rotate(itemIds, { angle: 30 }, baseActions),
+    label: i18n.t("Rotate 30"),
+    shortcut: "r",
+    icon: rotateIcon,
+  },
+  rotate45: {
+    action: (itemIds, _, baseActions) =>
+      rotate(itemIds, { angle: 45 }, baseActions),
+    label: i18n.t("Rotate 45"),
+    shortcut: "r",
+    icon: rotateIcon,
+  },
+  rotate60: {
+    action: (itemIds, _, baseActions) =>
+      rotate(itemIds, { angle: 60 }, baseActions),
+    label: i18n.t("Rotate 60"),
+    shortcut: "r",
+    icon: rotateIcon,
+  },
+  rotate90: {
+    action: (itemIds, _, baseActions) =>
+      rotate(itemIds, { angle: 90 }, baseActions),
+    label: i18n.t("Rotate 90"),
+    shortcut: "r",
+    icon: rotateIcon,
+  },
+  rotate180: {
+    action: (itemIds, _, baseActions) =>
+      rotate(itemIds, { angle: 180 }, baseActions),
+    label: i18n.t("Rotate 180"),
+    shortcut: "r",
+    icon: rotateIcon,
+  },
+  clone: {
+    action: cloneItem,
+    label: i18n.t("Clone"),
+    shortcut: "c",
+    disableDblclick: true,
+    edit: true,
+    icon: duplicateIcon,
+  },
+  lock: {
+    action: toggleLock,
+    label: i18n.t("Unlock") + "/" + i18n.t("Lock"),
+    disableDblclick: true,
+    icon: lockIcon,
+  },
+  remove: {
+    action: remove,
+    label: i18n.t("Remove all"),
+    shortcut: "Delete",
+    edit: true,
+    disableDblclick: true,
+    icon: deleteIcon,
+  },
+};
+
+export default actionMap;

+ 1 - 2
src/gameComponents/forms/ZoneFormFields.jsx

@@ -6,13 +6,12 @@ import Label from "../../components/ui/formUtils/Label";
 import Hint from "../../components/ui/formUtils/Hint";
 
 import ActionsField from "./ActionsField";
-import useGameItemActionMap from "../useGameItemActionMap";
+import actionMap from "../actionMap";
 
 const interactions = ["reveal", "hide", "revealSelf", "stack"];
 
 const Form = ({ initialValues }) => {
   const { t } = useTranslation();
-  const actionMap = useGameItemActionMap();
   return (
     <>
       <Label>

+ 2 - 291
src/gameComponents/index.jsx

@@ -1,294 +1,5 @@
-import i18n from "../i18n";
+export { default as itemTemplates, itemLibrary } from "./itemTemplates";
 
-import Rect from "./Rect";
-import Cube from "./Cube";
-import Round from "./Round";
-import Token from "./Token";
-import Image from "./Image";
-import Counter from "./Counter";
-import Dice from "./Dice";
-import Note from "./Note";
-import Zone from "./Zone";
-import Meeple from "./Meeple";
-import Jewel from "./Jewel";
-import Pawn from "./Pawn";
-import CheckerBoard from "./CheckerBoard";
-import Cylinder from "./Cylinder";
-
-import ImageFormFields from "./forms/ImageFormFields";
-import CounterFormFields from "./forms/CounterFormFields";
-import RectFormFields from "./forms/RectFormFields";
-import CubeFormFields from "./forms/CubeFormFields";
-import RoundFormFields from "./forms/RoundFormFields";
-import DiceFormFields from "./forms/DiceFormFields";
-import NoteFormFields from "./forms/NoteFormFields";
-import ZoneFormFields from "./forms/ZoneFormFields";
-import TokenFormFields from "./forms/TokenFormFields";
-import MeepleFormFields from "./forms/MeepleFormFields";
-import JewelFormFields from "./forms/JewelFormFields";
-import PawnFormFields from "./forms/PawnFormFields";
-import CheckerBoardFormFields from "./forms/CheckerBoardFormFields";
-import CylinderFormFields from "./forms/CylinderFormFields";
-
-export const itemMap = {
-  rect: {
-    component: Rect,
-    defaultActions: ["lock", "remove"],
-    availableActions: [
-      "stack",
-      "alignAsLine",
-      "alignAsSquare",
-      "shuffle",
-      "clone",
-      "lock",
-      "remove",
-    ],
-    form: RectFormFields,
-    name: i18n.t("Rectangle"),
-    template: {},
-  },
-  cube: {
-    component: Cube,
-    defaultActions: ["clone", "lock", "remove"],
-    availableActions: [
-      "stack",
-      "alignAsLine",
-      "alignAsSquare",
-      "shuffle",
-      "clone",
-      "lock",
-      "remove",
-    ],
-    form: CubeFormFields,
-    name: i18n.t("Cube"),
-    template: {},
-  },
-  cylinder: {
-    component: Cylinder,
-    defaultActions: ["clone", "lock", "remove"],
-    availableActions: [
-      "stack",
-      "alignAsLine",
-      "alignAsSquare",
-      "shuffle",
-      "clone",
-      "lock",
-      "remove",
-    ],
-    form: CylinderFormFields,
-    name: i18n.t("Cylinder"),
-    template: {},
-  },
-  round: {
-    component: Round,
-    defaultActions: ["clone", "lock", "remove"],
-    availableActions: [
-      "stack",
-      "alignAsLine",
-      "alignAsSquare",
-      "shuffle",
-      "clone",
-      "lock",
-      "remove",
-    ],
-    form: RoundFormFields,
-    name: i18n.t("Round"),
-    template: {},
-  },
-  token: {
-    component: Token,
-    defaultActions: ["clone", "lock", "remove"],
-    availableActions: [
-      "stack",
-      "alignAsLine",
-      "alignAsSquare",
-      "shuffle",
-      "clone",
-      "lock",
-      "remove",
-    ],
-    form: TokenFormFields,
-    name: i18n.t("Token"),
-    template: {},
-  },
-  meeple: {
-    component: Meeple,
-    defaultActions: ["clone", "lock", "remove"],
-    availableActions: [
-      "stack",
-      "alignAsLine",
-      "alignAsSquare",
-      "shuffle",
-      "clone",
-      "lock",
-      "remove",
-    ],
-    form: MeepleFormFields,
-    name: i18n.t("Meeple"),
-    template: {},
-  },
-  pawn: {
-    component: Pawn,
-    defaultActions: ["clone", "lock", "remove"],
-    availableActions: [
-      "stack",
-      "alignAsLine",
-      "alignAsSquare",
-      "shuffle",
-      "clone",
-      "lock",
-      "remove",
-    ],
-    form: PawnFormFields,
-    name: i18n.t("Pawn"),
-    template: {},
-  },
-  jewel: {
-    component: Jewel,
-    defaultActions: ["clone", "lock", "remove"],
-    availableActions: [
-      "stack",
-      "alignAsLine",
-      "alignAsSquare",
-      "shuffle",
-      "clone",
-      "lock",
-      "remove",
-    ],
-    form: JewelFormFields,
-    name: i18n.t("Jewel"),
-    template: {},
-  },
-  checkerboard: {
-    component: CheckerBoard,
-    defaultActions: ["clone", "lock", "remove"],
-    availableActions: ["clone", "lock", "remove"],
-    form: CheckerBoardFormFields,
-    name: i18n.t("Checkerboard"),
-    template: { layer: -1 },
-  },
-  image: {
-    component: Image,
-    defaultActions: (item) => {
-      if (item.backContent) {
-        return [
-          "flip",
-          "flipSelf",
-          "tap",
-          "stack",
-          "shuffle",
-          "clone",
-          "lock",
-          "remove",
-        ];
-      } else {
-        return ["tap", "stack", "shuffle", "clone", "lock", "remove"];
-      }
-    },
-    availableActions: (item) => {
-      if (item.backContent) {
-        return [
-          "flip",
-          "flipSelf",
-          "tap",
-          "rotate30",
-          "rotate45",
-          "rotate60",
-          "rotate90",
-          "rotate180",
-          "stack",
-          "alignAsLine",
-          "alignAsSquare",
-          "shuffle",
-          "randomlyRotate30",
-          "randomlyRotate45",
-          "randomlyRotate60",
-          "randomlyRotate90",
-          "randomlyRotate180",
-          "clone",
-          "lock",
-          "remove",
-        ];
-      } else {
-        return [
-          "tap",
-          "rotate30",
-          "rotate45",
-          "rotate60",
-          "rotate90",
-          "rotate180",
-          "stack",
-          "alignAsLine",
-          "alignAsSquare",
-          "shuffle",
-          "randomlyRotate30",
-          "randomlyRotate45",
-          "randomlyRotate60",
-          "randomlyRotate90",
-          "randomlyRotate180",
-          "clone",
-          "lock",
-          "remove",
-        ];
-      }
-    },
-    form: ImageFormFields,
-    name: i18n.t("Image"),
-    template: {},
-  },
-  counter: {
-    component: Counter,
-    defaultActions: ["clone", "lock", "remove"],
-    availableActions: ["clone", "lock", "remove"],
-    form: CounterFormFields,
-    name: i18n.t("Counter"),
-    template: {},
-  },
-  dice: {
-    component: Dice,
-    defaultActions: ["clone", "lock", "remove"],
-    availableActions: [
-      "clone",
-      "lock",
-      "remove",
-      "alignAsLine",
-      "alignAsSquare",
-    ],
-    form: DiceFormFields,
-    name: i18n.t("Dice"),
-    template: {},
-  },
-  note: {
-    component: Note,
-    defaultActions: ["shuffle", "clone", "lock", "remove"],
-    availableActions: [
-      "shuffle",
-      "clone",
-      "lock",
-      "remove",
-      "alignAsLine",
-      "alignAsSquare",
-    ],
-    form: NoteFormFields,
-    name: i18n.t("Note"),
-    template: {},
-  },
-  zone: {
-    component: Zone,
-    defaultActions: ["clone", "lock", "remove"],
-    availableActions: [
-      "clone",
-      "lock",
-      "remove",
-      "alignAsLine",
-      "alignAsSquare",
-    ],
-    form: ZoneFormFields,
-    name: i18n.t("Zone"),
-    template: { layer: -1 },
-  },
-};
-
-export { default as useGameItemActionMap } from "./useGameItemActionMap";
+export { default as actionMap } from "./actionMap";
 
 export { default as ItemForm } from "./ItemForm";

+ 300 - 0
src/gameComponents/itemTemplates.js

@@ -0,0 +1,300 @@
+import i18n from "../i18n";
+
+import { nanoid } from "nanoid";
+
+import Rect from "./Rect";
+import Cube from "./Cube";
+import Round from "./Round";
+import Token from "./Token";
+import Image from "./Image";
+import Counter from "./Counter";
+import Dice from "./Dice";
+import Note from "./Note";
+import Zone from "./Zone";
+import Meeple from "./Meeple";
+import Jewel from "./Jewel";
+import Pawn from "./Pawn";
+import CheckerBoard from "./CheckerBoard";
+import Cylinder from "./Cylinder";
+
+import ImageFormFields from "./forms/ImageFormFields";
+import CounterFormFields from "./forms/CounterFormFields";
+import RectFormFields from "./forms/RectFormFields";
+import CubeFormFields from "./forms/CubeFormFields";
+import RoundFormFields from "./forms/RoundFormFields";
+import DiceFormFields from "./forms/DiceFormFields";
+import NoteFormFields from "./forms/NoteFormFields";
+import ZoneFormFields from "./forms/ZoneFormFields";
+import TokenFormFields from "./forms/TokenFormFields";
+import MeepleFormFields from "./forms/MeepleFormFields";
+import JewelFormFields from "./forms/JewelFormFields";
+import PawnFormFields from "./forms/PawnFormFields";
+import CheckerBoardFormFields from "./forms/CheckerBoardFormFields";
+import CylinderFormFields from "./forms/CylinderFormFields";
+
+const itemTemplates = {
+  rect: {
+    component: Rect,
+    defaultActions: ["lock", "remove"],
+    availableActions: [
+      "stack",
+      "alignAsLine",
+      "alignAsSquare",
+      "shuffle",
+      "clone",
+      "lock",
+      "remove",
+    ],
+    form: RectFormFields,
+    name: i18n.t("Rectangle"),
+    template: {},
+  },
+  cube: {
+    component: Cube,
+    defaultActions: ["clone", "lock", "remove"],
+    availableActions: [
+      "stack",
+      "alignAsLine",
+      "alignAsSquare",
+      "shuffle",
+      "clone",
+      "lock",
+      "remove",
+    ],
+    form: CubeFormFields,
+    name: i18n.t("Cube"),
+    template: {},
+  },
+  cylinder: {
+    component: Cylinder,
+    defaultActions: ["clone", "lock", "remove"],
+    availableActions: [
+      "stack",
+      "alignAsLine",
+      "alignAsSquare",
+      "shuffle",
+      "clone",
+      "lock",
+      "remove",
+    ],
+    form: CylinderFormFields,
+    name: i18n.t("Cylinder"),
+    template: {},
+  },
+  round: {
+    component: Round,
+    defaultActions: ["clone", "lock", "remove"],
+    availableActions: [
+      "stack",
+      "alignAsLine",
+      "alignAsSquare",
+      "shuffle",
+      "clone",
+      "lock",
+      "remove",
+    ],
+    form: RoundFormFields,
+    name: i18n.t("Round"),
+    template: {},
+  },
+  token: {
+    component: Token,
+    defaultActions: ["clone", "lock", "remove"],
+    availableActions: [
+      "stack",
+      "alignAsLine",
+      "alignAsSquare",
+      "shuffle",
+      "clone",
+      "lock",
+      "remove",
+    ],
+    form: TokenFormFields,
+    name: i18n.t("Token"),
+    template: {},
+  },
+  meeple: {
+    component: Meeple,
+    defaultActions: ["clone", "lock", "remove"],
+    availableActions: [
+      "stack",
+      "alignAsLine",
+      "alignAsSquare",
+      "shuffle",
+      "clone",
+      "lock",
+      "remove",
+    ],
+    form: MeepleFormFields,
+    name: i18n.t("Meeple"),
+    template: {},
+  },
+  pawn: {
+    component: Pawn,
+    defaultActions: ["clone", "lock", "remove"],
+    availableActions: [
+      "stack",
+      "alignAsLine",
+      "alignAsSquare",
+      "shuffle",
+      "clone",
+      "lock",
+      "remove",
+    ],
+    form: PawnFormFields,
+    name: i18n.t("Pawn"),
+    template: {},
+  },
+  jewel: {
+    component: Jewel,
+    defaultActions: ["clone", "lock", "remove"],
+    availableActions: [
+      "stack",
+      "alignAsLine",
+      "alignAsSquare",
+      "shuffle",
+      "clone",
+      "lock",
+      "remove",
+    ],
+    form: JewelFormFields,
+    name: i18n.t("Jewel"),
+    template: {},
+  },
+  checkerboard: {
+    component: CheckerBoard,
+    defaultActions: ["clone", "lock", "remove"],
+    availableActions: ["clone", "lock", "remove"],
+    form: CheckerBoardFormFields,
+    name: i18n.t("Checkerboard"),
+    template: { layer: -1 },
+  },
+  image: {
+    component: Image,
+    defaultActions: (item) => {
+      if (item.backContent) {
+        return [
+          "flip",
+          "flipSelf",
+          "tap",
+          "stack",
+          "shuffle",
+          "clone",
+          "lock",
+          "remove",
+        ];
+      } else {
+        return ["tap", "stack", "shuffle", "clone", "lock", "remove"];
+      }
+    },
+    availableActions: (item) => {
+      if (item.backContent) {
+        return [
+          "flip",
+          "flipSelf",
+          "tap",
+          "rotate30",
+          "rotate45",
+          "rotate60",
+          "rotate90",
+          "rotate180",
+          "stack",
+          "alignAsLine",
+          "alignAsSquare",
+          "shuffle",
+          "randomlyRotate30",
+          "randomlyRotate45",
+          "randomlyRotate60",
+          "randomlyRotate90",
+          "randomlyRotate180",
+          "clone",
+          "lock",
+          "remove",
+        ];
+      } else {
+        return [
+          "tap",
+          "rotate30",
+          "rotate45",
+          "rotate60",
+          "rotate90",
+          "rotate180",
+          "stack",
+          "alignAsLine",
+          "alignAsSquare",
+          "shuffle",
+          "randomlyRotate30",
+          "randomlyRotate45",
+          "randomlyRotate60",
+          "randomlyRotate90",
+          "randomlyRotate180",
+          "clone",
+          "lock",
+          "remove",
+        ];
+      }
+    },
+    form: ImageFormFields,
+    name: i18n.t("Image"),
+    template: {},
+  },
+  counter: {
+    component: Counter,
+    defaultActions: ["clone", "lock", "remove"],
+    availableActions: ["clone", "lock", "remove"],
+    form: CounterFormFields,
+    name: i18n.t("Counter"),
+    template: {},
+  },
+  dice: {
+    component: Dice,
+    defaultActions: ["clone", "lock", "remove"],
+    availableActions: [
+      "clone",
+      "lock",
+      "remove",
+      "alignAsLine",
+      "alignAsSquare",
+    ],
+    form: DiceFormFields,
+    name: i18n.t("Dice"),
+    template: {},
+  },
+  note: {
+    component: Note,
+    defaultActions: ["shuffle", "clone", "lock", "remove"],
+    availableActions: [
+      "shuffle",
+      "clone",
+      "lock",
+      "remove",
+      "alignAsLine",
+      "alignAsSquare",
+    ],
+    form: NoteFormFields,
+    name: i18n.t("Note"),
+    template: {},
+  },
+  zone: {
+    component: Zone,
+    defaultActions: ["clone", "lock", "remove"],
+    availableActions: [
+      "clone",
+      "lock",
+      "remove",
+      "alignAsLine",
+      "alignAsSquare",
+    ],
+    form: ZoneFormFields,
+    name: i18n.t("Zone"),
+    template: { layer: -1 },
+  },
+};
+
+export const itemLibrary = Object.keys(itemTemplates).map((key) => ({
+  type: key,
+  ...itemTemplates[key],
+  uid: nanoid(),
+}));
+
+export default itemTemplates;

+ 2 - 2
src/gameComponents/useGameItemActionMap.jsx

@@ -4,7 +4,7 @@ import { nanoid } from "nanoid";
 import { toast } from "react-toastify";
 import { useSetRecoilState, useRecoilCallback } from "recoil";
 
-import { useItems } from "../components/board/Items";
+import { useItemBaseActions } from "../components/board/Items";
 import { SelectedItemsAtom } from "../components/board";
 import { useUsers } from "../components/users";
 import { ItemMapAtom } from "../components/board";
@@ -33,7 +33,7 @@ export const useGameItemActionMap = () => {
     insertItemBefore,
     reverseItemsOrder,
     swapItems,
-  } = useItems();
+  } = useItemBaseActions();
 
   const { t } = useTranslation();
 

+ 15 - 30
src/hooks/useGame.jsx

@@ -1,52 +1,37 @@
 import React, { useContext } from "react";
-import { useSetRecoilState, useRecoilCallback } from "recoil";
 
 import { updateGame } from "../utils/api";
 
-import { useItems } from "../components/board/Items";
-import {
-  AvailableItemListAtom,
-  AllItemsSelector,
-  BoardConfigAtom,
-} from "../components/board";
-import useBoardConfig from "../components/useBoardConfig";
+import { useBoardConfig } from "react-sync-board";
 
 export const GameContext = React.createContext({});
 
 export const GameProvider = ({ gameId, game, children }) => {
-  const { setItemList } = useItems();
-  const setAvailableItemList = useSetRecoilState(AvailableItemListAtom);
-  const [, setBoardConfig] = useBoardConfig();
+  const [items, setItems] = React.useState([]);
+  const [availableItems, setAvailableItems] = React.useState([]);
+  const [boardConfig, setBoardConfig] = useBoardConfig();
 
   const [gameLoaded, setGameLoaded] = React.useState(false);
 
   const setGame = React.useCallback(
     async (newGame) => {
-      setAvailableItemList(newGame.availableItems);
+      setAvailableItems(newGame.availableItems);
       // The filter prevent the empty item bug on reload
-      setItemList(newGame.items.filter((item) => item));
+      setItems(newGame.items.filter((item) => item));
       setBoardConfig(newGame.board, false);
       setGameLoaded(true);
     },
-    [setAvailableItemList, setBoardConfig, setItemList]
+    [setBoardConfig]
   );
 
-  const getCurrentGame = useRecoilCallback(
-    ({ snapshot }) => async () => {
-      const availableItemList = await snapshot.getPromise(
-        AvailableItemListAtom
-      );
-      const boardConfig = await snapshot.getPromise(BoardConfigAtom);
-      const itemList = await snapshot.getPromise(AllItemsSelector);
-      const currentGame = {
-        items: itemList,
-        board: boardConfig,
-        availableItems: availableItemList,
-      };
-      return currentGame;
-    },
-    []
-  );
+  const getCurrentGame = React.useCallback(async () => {
+    const currentGame = {
+      items,
+      board: boardConfig,
+      availableItems,
+    };
+    return currentGame;
+  }, [availableItems, boardConfig, items]);
 
   const saveGame = React.useCallback(async () => {
     const currentGame = await getCurrentGame();

+ 17 - 0
src/hooks/useNotify.js

@@ -0,0 +1,17 @@
+import React from "react";
+
+const useNotify = () => {
+  const [count, setCount] = React.useState(0);
+
+  const add = React.useCallback(() => {
+    setCount((prev) => prev + 1);
+  }, []);
+
+  const reset = React.useCallback(() => {
+    setCount(0);
+  }, []);
+
+  return { count, add, reset };
+};
+
+export default useNotify;

+ 67 - 74
src/hooks/useSession.jsx

@@ -1,19 +1,8 @@
+import { debounce } from "lodash";
 import React, { useContext } from "react";
-import { useSetRecoilState, useRecoilCallback } from "recoil";
 
 import { updateSession, getSession, getGame } from "../utils/api";
 
-import SubscribeSessionEvents from "../components/SubscribeSessionEvents";
-import { useItems } from "../components/board/Items";
-import {
-  AvailableItemListAtom,
-  AllItemsSelector,
-  BoardConfigAtom,
-} from "../components/board";
-import { MessagesAtom, parseMessage } from "../components/message/useMessage";
-import useBoardConfig from "../components/useBoardConfig";
-import { useC2C } from "react-sync-board";
-
 export const SessionContext = React.createContext({});
 
 const emtpyBoard = {
@@ -25,7 +14,7 @@ const emtpyBoard = {
     translations: [
       {
         language: "fr",
-        name: "Choississez un jeu",
+        name: "Choisissez un jeu",
         description: "...",
       },
     ],
@@ -37,11 +26,12 @@ const emtpyBoard = {
 };
 
 export const SessionProvider = ({ sessionId, fromGameId, children }) => {
-  const { c2c } = useC2C("board");
-  const { setItemList } = useItems();
-  const setAvailableItemList = useSetRecoilState(AvailableItemListAtom);
-  const [, setBoardConfig] = useBoardConfig();
-  const setMessages = useSetRecoilState(MessagesAtom);
+  const [initialItems, setInitialItems] = React.useState([]);
+  const [availableItems, setAvailableItems] = React.useState([]);
+  const [messages, setMessages] = React.useState([]);
+  const [boardConfig, setBoardConfig] = React.useState({});
+  const [currentItems, setCurrentItems] = React.useState([]);
+  const [isMaster, setIsMaster] = React.useState(null);
 
   const [sessionLoaded, setSessionLoaded] = React.useState(false);
   const [currentGameId, setCurrentGameId] = React.useState(fromGameId);
@@ -74,89 +64,92 @@ export const SessionProvider = ({ sessionId, fromGameId, children }) => {
   }, [fromGameId, sessionId]);
 
   const setSession = React.useCallback(
-    async (newData, sync = false) => {
+    async (newData) => {
       const { availableItems, items, board, messages = [] } = newData;
 
-      setAvailableItemList(availableItems);
+      setAvailableItems(availableItems);
       // The filter prevent the empty item bug on reload
-      setItemList(items.filter((item) => item));
-      setBoardConfig(board, false);
-      setMessages(messages.map((m) => parseMessage(m)));
-
-      if (sync) {
-        // Send loadSession event for other user
-        c2c.publish("loadSession", newData);
-      }
-
+      setInitialItems(items.filter((item) => item));
+      setBoardConfig(board);
+      setMessages(messages);
       setSessionLoaded(true);
     },
-    [c2c, setAvailableItemList, setBoardConfig, setItemList, setMessages]
+    [setMessages]
   );
 
-  const getCurrentSession = useRecoilCallback(
-    ({ snapshot }) => async () => {
-      const availableItemList = await snapshot.getPromise(
-        AvailableItemListAtom
-      );
-      const messages = await snapshot.getPromise(MessagesAtom);
-      const boardConfig = await snapshot.getPromise(BoardConfigAtom);
-      const itemList = await snapshot.getPromise(AllItemsSelector);
-
-      const currentSession = {
-        items: itemList,
-        board: boardConfig,
-        availableItems: availableItemList,
-        messages: messages.slice(-50),
-        timestamp: Date.now(),
-        gameId: fromGameId,
-      };
-
-      return currentSession;
+  /*const getCurrentSession = React.useCallback(() => {
+    const currentSession = {
+      items: currentItems,
+      board: boardConfig,
+      availableItems: availableItems,
+      messages: messages.slice(-50),
+      timestamp: Date.now(),
+      gameId: fromGameId,
+    };
+
+    return currentSession;
+  }, [availableItems, boardConfig, currentItems, fromGameId, messages]);*/
+
+  const changeGame = React.useCallback(async (newGameId) => {
+    const newGame = await getGame(newGameId);
+
+    setAvailableItems(newGame.availableItems);
+    // The filter prevent the empty item bug on reload
+    setInitialItems(newGame.items.filter((item) => item));
+    setBoardConfig(newGame.board);
+    //const currentSession = getCurrentSession();
+
+    //setSession({ ...currentSession, ...newGame });
+  }, []);
+
+  const saveSession = React.useCallback(
+    async (currentSession) => {
+      //const currentSession = await getCurrentSession();
+
+      if (currentSession.items.length) {
+        try {
+          return await updateSession(sessionId, currentSession);
+        } catch (e) {
+          console.log(e);
+        }
+      }
     },
-    [fromGameId]
+    [sessionId]
   );
 
-  const changeGame = React.useCallback(
-    async (newGameId) => {
-      const newGame = await getGame(newGameId);
-
-      const currentSession = getCurrentSession();
-
-      setSession({ ...currentSession, ...newGame }, true);
-    },
-    [getCurrentSession, setSession]
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  const debouncedSetCurrentItems = React.useCallback(
+    debounce(setCurrentItems, 500),
+    []
   );
 
-  const saveSession = React.useCallback(async () => {
-    const currentSession = await getCurrentSession();
-
-    if (currentSession.items.length) {
-      try {
-        return await updateSession(sessionId, currentSession);
-      } catch (e) {
-        console.log(e);
-      }
-    }
-  }, [sessionId, getCurrentSession]);
-
   return (
     <SessionContext.Provider
       value={{
         loadSession,
         changeGame,
         setSession,
-        getSession: getCurrentSession,
+        // getSession: getCurrentSession,
         saveSession,
         sessionLoaded,
         sessionId,
         gameId: currentGameId,
+        initialItems,
+        availableItems,
+        boardConfig,
+        messages,
+        setCurrentItems: debouncedSetCurrentItems,
+        currentItems,
+        isMaster,
+        initialMessages: messages,
+        setIsMaster,
       }}
     >
       {children}
-      <SubscribeSessionEvents
+      {/*<SubscribeSessionEvents
         getSession={getCurrentSession}
         setSession={setSession}
-      />
+      />*/}
     </SessionContext.Provider>
   );
 };

+ 6 - 0
src/index.jsx

@@ -10,6 +10,8 @@ import "./i18n"; // load translation system
 import App from "./App";
 import * as serviceWorker from "./serviceWorker";
 
+import styled from "styled-components";
+
 ReactDOM.render(
   <React.StrictMode>
     <App />
@@ -21,3 +23,7 @@ ReactDOM.render(
 // unregister() to register() below. Note this comes with some pitfalls.
 // Learn more about service workers: https://bit.ly/CRA-PWA
 serviceWorker.unregister();
+
+window.Styled2 = styled;
+
+console.log(window.Styled2 === window.Styled1);

+ 1 - 1
src/utils/api.js

@@ -66,7 +66,7 @@ export const getBestTranslationFromConfig = (
     defaultDescription,
     name,
     info,
-  },
+  } = {},
   langs
 ) => {
   const translationsMap = {

+ 22 - 26
src/views/AutoSaveSession.jsx

@@ -1,23 +1,16 @@
 import React from "react";
-import { useRecoilValue } from "recoil";
-import { useC2C } from "react-sync-board";
-import { MessagesAtom } from "../components/message/useMessage";
+import { useItems, useBoardConfig, useMessage } from "react-sync-board";
 import useTimeout from "../hooks/useTimeout";
 import useSession from "../hooks/useSession";
-import debounce from "lodash.debounce";
 
-import {
-  AvailableItemListAtom,
-  BoardConfigAtom,
-  AllItemsSelector,
-} from "../components/board";
-
-const GRACE_DELAY = import.meta.env.VITE_CI ? 100 : 5000;
+const GRACE_DELAY = import.meta.env.VITE_CI === "1" ? 100 : 5000;
 
 export const AutoSaveSession = () => {
-  const { isMaster } = useC2C("board");
-
-  const { saveSession } = useSession();
+  const { saveSession, isMaster, availableItems, gameId } = useSession();
+  const items = useItems();
+  const [boardConfig] = useBoardConfig();
+  const { messages } = useMessage();
+  const timeoutRef = React.useRef(null);
 
   // Delay the first update to avoid too many session
   const readyRef = React.useRef(false);
@@ -26,21 +19,24 @@ export const AutoSaveSession = () => {
     readyRef.current = true;
   }, GRACE_DELAY);
 
-  const messages = useRecoilValue(MessagesAtom);
-  const itemList = useRecoilValue(AllItemsSelector);
-  const boardConfig = useRecoilValue(BoardConfigAtom);
-  const availableItemList = useRecoilValue(AvailableItemListAtom);
-
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  const autoSave = React.useCallback(debounce(saveSession, 1000), [
-    saveSession,
-  ]);
+  const autoSave = React.useCallback(() => {
+    const currentSession = {
+      items: items,
+      board: boardConfig,
+      availableItems: availableItems,
+      messages: messages.slice(-50),
+      timestamp: Date.now(),
+      gameId: gameId,
+    };
+    saveSession(currentSession);
+  }, [availableItems, boardConfig, gameId, items, messages, saveSession]);
 
   React.useEffect(() => {
-    if (isMaster && readyRef.current) {
-      autoSave();
+    if (readyRef.current && isMaster) {
+      clearTimeout(timeoutRef.current);
+      timeoutRef.current = setTimeout(autoSave, GRACE_DELAY);
     }
-  }, [isMaster, autoSave, itemList, boardConfig, availableItemList, messages]);
+  }, [autoSave, isMaster, items, messages, boardConfig]);
 
   return null;
 };

+ 91 - 0
src/views/BoardView/ActionBar.jsx

@@ -0,0 +1,91 @@
+import React from "react";
+
+import MessageButton from "../../components/messages/MessageButton";
+import EditInfoButton from "../../components/EditInfoButton";
+import AddItemButton from "../../components/AddItemButton";
+
+import styled from "styled-components";
+import { useTranslation } from "react-i18next";
+
+import Touch from "../../components/ui/Touch";
+
+const StyledActionBar = styled.div`
+  position: absolute;
+  bottom: 1em;
+  right: 0em;
+  display: flex;
+  width: 100%;
+  text-shadow: 1px 1px 2px #222;
+  font-size: 0.8em;
+  pointer-events: none;
+
+  & > *:not(.spacer) {
+    padding: 0 1.5em;
+    pointer-events: all;
+  }
+
+  & .spacer {
+    flex: 1;
+  }
+
+  @media screen and (max-width: 640px) {
+    & > *:not(.spacer) {
+      padding: 0 0.5em;
+    }
+    & .spacer {
+      padding: 0;
+    }
+  }
+
+  @media screen and (max-width: 420px) {
+    & > *:not(.spacer) {
+      padding: 0 0.2em;
+    }
+  }
+`;
+
+const ActionBar = ({
+  editMode,
+  BoardFormComponent,
+  itemLibraries,
+  moveFirst,
+  setMoveFirst,
+  hideMenu,
+  setHideMenu,
+}) => {
+  const { t } = useTranslation();
+  return (
+    <StyledActionBar>
+      {!editMode && <MessageButton />}
+      {editMode && <EditInfoButton BoardFormComponent={BoardFormComponent} />}
+      <div className="spacer" />
+      <Touch
+        onClick={() => setMoveFirst(false)}
+        alt={t("Select mode")}
+        label={t("Select")}
+        title={t("Switch to select mode")}
+        icon={"mouse-pointer"}
+        active={!moveFirst}
+      />
+      <Touch
+        onClick={() => setMoveFirst(true)}
+        alt={t("Move mode")}
+        label={t("Move")}
+        title={t("Switch to move mode")}
+        icon={"hand"}
+        active={moveFirst}
+      />
+      <Touch
+        onClick={() => setHideMenu((prev) => !prev)}
+        alt={hideMenu ? t("Show menu") : t("Hide menu")}
+        label={hideMenu ? t("Show menu") : t("Hide menu")}
+        title={hideMenu ? t("Show action menu") : t("Hide action menu")}
+        icon={hideMenu ? "eye-with-line" : "eye"}
+      />
+      <div className="spacer" />
+      <AddItemButton itemLibraries={itemLibraries} />
+    </StyledActionBar>
+  );
+};
+
+export default ActionBar;

+ 1 - 3
src/views/BoardView/BoardForm.jsx

@@ -6,12 +6,10 @@ import { Field } from "react-final-form";
 import Hint from "../../components/ui/formUtils/Hint";
 import Label from "../../components/ui/formUtils/Label";
 import SliderRange from "../../components/ui/SliderRange";
-import useBoardConfig from "../../components/useBoardConfig";
 import { ImageField } from "../../components/mediaLibrary";
 
-const BoardConfigForm = () => {
+const BoardConfigForm = ({ boardConfig, setBoardConfig }) => {
   const { t } = useTranslation();
-  const [boardConfig, setBoardConfig] = useBoardConfig();
 
   const [defaultPlayerCount] = React.useState([]);
 

+ 53 - 11
src/views/BoardView/BoardView.jsx

@@ -1,18 +1,27 @@
 import React from "react";
 
 import { SHOW_WELCOME } from "../../utils/settings";
-import MainView from "../../components/MainView";
-import { useC2C } from "react-sync-board";
+import { BoardWrapper, Board } from "react-sync-board";
 
 import WelcomeModal from "./WelcomeModal";
 import NavBar from "./NavBar";
 import BoardForm from "./BoardForm";
+import SelectedItemPane from "./SelectedItemsPane";
+
+import { ItemForm } from "../../gameComponents";
 
 import {
   uploadResourceImage,
   listResourceImage,
   deleteResourceImage,
 } from "../../utils/api";
+import ActionBar from "./ActionBar";
+
+import {
+  MediaLibraryProvider,
+  ImageDropNPaste,
+} from "../../components/mediaLibrary";
+import AutoSaveSession from "../AutoSaveSession";
 
 const mediaHandlers = {
   uploadMedia: uploadResourceImage,
@@ -20,23 +29,56 @@ const mediaHandlers = {
   deleteMedia: deleteResourceImage,
 };
 
-export const BoardView = (props) => {
-  const { isMaster } = useC2C("board");
+const style = {
+  background:
+    "radial-gradient(circle, hsla(218, 30%, 40%, 0.7), hsla(218, 40%, 40%, 0.05) 100%),  url(/board.png)",
+  border: "1px solid transparent",
+  borderRadius: "2px",
+  boxShadow: "0px 3px 6px #00000029",
+};
 
+export const BoardView = (props) => {
+  // TODO open only for master
   const [showWelcomeModal, setShowWelcomeModal] = React.useState(
-    SHOW_WELCOME && !props.editMode && isMaster
+    SHOW_WELCOME && !props.editMode
   );
 
+  const [moveFirst, setMoveFirst] = React.useState(false);
+  const [hideMenu, setHideMenu] = React.useState(false);
+
   return (
-    <>
-      <NavBar editMode={props.edit} />
-      <WelcomeModal show={showWelcomeModal} setShow={setShowWelcomeModal} />
-      <MainView
+    <MediaLibraryProvider libraries={props.mediaLibraries} {...mediaHandlers}>
+      <BoardWrapper
         {...props}
         mediaHandlers={mediaHandlers}
         BoardFormComponent={BoardForm}
-      />
-    </>
+        hideMenu={hideMenu}
+        style={{
+          position: "relative",
+          width: "100vw",
+          height: "100vh",
+          overflow: "hidden",
+        }}
+      >
+        <ImageDropNPaste>
+          <Board moveFirst={moveFirst} style={style} />
+          <NavBar editMode={props.edit} />
+          <ActionBar
+            editMode={props.edit}
+            BoardFormComponent={BoardForm}
+            itemLibraries={props.itemLibraries}
+            moveFirst={moveFirst}
+            setMoveFirst={setMoveFirst}
+            hideMenu={hideMenu}
+            setHideMenu={setHideMenu}
+          />
+          <WelcomeModal show={showWelcomeModal} setShow={setShowWelcomeModal} />
+        </ImageDropNPaste>
+
+        <AutoSaveSession />
+        <SelectedItemPane ItemFormComponent={ItemForm} />
+      </BoardWrapper>
+    </MediaLibraryProvider>
   );
 };
 

+ 67 - 0
src/views/BoardView/DownloadLink.jsx

@@ -0,0 +1,67 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+
+const generateDownloadURI = (data) => {
+  return (
+    "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data))
+  );
+};
+
+export const DownloadLink = ({ getData = () => {} }) => {
+  const { t } = useTranslation();
+
+  const [downloadURI, setDownloadURI] = React.useState("");
+  const [date, setDate] = React.useState(Date.now());
+  const [genOnce, setGenOnce] = React.useState(false);
+
+  const updateSaveLink = React.useCallback(async () => {
+    const data = await getData();
+    if (data.items.length) {
+      setDownloadURI(generateDownloadURI(data));
+      setDate(Date.now());
+      setGenOnce(true);
+    }
+  }, [getData]);
+
+  React.useEffect(() => {
+    let mounted = true;
+
+    const cancel = setInterval(() => {
+      if (!mounted) return;
+      updateSaveLink();
+    }, 2000);
+
+    updateSaveLink();
+
+    return () => {
+      mounted = false;
+      setGenOnce(false);
+      clearInterval(cancel);
+    };
+  }, [updateSaveLink]);
+
+  return (
+    <>
+      {genOnce && (
+        <a
+          className="button success icon"
+          href={downloadURI}
+          download={`airboardgame_${date}.json`}
+        >
+          {t("Export")}
+          <img
+            src={"https://icongr.am/entypo/download.svg?size=20&color=f9fbfa"}
+            alt="icon"
+          />
+        </a>
+      )}
+      {!genOnce && (
+        <button className="button" disabled>
+          {t("Generating export")}...
+        </button>
+      )}
+    </>
+  );
+};
+
+export default DownloadLink;

+ 18 - 2
src/views/BoardView/ExportModal.jsx

@@ -2,13 +2,29 @@ import React from "react";
 import { useTranslation } from "react-i18next";
 
 import useSession from "../../hooks/useSession";
+import { useItems, useBoardConfig, useMessage } from "react-sync-board";
 
-import DownloadLink from "../../components/DownloadLink";
+import DownloadLink from "./DownloadLink";
 import Modal from "../../components/ui/Modal";
 
 const ExportModal = ({ show, setShow }) => {
   const { t } = useTranslation();
-  const { getSession } = useSession();
+  const { gameId, availableItems } = useSession();
+  const items = useItems();
+  const [boardConfig] = useBoardConfig();
+  const { messages } = useMessage();
+
+  const getSession = React.useCallback(() => {
+    const currentSession = {
+      items: items,
+      board: boardConfig,
+      availableItems: availableItems,
+      messages: messages.slice(-50),
+      timestamp: Date.now(),
+      gameId: gameId,
+    };
+    return currentSession;
+  }, [availableItems, boardConfig, gameId, items, messages]);
 
   return (
     <Modal title={t("Save game")} setShow={setShow} show={show}>

+ 5 - 4
src/views/BoardView/InfoModal.jsx

@@ -5,7 +5,7 @@ import useAsyncEffect from "use-async-effect";
 import styled from "styled-components";
 
 import Modal from "../../components/ui/Modal";
-import { BoardConfigAtom } from "../../components/board";
+// import { BoardConfigAtom } from "../../components/board";
 
 import { getBestTranslationFromConfig } from "../../utils/api";
 
@@ -30,11 +30,12 @@ const InfoModal = ({ show, setShow }) => {
 
   const [info, setInfo] = React.useState("");
 
-  const boardConfig = useRecoilValue(BoardConfigAtom);
+  // const boardConfig = useRecoilValue(BoardConfigAtom);
 
   const translation = React.useMemo(
-    () => getBestTranslationFromConfig(boardConfig, i18n.languages),
-    [boardConfig, i18n.languages]
+    // () => getBestTranslationFromConfig(boardConfig, i18n.languages),
+    () => getBestTranslationFromConfig({}, i18n.languages),
+    [i18n.languages]
   );
 
   useAsyncEffect(

+ 53 - 0
src/views/BoardView/ItemFormFactory.jsx

@@ -0,0 +1,53 @@
+import React from "react";
+import { Form } from "react-final-form";
+
+import AutoSave from "../../components/ui/formUtils/AutoSave";
+import {
+  useItemBaseActions,
+  useSelectedItems,
+  useItems,
+} from "react-sync-board";
+
+export const getFormFieldComponent = (type, itemMap) => {
+  if (type in itemMap) {
+    return itemMap[type].form;
+  }
+  return () => null;
+};
+
+const ItemFormFactory = ({ ItemFormComponent }) => {
+  const { batchUpdateItems } = useItemBaseActions();
+  const items = useItems();
+  const selectedItems = useSelectedItems();
+
+  const onSubmitHandler = React.useCallback(
+    (formValues) => {
+      batchUpdateItems(selectedItems, (item) => ({
+        ...item,
+        ...formValues,
+      }));
+    },
+    [batchUpdateItems, selectedItems]
+  );
+
+  return (
+    <Form
+      onSubmit={onSubmitHandler}
+      render={() => (
+        <div
+          style={{
+            display: "flex",
+            flexDirection: "column",
+          }}
+        >
+          <AutoSave save={onSubmitHandler} />
+          <ItemFormComponent
+            items={items.filter(({ id }) => selectedItems.includes(id))}
+          />
+        </div>
+      )}
+    />
+  );
+};
+
+export default React.memo(ItemFormFactory);

+ 3 - 4
src/views/BoardView/LoadSessionModal.jsx

@@ -1,7 +1,6 @@
 import React from "react";
 import { useTranslation } from "react-i18next";
 
-import { useC2C } from "react-sync-board";
 import useSession from "../../hooks/useSession";
 
 import Modal from "../../components/ui/Modal";
@@ -10,16 +9,16 @@ import LoadData from "./LoadData";
 
 const LoadSessionModal = ({ show, setShow }) => {
   const { t } = useTranslation();
-  const { c2c } = useC2C("board");
+  //const { c2c } = useC2C("board");
   const { setSession } = useSession();
 
   const loadSession = React.useCallback(
     (sessionData) => {
       setSession(sessionData);
-      c2c.publish("loadSession", sessionData);
+      // c2c.publish("loadSession", sessionData);
       setShow(false);
     },
-    [c2c, setSession, setShow]
+    [setSession, setShow]
   );
 
   return (

+ 4 - 6
src/views/BoardView/NavBar.jsx

@@ -5,14 +5,12 @@ import { useTranslation } from "react-i18next";
 import { useHistory, useRouteMatch } from "react-router-dom";
 import { confirmAlert } from "react-confirm-alert";
 
-import { UserList } from "../../components/users";
+import UserList from "../../components/users/UserList";
 import Touch from "../../components/ui/Touch";
-import useBoardConfig from "../../components/useBoardConfig";
 import WebConferenceButton from "../webconf/WebConferenceButton";
 
 import { getBestTranslationFromConfig } from "../../utils/api";
 import { ENABLE_WEBCONFERENCE } from "../../utils/settings";
-import { useC2C } from "react-sync-board";
 import useLocalStorage from "../../hooks/useLocalStorage";
 
 import InfoModal from "./InfoModal";
@@ -23,6 +21,8 @@ import ExportModal from "./ExportModal";
 import SaveExportModal from "./SaveExportModal";
 import WelcomeModal from "./WelcomeModal";
 
+import useSession from "../../hooks/useSession";
+
 const StyledNavBar = styled.div.attrs(() => ({ className: "nav" }))`
   position: fixed;
   top: 0;
@@ -133,7 +133,7 @@ const StyledNavBar = styled.div.attrs(() => ({ className: "nav" }))`
 
 const NavBar = ({ editMode }) => {
   const { t, i18n } = useTranslation();
-  const { isMaster, room } = useC2C("board");
+  const { sessionId: room, isMaster, boardConfig } = useSession();
 
   const history = useHistory();
   const match = useRouteMatch();
@@ -145,8 +145,6 @@ const NavBar = ({ editMode }) => {
   const [showLink, setShowLink] = React.useState(false);
   const [isBeta] = useLocalStorage("isBeta", false);
 
-  const [boardConfig] = useBoardConfig();
-
   const translation = React.useMemo(
     () => getBestTranslationFromConfig(boardConfig, i18n.languages),
     [boardConfig, i18n.languages]

+ 248 - 0
src/views/BoardView/SelectedItemsPane.jsx

@@ -0,0 +1,248 @@
+import React from "react";
+import styled from "styled-components";
+import { toast } from "react-toastify";
+import { useTranslation } from "react-i18next";
+
+import { insideClass, hasClass } from "../utils";
+import SidePanel from "../../components/ui/SidePanel";
+import ItemFormFactory from "./ItemFormFactory";
+import {
+  useAvailableActions,
+  useItemActions,
+  useSelectionBox,
+  useSelectedItems,
+  useBoardState,
+} from "react-sync-board";
+
+const ActionPane = styled.div.attrs(({ top, left, height }) => {
+  if (top < 120) {
+    return {
+      style: {
+        transform: `translate(${left}px, ${top + height + 5}px)`,
+      },
+    };
+  }
+  return {
+    style: {
+      transform: `translate(${left}px, ${top - 60}px)`,
+    },
+  };
+})`
+  top: 0;
+  left: 0;
+  user-select: none;
+  touch-action: none;
+  position: absolute;
+  display: flex;
+  background-color: var(--color-blueGrey);
+  justify-content: center;
+  align-items: center;
+  border-radius: 4px;
+  padding: 0.1em 0.5em;
+  transition: opacity 100ms;
+  opacity: ${({ hide }) => (hide ? 0 : 0.9)};
+  
+  box-shadow: 2px 2px 10px 0.3px rgba(0, 0, 0, 0.5);
+
+  &:hover{
+    opacity: 1;
+  }
+
+  & button{
+    margin 0 4px;
+    padding: 0em;
+    height: 50px
+  }
+  & .button.icon-only{
+    padding: 0em;
+    opacity: 0.5;
+  }
+  & button.icon-only:hover{
+    opacity: 1;
+  }
+  & .count{
+    color: var(--color-secondary);
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    line-height: 0.8em;
+  }
+  & .number{
+    font-size: 1.5em;
+    line-height: 1em;
+  }
+`;
+
+const CardContent = styled.div.attrs(() => ({ className: "content" }))`
+  display: flex;
+  flex-direction: column;
+  padding: 0.5em;
+`;
+
+const SelectedItemsPane = ({ hideMenu = false, ItemFormComponent }) => {
+  const actionMap = useItemActions();
+
+  const { availableActions } = useAvailableActions();
+  const [showEdit, setShowEdit] = React.useState(false);
+
+  const { t } = useTranslation();
+
+  const selectedItems = useSelectedItems();
+  const boardState = useBoardState();
+
+  const selectionBox = useSelectionBox();
+
+  React.useEffect(() => {
+    const onKeyUp = (e) => {
+      // Block shortcut if we are typing in a textarea or input
+      if (["INPUT", "TEXTAREA"].includes(e.target.tagName)) return;
+
+      Object.keys(actionMap).forEach((key) => {
+        const { shortcut, action, edit: whileEdit } = actionMap[key];
+        if (
+          availableActions.includes(key) &&
+          e.key === shortcut &&
+          showEdit === !!whileEdit
+        ) {
+          // here
+          action();
+        }
+      });
+    };
+    document.addEventListener("keyup", onKeyUp);
+    return () => {
+      document.removeEventListener("keyup", onKeyUp);
+    };
+  }, [actionMap, availableActions, showEdit]);
+
+  const onDblClick = React.useCallback(
+    (e) => {
+      const foundElement = insideClass(e.target, "item");
+
+      // We dblclick outside of an element
+      if (!foundElement) return;
+
+      if (hasClass(foundElement, "locked")) {
+        toast.info(t("Long click to select locked elements"));
+        return;
+      }
+
+      const filteredActions = availableActions.filter(
+        (action) => !actionMap[action].disableDblclick
+      );
+
+      if (e.ctrlKey && filteredActions.length > 1) {
+        // Use second action
+        // here
+        actionMap[filteredActions[1]].action();
+      } else if (filteredActions.length > 0) {
+        // here
+        actionMap[filteredActions[0]].action();
+      }
+    },
+    [actionMap, availableActions, t]
+  );
+
+  React.useEffect(() => {
+    document.addEventListener("dblclick", onDblClick);
+    return () => {
+      document.removeEventListener("dblclick", onDblClick);
+    };
+  }, [onDblClick]);
+
+  if (hideMenu || selectedItems.length === 0) {
+    return null;
+  }
+
+  let title = "";
+  if (selectedItems.length === 1) {
+    title = t("Edit item");
+  }
+  if (selectedItems.length > 1) {
+    title = t("Edit all items");
+  }
+
+  return (
+    <>
+      <SidePanel
+        key={selectedItems[0]}
+        open={showEdit && !boardState.selecting}
+        onClose={() => {
+          setShowEdit(false);
+        }}
+        title={title}
+        width="25%"
+      >
+        <CardContent>
+          <ItemFormFactory ItemFormComponent={ItemFormComponent} />
+        </CardContent>
+      </SidePanel>
+      {selectedItems.length && !hideMenu && (
+        <ActionPane
+          {...selectionBox}
+          hide={
+            boardState.zooming || boardState.panning || boardState.movingItems
+          }
+        >
+          {(selectedItems.length > 1 || boardState.selecting) && (
+            <div className="count">
+              <span className="number">{selectedItems.length}</span>
+              <span>{t("Items")}</span>
+            </div>
+          )}
+          {!boardState.selecting &&
+            availableActions.map((action) => {
+              const {
+                label,
+                action: handler,
+                multiple,
+                edit: onlyEdit,
+                shortcut,
+                icon,
+              } = actionMap[action];
+              if (multiple && selectedItems.length < 2) return null;
+              if (onlyEdit && !showEdit) return null;
+              return (
+                <button
+                  className="button clear icon-only"
+                  key={action}
+                  // here
+                  onClick={() => handler()}
+                  title={label + (shortcut ? ` (${shortcut})` : "")}
+                >
+                  <img
+                    src={icon}
+                    style={{ width: "32px", height: "32px" }}
+                    alt={label}
+                  />
+                </button>
+              );
+            })}
+
+          {!boardState.selecting && (
+            <button
+              className="button clear icon-only"
+              onClick={() => setShowEdit((prev) => !prev)}
+              title={t("Edit")}
+            >
+              {!showEdit && (
+                <img
+                  src="https://icongr.am/feather/edit.svg?size=32&color=ffffff"
+                  alt={t("Edit")}
+                />
+              )}
+              {showEdit && (
+                <img
+                  src="https://icongr.am/feather/edit.svg?size=32&color=db5034"
+                  alt={t("Edit")}
+                />
+              )}
+            </button>
+          )}
+        </ActionPane>
+      )}
+    </>
+  );
+};
+
+export default SelectedItemsPane;

+ 2 - 2
src/views/BoardView/WelcomeModal.jsx

@@ -6,7 +6,7 @@ import { toast } from "react-toastify";
 
 import Modal from "../../components/ui/Modal";
 
-import { useC2C } from "react-sync-board";
+import useSession from "../../hooks/useSession";
 
 const StyledUrl = styled.div`
   background-color: var(--color-midGrey);
@@ -34,7 +34,7 @@ const WelcomeModal = ({ show, setShow, welcome = true }) => {
   const currentUrl = window.location.href;
   const inputRef = React.useRef();
 
-  const { room } = useC2C("board");
+  const { sessionId: room } = useSession();
 
   const handleCopy = () => {
     inputRef.current.style.display = "block";

+ 1 - 1
src/views/GameListItem.jsx

@@ -6,7 +6,7 @@ import { deleteGame, getBestTranslationFromConfig } from "../utils/api";
 import { confirmAlert } from "react-confirm-alert";
 import { toast } from "react-toastify";
 import { useMutation, useQueryClient } from "react-query";
-import { media2Url } from "./utils";
+import { media2Url } from "../components/mediaLibrary";
 
 const Game = styled.li`
   position: relative;

+ 156 - 0
src/views/Session.jsx

@@ -0,0 +1,156 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import useAsyncEffect from "use-async-effect";
+import { nanoid } from "nanoid";
+
+import {
+  itemTemplates,
+  itemLibrary,
+  actionMap,
+  ItemForm,
+} from "../gameComponents";
+
+import BoardView from "./BoardView";
+import Waiter from "./Waiter";
+
+import useSession, { SessionProvider } from "../hooks/useSession";
+
+// Keep compatibility with previous availableItems shape
+const migrateAvailableItemList = (old) => {
+  const groupMap = old.reduce((acc, { groupId, ...item }) => {
+    if (!acc[groupId]) {
+      acc[groupId] = [];
+    }
+    acc[groupId].push(item);
+    return acc;
+  }, {});
+  return Object.keys(groupMap).map((name) => ({
+    name,
+    items: groupMap[name],
+  }));
+};
+
+const adaptItem = (item) => ({
+  type: item.type,
+  template: item,
+  component: itemTemplates[item.type].component,
+  name: item.name || item.label || item.text || itemTemplates[item.type].name,
+  uid: nanoid(),
+});
+
+const adaptAvailableItems = (nodes) => {
+  return nodes.map((node) => {
+    if (node.type) {
+      return adaptItem(node);
+    } else {
+      return { ...node, items: adaptAvailableItems(node.items) };
+    }
+  });
+};
+
+export const Session = () => {
+  const {
+    loadSession,
+    setSession,
+    sessionLoaded,
+    gameId,
+    sessionId,
+    initialItems,
+    availableItems,
+    setCurrentItems,
+    setIsMaster,
+    boardConfig,
+    initialMessages,
+  } = useSession();
+
+  const gameLoadingRef = React.useRef(false);
+
+  const { t } = useTranslation();
+
+  useAsyncEffect(
+    async (isMounted) => {
+      if (!sessionLoaded && !gameLoadingRef.current) {
+        gameLoadingRef.current = true;
+        const sessionData = await loadSession();
+
+        if (!isMounted) return;
+        setSession(sessionData, true);
+      }
+    },
+    [sessionLoaded]
+  );
+
+  const availableItemLibrary = React.useMemo(() => {
+    let itemList = availableItems;
+    if (itemList.length && itemList[0].groupId) {
+      itemList = migrateAvailableItemList(itemList);
+    }
+    return adaptAvailableItems(itemList);
+  }, [availableItems]);
+
+  const itemLibraries = [
+    {
+      name: "Standard",
+      key: "standard",
+      items: itemLibrary,
+    },
+    {
+      name: "Box",
+      key: "box",
+      items: availableItemLibrary,
+    },
+  ];
+
+  const mediaLibraries = React.useMemo(() => {
+    if (gameId) {
+      return [
+        {
+          id: "session",
+          name: t("Session"),
+          boxId: "session",
+          resourceId: sessionId,
+        },
+        { id: "game", name: t("Game"), boxId: "game", resourceId: gameId },
+      ];
+    }
+    return [
+      {
+        id: "session",
+        name: t("Session"),
+        boxId: "session",
+        resourceId: sessionId,
+      },
+    ];
+  }, [gameId, sessionId, t]);
+
+  if (!sessionLoaded) {
+    return <Waiter message={t("Session loading...")} />;
+  }
+
+  return (
+    <BoardView
+      room={`room_${sessionId}`}
+      initialBoardConfig={boardConfig}
+      session={sessionId}
+      itemTemplates={itemTemplates}
+      mediaLibraries={mediaLibraries}
+      actions={actionMap}
+      itemLibraries={itemLibraries}
+      ItemFormComponent={ItemForm}
+      initialItems={initialItems}
+      initialMessages={initialMessages}
+      onItemsChange={setCurrentItems}
+      onMasterChange={setIsMaster}
+    />
+  );
+};
+
+const ConnectedSessionView = ({ sessionId, fromGame }) => {
+  return (
+    <SessionProvider sessionId={sessionId} fromGameId={fromGame}>
+      <Session />
+    </SessionProvider>
+  );
+};
+
+export default ConnectedSessionView;

+ 0 - 0
src/views/SessionView.jsx → src/views/SessionView_old.jsx


+ 42 - 15
src/views/utils.js

@@ -11,20 +11,47 @@ export const search = (term, string) => {
   return strings.some((s) => cleanWord(s).includes(cleanedTerm));
 };
 
-export const media2Url = (value, apiBase) => {
-  if (value && typeof value === "object") {
-    switch (value.type) {
-      case "local":
-        return `${apiBase}/${value.content}`;
-      case "external":
-        return value.content;
-      case "dataUrl":
-        return value.content;
-      case "empty":
-        return null;
-      default:
-      // do nothing
-    }
+export const hasClass = (element, className) =>
+  element.classList && element.classList.contains(className);
+
+export const insideClass = (element, className) => {
+  if (hasClass(element, className)) {
+    return element;
+  }
+  if (!element.parentNode) {
+    return false;
+  }
+  return insideClass(element.parentNode, className);
+};
+
+/**
+ * Shuffles array in place.
+ * @param {Array} a items An array containing the items.
+ */
+export const shuffle = (a) => {
+  // eslint-disable-next-line no-plusplus
+  for (let i = a.length - 1; i > 0; i--) {
+    const j = Math.floor(Math.random() * (i + 1));
+    // eslint-disable-next-line no-param-reassign
+    [a[i], a[j]] = [a[j], a[i]];
   }
-  return value;
+  return a;
+};
+
+export const randInt = (min, max) =>
+  Math.floor(Math.random() * (max - min + 1)) + min;
+
+const isPointInsideRect = (point, rect) =>
+  point.x > rect.left &&
+  point.x < rect.left + rect.width &&
+  point.y > rect.top &&
+  point.y < rect.top + rect.height;
+
+export const isItemInsideElement = (itemElement, otherElem) => {
+  const rect = otherElem.getBoundingClientRect();
+  const fourElem = Array.from(itemElement.querySelectorAll(".corner"));
+  return fourElem.every((corner) => {
+    const { top: y, left: x } = corner.getBoundingClientRect();
+    return isPointInsideRect({ x, y }, rect);
+  });
 };

+ 2 - 4
src/views/webconf/StreamList.jsx

@@ -4,8 +4,6 @@ import styled from "styled-components";
 import useOpenVidu from "./useOpenVidu";
 import useLocalStorage from "../../hooks/useLocalStorage";
 
-import useUsers from "../../components/users/useUsers";
-
 import LocalStream from "./LocalStream";
 import RemoteStream from "./RemoteStream";
 
@@ -65,7 +63,7 @@ const StyledWebConference = styled.div`
   }
 `;
 
-const WebConferenceContent = () => {
+const WebConferenceContent = ({ users }) => {
   const { remoteStreams = [], localStream } = useOpenVidu();
 
   const [showLocalVideo, setShowLocalVideo] = useLocalStorage(
@@ -85,7 +83,7 @@ const WebConferenceContent = () => {
     setShowLocalAudio((prev) => !prev);
   }, [setShowLocalAudio]);
 
-  const { localUsers: users } = useUsers();
+  // const { localUsers: users } = useUsers();
 
   const streamMap = React.useMemo(
     () =>

+ 4 - 4
src/views/webconf/WebConference.jsx

@@ -1,13 +1,13 @@
 import React from "react";
 
-import useUsers from "../../components/users/useUsers";
+// import useUsers from "../../components/users/useUsers";
 import { getConfToken } from "../../utils/api";
 
 import { OpenViduProvider } from "./useOpenVidu";
 import StreamList from "./StreamList";
 
-const WebConference = ({ room }) => {
-  const { currentUser } = useUsers();
+const WebConference = ({ room, currentUser, users }) => {
+  // const { currentUser } = useUsers();
 
   const getUserData = React.useCallback(
     () => JSON.stringify({ uid: currentUser.uid }),
@@ -21,7 +21,7 @@ const WebConference = ({ room }) => {
       getUserData={getUserData}
       getToken={getConfToken}
     >
-      <StreamList />
+      <StreamList users={users} />
     </OpenViduProvider>
   );
 };

+ 10 - 1
src/views/webconf/WebConferenceButton.jsx

@@ -1,6 +1,8 @@
 import React from "react";
 import { useTranslation } from "react-i18next";
 
+import { useUsers } from "react-sync-board";
+
 import Touch from "../../components/ui/Touch";
 import useLocalStorage from "../../hooks/useLocalStorage";
 
@@ -8,6 +10,7 @@ import WebConference from "./WebConference";
 
 export const WeConferenceButton = ({ room }) => {
   const { t } = useTranslation();
+  const { currentUser, localUsers } = useUsers();
   const [webConference, setWebConference] = useLocalStorage(
     "enableWebconference",
     false
@@ -22,7 +25,13 @@ export const WeConferenceButton = ({ room }) => {
         icon="https://icongr.am/material/message-video.svg?size=24&color=f9fbfa"
         active={webConference}
       />
-      {webConference && <WebConference room={room} />}
+      {webConference && (
+        <WebConference
+          room={room}
+          currentUser={currentUser}
+          users={localUsers}
+        />
+      )}
     </>
   );
 };