|
|
@@ -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));
|
|
|
+ }}>[✕]</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/> );
|