Browse Source

Initial commit

Joeri Exelmans 2 years ago
commit
1f0a20629e
10 changed files with 4513 additions and 0 deletions
  1. 7 0
      .gitignore
  2. 45 0
      dist/index.html
  3. 40 0
      package.json
  4. 3877 0
      pnpm-lock.yaml
  5. 7 0
      shell.nix
  6. 61 0
      src/frontend/api.ts
  7. 66 0
      src/frontend/index.css
  8. 357 0
      src/frontend/index.tsx
  9. 15 0
      tsconfig.json
  10. 38 0
      webpack.config.js

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+node_modules/
+
+# coverage analysis output
+.nyc_output/
+
+# this file will be generated when running webpack:
+dist/*

+ 45 - 0
dist/index.html

@@ -0,0 +1,45 @@
+<!doctype html>
+<html>
+  <head>
+    <meta charset="UTF-8"/>
+<link rel="icon" href="/themes/custom/dropsolid-theme-flex-8/favicon.ico" type="image/vnd.microsoft.icon" />
+
+          <link rel="apple-touch-icon" sizes="57x57" href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/apple-touch-icon-57x57.png">
+      <link rel="apple-touch-icon" sizes="60x60" href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/apple-touch-icon-60x60.png">
+      <link rel="apple-touch-icon" sizes="72x72" href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/apple-touch-icon-72x72.png">
+      <link rel="apple-touch-icon" sizes="76x76" href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/apple-touch-icon-76x76.png">
+      <link rel="apple-touch-icon" sizes="114x114" href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/apple-touch-icon-114x114.png">
+      <link rel="apple-touch-icon" sizes="120x120" href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/apple-touch-icon-120x120.png">
+      <link rel="apple-touch-icon" sizes="144x144" href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/apple-touch-icon-144x144.png">
+      <link rel="apple-touch-icon" sizes="152x152" href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/apple-touch-icon-152x152.png">
+      <link rel="apple-touch-icon" sizes="180x180" href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/apple-touch-icon-180x180.png">
+
+      <link rel='mask-icon' color='#0096A9' href='/themes/custom/dropsolid-theme-flex-8/favicons/generated/safari-pinned-tab.svg'>
+
+      <link rel="icon" type="image/png" sizes="36x36"  href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/android-chrome-36x36.png">
+      <link rel="icon" type="image/png" sizes="48x48"  href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/android-chrome-48x48.png">
+      <link rel="icon" type="image/png" sizes="72x72"  href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/android-chrome-72x72.png">
+      <link rel="icon" type="image/png" sizes="96x96"  href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/android-chrome-96x96.png">
+      <link rel="icon" type="image/png" sizes="144x144"  href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/android-chrome-144x144.png">
+      <link rel="icon" type="image/png" sizes="192x192"  href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/android-chrome-192x192.png">
+      <link rel="icon" type="image/png" sizes="256x256"  href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/android-chrome-256x256.png">
+
+      <link rel="icon" type="image/png" sizes="32x32" href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/favicon-32x32.png">
+      <link rel="icon" type="image/png" sizes="16x16" href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/favicon-16x16.png">
+      <link rel="icon" href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/favicon.ico">
+
+      <link rel="manifest" href="/themes/custom/dropsolid-theme-flex-8/favicons/generated/manifest.json">
+      <meta name="msapplication-TileColor" content="#0096A9">
+      <meta name="msapplication-TileImage" content="/themes/custom/dropsolid-theme-flex-8/favicons/generated/ms-icon-70x70.png">
+      <meta name="msapplication-TileImage" content="/themes/custom/dropsolid-theme-flex-8/favicons/generated/ms-icon-144x144.png">
+      <meta name="msapplication-TileImage" content="/themes/custom/dropsolid-theme-flex-8/favicons/generated/ms-icon-150x150.png">
+      <meta name="msapplication-TileImage" content="/themes/custom/dropsolid-theme-flex-8/favicons/generated/ms-icon-310x310.png">
+      <meta name="msapplication-TileImage" content="/themes/custom/dropsolid-theme-flex-8/favicons/generated/ms-icon-310x150.png">
+
+    <title>CDF Mod Tool</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="./bundle.js"></script>
+  </body>
+</html>

+ 40 - 0
package.json

@@ -0,0 +1,40 @@
+{
+	"devDependencies": {
+		"@types/mocha": "^10.0.1",
+		"@types/node": "^18.11.18",
+		"@types/react": "^18.0.26",
+		"@types/react-dom": "^18.0.10",
+		"fork-ts-checker-webpack-plugin": "^7.2.14",
+		"mocha": "^10.2.0",
+		"nyc": "^15.1.0",
+		"ts-loader": "^9.4.2",
+		"url-loader": "^4.1.1",
+		"webpack": "^5.75.0",
+		"webpack-cli": "^4.10.0",
+		"webpack-dev-server": "^4.11.1"
+	},
+	"dependencies": {
+		"@tabler/icons": "^1.119.0",
+		"@dnd-kit/core": "^6.0.7",
+		"@dnd-kit/sortable": "^7.0.2",
+		"@dnd-kit/utilities": "^3.2.1",
+		"@emotion/react": "^11.10.5",
+		"@mantine/core": "^5.10.0",
+		"@mantine/hooks": "^5.10.0",
+		"css-loader": "^6.7.3",
+		"react": "^18.2.0",
+		"react-beautiful-dnd": "^13.1.1",
+		"react-dom": "^18.2.0",
+		"react-icons": "^4.7.1",
+		"ts-node": "^10.9.1",
+		"typescript": "^4.9.4"
+	},
+	"scripts": {
+		"test": "mocha --require ts-node/register",
+		"test-all": "mocha --require ts-node/register './src/**/*.test.ts'",
+		"test-with-coverage": "nyc --reporter=text mocha --require ts-node/register",
+		"test-all-with-coverage": "nyc --reporter=text mocha --require ts-node/register './src/**/*.test.ts'",
+		"webpack": "webpack --mode=production",
+		"dev-server": "webpack serve --mode=development --devtool=eval-source-map"
+	}
+}

File diff suppressed because it is too large
+ 3877 - 0
pnpm-lock.yaml


+ 7 - 0
shell.nix

@@ -0,0 +1,7 @@
+{ pkgs ? import <nixpkgs> {} }:
+  pkgs.mkShell {
+    # nativeBuildInputs is usually what you want -- tools you need to run
+    nativeBuildInputs = with pkgs; [
+      nodePackages.pnpm
+    ];
+}

+ 61 - 0
src/frontend/api.ts

@@ -0,0 +1,61 @@
+interface Participant{
+  displayed: boolean; // currently visible on videowall?
+  uid: string; // ubuntu4@uantwerpen.be
+}
+type ListParticipantsResponse = Array<Participant>;
+
+export function login({server, userEmail, password}) {
+  return fetch(server + '/api/signin', {
+    method: "POST",
+    body: JSON.stringify({email: userEmail, password: password}),
+    headers: {
+      'Content-Type': 'application/json',
+    },
+  })
+  .then(response => {
+    if (response.status === 200) {
+      return response.json();
+    }
+    else {
+      throw new Error(`${response.status}: ${response.statusText}`);
+    }
+  })
+  .then(jsonParsed => {
+    const {accessToken} = jsonParsed;
+    return accessToken;
+  });
+}
+
+export function getScreenShares({server, userEmail, accessToken}) {
+  return fetch(server + '/api/monitoring/' + encodeURIComponent(userEmail) + '/participant', {
+    method: "GET",
+    headers: {
+      'Authorization': 'Bearer ' + accessToken,
+    },
+  })
+  .then(response => {
+    if (response.status === 200) {
+      return response.json();
+    } else {
+      throw new Error(`${response.status}: ${response.statusText}`);
+    }
+  })
+  .then(jsonParsed => {
+    const {data}: {data: ListParticipantsResponse} = jsonParsed;
+    return {
+      videowall: data.filter(p => p.displayed).map(p => p.uid),
+      hidden: data.filter(p => !p.displayed).map(p => p.uid),
+    };
+  });
+}
+
+export function setScreenShares({server, userEmail, accessToken, visibleShares}) {
+  return fetch(server + '/api/monitoring/' + encodeURIComponent(userEmail) + '/participant/display', {
+    method: "PUT",
+    headers: {
+      'Authorization': 'Bearer ' + accessToken,
+      'Content-Type': 'application/json',
+    },
+    body: JSON.stringify(visibleShares),
+  });
+}

+ 66 - 0
src/frontend/index.css

@@ -0,0 +1,66 @@
+html,body, #root {
+    -webkit-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+    height: 100%;
+}
+
+#root {
+    height: 100vh;
+    font-size: 14px;
+}
+
+.canvas {
+  background-color: #eee;
+  width: 100%;
+  height: 350px;
+  vertical-align:top;
+}
+
+@media (prefers-color-scheme: dark) {
+  .canvas {
+    background-color: #888;
+  }
+}
+
+.mantine-List-item {
+    font-size: 14px;
+}
+
+.mantine-Text-root + .mantine-Text-root {
+    margin-bottom: 10px;
+}
+
+.mantine-Blockquote-root {
+    font-size: 14px;
+    background-color: #eee;
+    padding: 4px;
+}
+
+[action] {
+    border-left: 4px solid darkorange;
+    margin-bottom: 2px;
+    font-weight: bolder;
+}
+
+[result] {
+    border-left: 4px solid royalblue;
+    margin-left: 4px;
+    margin-bottom: 2px;
+    background-color: #f8f8f8;
+}
+
+@media (prefers-color-scheme: dark) {
+    .mantine-Blockquote-root {
+        background-color: #444;
+    }
+
+    [result] {
+        background-color: #282828;
+    }
+}
+
+.mantine-ScrollArea-scrollbar {
+    padding: 0px;
+}

+ 357 - 0
src/frontend/index.tsx

@@ -0,0 +1,357 @@
+import './index.css';
+
+import * as React from 'react';
+import {createRoot} from 'react-dom/client';
+
+import {
+  TextInput,
+  Text,
+  PasswordInput,
+  Button,
+  Group,
+  Space,
+  Tabs,
+  Stack,
+  CloseButton,
+  Card,
+  Title,
+  Center,
+  Accordion,
+  SimpleGrid,
+  Anchor,
+  ActionIcon,
+  LoadingOverlay,
+  NumberInput,
+  List,
+  MantineProvider,
+} from '@mantine/core';
+import {useColorScheme} from '@mantine/hooks';
+
+import {IconAlertCircle} from "@tabler/icons";
+
+import {
+  DragDropContext,
+  Droppable,
+  Draggable,
+} from "react-beautiful-dnd";
+
+import * as api from "./api";
+
+const container = document.getElementById('root');
+const root = createRoot(container!);
+
+interface ConnectionProps {
+  server: string;
+  user: string;
+  pass: string;
+}
+
+interface LoginProps {
+  onLogin: (c: ConnectionProps) => void;
+}
+
+function LoginForm(props: LoginProps) {
+  const [server, setServer] = React.useState<string>("https://msdl-testing.uantwerpen.be");
+  const [user, setUser] = React.useState<string>("joeri+1@uantwerpen.be");
+  const [pass, setPass] = React.useState<string>("drovio123");
+
+  return <form>
+    <SimpleGrid cols={2} style={{maxWidth: 1000}}>
+    <div>
+      <Title order={5}>Credentials</Title>
+      <Space h="md"/>
+      <TextInput
+        label="Drovio User (of CDF room)"
+        value={user}
+        onChange={e => setUser(e.currentTarget.value)}
+        error={/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(user) ? "Invalid email" : undefined}
+      />
+      <Space h="md"/>
+      <PasswordInput
+        label="Drovio Password"
+        value={pass}
+        onChange={e => setPass(e.currentTarget.value)}
+      />
+    </div>
+    <div>
+      <Title order={5}>Advanced</Title>
+      <Space h="md"/>
+      <TextInput
+        label="Server"
+        value={server}
+        onChange={e => setServer(e.currentTarget.value)}
+      />
+    </div>
+    </SimpleGrid>
+    <Space h="xl"/>
+    <Button disabled={user.length === 0} onClick={() => props.onLogin({server, user, pass})}>Login</Button>
+  </form>;
+}
+
+type ConnectingState = {
+  state: "connecting";
+};
+
+type ErrorState = {
+  state: "error";
+  msg: string;
+};
+
+type SuccessState = {
+  state: "success";
+  accessToken: string;
+  updating: boolean; // did we just send an uncompleted request to the server to change the layout?
+};
+
+type ConnectionState =
+  | ConnectingState
+  | ErrorState
+  | SuccessState;
+
+interface ItemProps {
+  children?: React.ReactNode,
+  id: string,
+  style?: object,
+  dragging?: boolean,
+}
+
+function Item(props: ItemProps) {
+  const style: React.CSSProperties = {
+    cursor: props.dragging ? 'grabbing' : 'grab',
+    // height: '100%',
+    boxShadow: props.dragging ? "0px 0px 30px rgba(0,0,0,0.7)" : "0px 0px 5px rgba(0,0,0,0.5)",
+    backgroundColor: "white",
+    userSelect: 'none',
+    margin: 20,
+    minWidth: 200,
+    height: 120,
+
+    ...props.style,
+  };
+
+  return <Center {...props} style={style}>{props.id}</Center>
+}
+
+type LayoutType = "row" | "grid";
+
+type Items = {videowall: Array<string>, hidden: Array<string>}
+
+interface VideoWallControlProps {
+  items: Items;
+  setItems: (callback: (Items) => Items) => void;
+  freeze: boolean;
+
+  onChange: (Items) => void;
+}
+
+function VideoWallControl(props: VideoWallControlProps) {
+  function onDragEnd(result) {
+    if (!result.destination) {
+      return;
+    }
+    props.setItems(items => {
+      const newItems = {
+        videowall: items.videowall.slice(),
+        hidden: items.hidden.slice(),
+      }
+      const removed = newItems[result.source.droppableId].splice(result.source.index, 1);
+      newItems[result.destination.droppableId].splice(result.destination.index, 0, ...removed);
+
+      props.onChange(newItems);
+      return newItems;
+    });
+  }
+
+  return <>
+    <DragDropContext onDragEnd={onDragEnd}>
+      <div style={{position:"relative"}}>
+      <LoadingOverlay visible={false} overlayBlur={2}/>
+      <Title order={5}>Video Wall</Title>
+      <Space h="md"/>
+      <Droppable droppableId="videowall" direction="horizontal">
+        {(provided, snapshot) => (
+          <div ref={provided.innerRef} {...provided.droppableProps} style={{height: 160, backgroundColor: '#0096a9', position:"relative"}}>
+            <Group spacing={0} style={{height: '100%'}}>
+              {props.items.videowall.map((item, index) => (
+                <Draggable key={item} draggableId={item} index={index} style={{height: '100%'}}>
+                  {(provided, snapshot) =>
+                    <div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
+                      <Item id={item} dragging={snapshot.isDragging}/>
+                    </div>}
+                </Draggable>
+              ))}
+              {props.items.videowall.length === 0 ? <Center style={{width:"100%",height:"100%"}}><Text color="white">No active screenshares.</Text></Center> : <></>}
+              <div/>
+            </Group>
+            {provided.placeholder}
+          </div>
+        )}
+      </Droppable>
+      <Space h="xl"/>
+      <Title order={5}>Hidden Screenshares</Title>
+      <Space h="md"/>
+      <Droppable droppableId="hidden" direction="horizontal">
+        {(provided, snapshot) => (
+          <div ref={provided.innerRef} {...provided.droppableProps} style={{height: 160, backgroundColor: '#555059', position:"relative"}}>
+            <Group spacing={0} style={{height: '100%'}}>
+              {props.items.hidden.map((item, index) => (
+                <Draggable key={item} draggableId={item} index={index}>
+                  {(provided, snapshot) =>
+                    <div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
+                      <Item id={item} dragging={snapshot.isDragging}/>
+                    </div>}
+                </Draggable>
+              ))}
+              {props.items.hidden.length === 0 ? <Center style={{width:"100%",height:"100%"}}><Text color="white">No hidden screenshares.</Text></Center> : <></>}
+              <div/>
+            </Group>
+            {provided.placeholder}
+          </div>
+        )}
+      </Droppable>
+      </div>
+    </DragDropContext>
+  </>;
+}
+
+
+function ConnectionTab(props: ConnectionProps) {
+  const [state, setState] = React.useState<ConnectionState>({state: "connecting"});
+  const [items, setItems] = React.useState<Items>({videowall: [], hidden: []});
+
+  function refreshScreenShares(accessToken) {
+    return api.getScreenShares({
+      server: props.server,
+      userEmail: props.user,
+      accessToken,
+    });
+  }
+
+  function attemptConnect() {
+    api.login({
+      server: props.server,
+      userEmail: props.user,
+      password: props.pass,
+    })
+    .then(accessToken => {
+      return refreshScreenShares(accessToken)
+      .then(setItems)
+      .then(() => {
+        setState({state: 'success', accessToken, updating: false});
+      });
+    })
+    .catch(err => {
+      setState({state: 'error', msg: err.message});
+    });    
+  }
+
+  React.useEffect(() => {
+    attemptConnect();
+  }, []);
+
+  const [pollInterval, setPollInterval] = React.useState<number>(1000);
+
+  React.useEffect(() => {
+    let canceled = false;
+    if (state.state === "success" && !state.updating) {
+      const accessToken = state.accessToken;
+      setTimeout(() => {
+        refreshScreenShares(accessToken)
+        .then(items => {
+          console.log("setItems due to POLL");
+          if (!canceled) setItems(items);
+        });
+      }, pollInterval);
+    }
+    return function cleanup() {
+      canceled = true;
+    }
+  }, [state, items, pollInterval]);
+
+  switch (state.state) {
+    case "connecting":
+      return <Text>Connecting...</Text>;
+    case "success":
+      return <>
+        <VideoWallControl {...{items, setItems}} freeze={state.updating} onChange={items => {
+          // setItems(items);
+          console.log("SEND UPDATE");
+          setState(oldState => ({...oldState, updating: true}));
+          api.setScreenShares({
+            server: props.server,
+            userEmail: props.user,
+            accessToken: state.accessToken,
+            visibleShares: items.videowall,
+          })
+          .then(result => {
+            console.log("UPDATE RESULT:", result);
+            setState(oldState => ({...oldState, updating: false}));
+          })
+        }}/>
+        <Space h="xl"/>
+        <Title order={5}>Advanced</Title>
+        <Space h="md"/>
+        <NumberInput label="Polling interval (ms)" value={pollInterval} onChange={setPollInterval} step={100}/>
+      </>;
+    case "error":
+      return <>
+        <List style={{color:"red"}} icon={<IconAlertCircle/>}>
+          <List.Item>Error {state.msg}</List.Item>
+        </List>
+        <Space h="xl"/>
+        <Button onClick={attemptConnect}>Retry</Button>
+      </>;
+  }
+}
+
+function App() {
+  const [connections, setConnections] = React.useState<Array<ConnectionProps>>([]);
+  const [currentTab, setCurrentTab] = React.useState<string|null>("login");
+
+  // Workaround: after closing a tab, the Tabs.onTabChange callback occurs, with the closed tab as parameter.
+  if (currentTab?.startsWith("conn-") && !connections.some(c => "conn-" + c.user === currentTab)) {
+    setCurrentTab("login");
+  }
+
+  const preferredColorScheme = useColorScheme();
+
+  return (
+    <MantineProvider theme={{colorScheme: preferredColorScheme}} withGlobalStyles withNormalizeCSS>
+      <Stack style={{userSelect: 'none'}}>
+        {/*<Group position="left"></Group>*/}
+        <Group position="left" style={{padding:10}}>
+        <Title order={3}>CDF Mod Tool</Title>
+        <img src="https://www.flandersmake.be/themes/custom/dropsolid-theme-flex-8/logo.svg" style={{width:100, position: "absolute", right: 10, top:10}}/>
+        </Group>
+        <Tabs value={currentTab} onTabChange={setCurrentTab}>
+          <Tabs.List>
+            <Tabs.Tab value="login">Login</Tabs.Tab>
+            {connections.map(c =>
+              <Tabs.Tab value={"conn-" + c.user} key={c.user}>
+                <Group>
+                  {c.user}
+                  <Anchor onClick={() => {
+                    setConnections(prevConnections => prevConnections.filter(conn => conn.user !== c.user));
+                  }}>[&#x2715;]</Anchor>
+                </Group>
+              </Tabs.Tab>)}
+          </Tabs.List>
+          <Tabs.Panel value="login" style={{padding:20}}>
+            <LoginForm onLogin={(c: ConnectionProps) => {
+              console.log("login");
+                setConnections(prevConnections => prevConnections.filter(conn => conn.user !== c.user).concat(c));
+                setCurrentTab("conn-" + c.user);
+            }}/>
+          </Tabs.Panel>
+          {connections.map(c =>
+            <Tabs.Panel value={"conn-" + c.user} style={{padding:20}} key={c.user}>
+              <ConnectionTab server={c.server} user={c.user} pass={c.pass}/>
+            </Tabs.Panel>)}
+        </Tabs>
+      </Stack>
+    </MantineProvider>
+  );
+}
+
+
+root.render( <App/> );

+ 15 - 0
tsconfig.json

@@ -0,0 +1,15 @@
+{
+  "compilerOptions": {
+    "types": ["mocha", "node", "react"],
+    "target": "es6",
+    "jsx": "react",
+    "sourceMap": true,
+    "noImplicitThis": true,
+    "strictBindCallApply": true,
+    "strictNullChecks": true,
+    "strictFunctionTypes": true,
+    "strictPropertyInitialization": true,
+    "alwaysStrict": true,
+    "moduleResolution": "nodenext",
+  }
+}

+ 38 - 0
webpack.config.js

@@ -0,0 +1,38 @@
+const path = require('path');
+const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
+
+
+module.exports = {
+  entry: path.resolve(__dirname, 'src', 'frontend', 'index.tsx'),
+  module: {
+    rules: [
+      {
+        test: /\.tsx?$/,
+        use: 'ts-loader',
+        exclude: /node_modules/,
+      },
+      {
+        test: /\.css$/i,
+        use: ["css-loader"],
+      },
+      {
+        test: /\.svg$/,
+        use: 'url-loader'
+      },
+    ],
+  },
+  resolve: {
+    extensions: ['.tsx', '.ts', '.js'],
+  },
+  output: {
+    filename: 'bundle.js',
+    path: path.resolve(__dirname, 'dist'),
+  },
+  devServer: {
+    static: path.resolve(__dirname, 'dist'),
+    port: 9000,
+  },
+  plugins: [
+    new ForkTsCheckerWebpackPlugin()
+  ]
+};