Browse Source

UI changes

* Remove side bar, add "about" drawer
* Move sidebar elements to title bar
* Make connection status a spinner with a tooltip
* Move user list to popover
* Change username to be inline editable element
* Make dark mode toggle an icon in the titlebar
* Make document title copy link on click
* Add confirmation box for loading Rustpad code
Jonathan Goren 3 years ago
parent
commit
06344f9759
3 changed files with 411 additions and 228 deletions
  1. 293 122
      src/App.tsx
  2. 19 17
      src/ConnectionStatus.tsx
  3. 99 89
      src/User.tsx

+ 293 - 122
src/App.tsx

@@ -1,39 +1,42 @@
-import { useEffect, useRef, useState } from "react";
 import {
+  AlertDialog,
+  AlertDialogBody,
+  AlertDialogContent,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogOverlay,
   Box,
-  Button,
-  Container,
-  Flex,
+  Button, Container, Drawer, DrawerBody,
+  DrawerCloseButton, DrawerContent, DrawerHeader, DrawerOverlay, Flex,
   Heading,
   HStack,
-  Icon,
-  Input,
-  InputGroup,
-  InputRightElement,
-  Link,
-  Select,
-  Stack,
-  Switch,
-  Text,
-  useToast,
+  Icon, IconButton, Input, InputGroup, InputLeftElement, InputRightElement, Link,
+  Popover, PopoverCloseButton,
+  PopoverContent, PopoverTrigger,
+  Select, Spacer, Text, Tooltip, useDisclosure, useToast
 } from "@chakra-ui/react";
+import Editor from "@monaco-editor/react";
+import { editor } from "monaco-editor/esm/vs/editor/editor.api";
+import { useEffect, useRef, useState } from "react";
+import { HiUserGroup, HiUser } from "react-icons/hi";
+import { FaMoon, FaSun } from "react-icons/fa";
 import {
   VscChevronRight,
   VscFolderOpened,
   VscGist,
-  VscRepoPull,
+  VscLink, VscRepoPull
 } from "react-icons/vsc";
 import useStorage from "use-local-storage-state";
-import Editor from "@monaco-editor/react";
-import { editor } from "monaco-editor/esm/vs/editor/editor.api";
 import rustpadRaw from "../rustpad-server/src/rustpad.rs?raw";
-import languages from "./languages.json";
 import animals from "./animals.json";
-import Rustpad, { UserInfo } from "./rustpad";
-import useHash from "./useHash";
 import ConnectionStatus from "./ConnectionStatus";
 import Footer from "./Footer";
-import User from "./User";
+import languages from "./languages.json";
+import Rustpad, { UserInfo } from "./rustpad";
+import useHash from "./useHash";
+import UserList from "./User";
+import React, { Component } from 'react';
+
 
 function getWsUri(id: string) {
   return (
@@ -125,6 +128,8 @@ function App() {
     }
   }
 
+
+
   async function handleCopy() {
     await navigator.clipboard.writeText(`${window.location.origin}/#${id}`);
     toast({
@@ -136,7 +141,13 @@ function App() {
     });
   }
 
-  function handleLoadSample() {
+
+  function handleDarkMode() {
+    setDarkMode(!darkMode);
+  }
+
+
+  const handleLoadSample = () => {
     if (editor?.getModel()) {
       const model = editor.getModel()!;
       model.pushEditOperations(
@@ -156,9 +167,9 @@ function App() {
     }
   }
 
-  function handleDarkMode() {
-    setDarkMode(!darkMode);
-  }
+
+  const aboutDrawer = useDisclosure()
+
 
   return (
     <Flex
@@ -168,59 +179,201 @@ function App() {
       bgColor={darkMode ? "#1e1e1e" : "white"}
       color={darkMode ? "#cbcaca" : "inherit"}
     >
+
       <Box
-        flexShrink={0}
+        flexShrink={2}
         bgColor={darkMode ? "#333333" : "#e8e8e8"}
         color={darkMode ? "#cccccc" : "#383838"}
         textAlign="center"
         fontSize="sm"
-        py={0.5}
       >
-        Rustpad
-      </Box>
+        <Flex spacing={0} px={2} alignItems="center">
+          <ConnectionStatus darkMode={darkMode} connection={connection} />
+
+          <Button
+            h="1.4rem"
+            fontWeight="normal"
+            rounded="none"
+            _hover={{ bg: darkMode ? "#575759" : "gray.200" }}
+            bgColor={darkMode ? "#333333" : "gray.200"}
+            px={2}
+            onClick={aboutDrawer.onOpen}
+          >
+            Rustpad
+          </Button>
+
+          <Tooltip label="Syntax highlighting">
+            <Select
+              maxWidth="10em"
+              size="xs"
+              bgColor={darkMode ? "#3c3c3c" : "white"}
+              _hover={{ bgColor: darkMode ? "#575757" : "dddddd" }}
+              borderColor={darkMode ? "#3c3c3c" : "white"}
+              value={language}
+              onChange={(event) => handleChangeLanguage(event.target.value)}
+
+            >
+              {languages.map((lang) => (
+                <option key={lang} value={lang} style={{ color: "black" }}>
+                  {lang}
+                </option>
+              ))}
+            </Select>
+          </Tooltip>
+          <Spacer />
+          <Users
+            users={users}
+            darkMode={darkMode}
+            me={{ name, hue }}
+            setName={setName}
+            setHue={setHue}
+            id={id}
+            handleCopy={handleCopy}
+          />
+          <DarkModeToggle darkMode={darkMode} toggle={handleDarkMode} />
+        </Flex>
+      </Box >
       <Flex flex="1 0" minH={0}>
-        <Container
-          w="xs"
-          bgColor={darkMode ? "#252526" : "#f3f3f3"}
-          overflowY="auto"
-          maxW="full"
-          lineHeight={1.4}
-          py={4}
+
+        <Drawer
+          isOpen={aboutDrawer.isOpen}
+          onClose={aboutDrawer.onClose}
+          placement="left"
         >
-          <ConnectionStatus darkMode={darkMode} connection={connection} />
+          <AboutDrawer loadSample={handleLoadSample} darkMode={darkMode} />
+        </Drawer>
+        <Flex flex={1} minW={0} h="100%" direction="column" overflow="hidden">
+          <HStack
+            h={6}
+            spacing={1}
+            color="#888888"
+            fontWeight="medium"
+            fontSize="13px"
+            px={3.5}
+            flexShrink={0}
+          >
+            <Icon as={VscFolderOpened} fontSize="md" color="blue.500" />
+            <Text>rustpad</Text>
+            <Icon as={VscChevronRight} fontSize="md" />
+            <Link onClick={handleCopy} color="#bbbbbb" _hover={{ color: "#ffffff" }} >
+              <HStack spacing={1} >
+                <Icon as={VscGist} fontSize="md" color="purple.500" />
+                <Text>{id}</Text>
+                <Icon as={VscLink} fontSize="md" color="grey.500" />
+              </HStack>
+            </Link>
+          </HStack>
+          <Box flex={1} minH={0}>
+            <Editor
+              theme={darkMode ? "vs-dark" : "vs"}
+              language={language}
+              options={{
+                automaticLayout: true,
+                fontSize: 13,
+              }}
+              onMount={(editor) => setEditor(editor)}
+            />
+          </Box>
+        </Flex>
+      </Flex>
+      <Footer />
+    </Flex >
+  );
+}
+
+export default App;
+
+type DarkModeProps = {
+  darkMode: boolean;
+  toggle: () => unknown;
+}
+
+function DarkModeToggle({ darkMode, toggle }: DarkModeProps) {
+  return (
+    <Tooltip label="Toggle dark mode">
+      <IconButton
+        aria-label="Toggle dark mode"
+        size="xs"
+        rounded="none"
+        onClick={toggle}
+        bgColor={darkMode ? "#333333" : "#e8e8e8"}
+        _hover={{ bg: darkMode ? "#575757" : "gray.200" }}
+        icon={darkMode ? <FaSun /> : <FaMoon />}
+      />
+    </Tooltip>
+  )
+}
+
+type UsersProps = {
+  users: Record<number, UserInfo>;
+  darkMode: boolean;
+  me: UserInfo;
+  setName: (name: string) => unknown;
+  setHue: (hue: number) => unknown;
+  id: string;
+  handleCopy: () => unknown;
+};
+
 
-          <Flex justifyContent="space-between" mt={4} mb={1.5} w="full">
-            <Heading size="sm">Dark Mode</Heading>
-            <Switch isChecked={darkMode} onChange={handleDarkMode} />
-          </Flex>
+function Users({ users, darkMode, me, setName, setHue, id, handleCopy }: UsersProps) {
+  const [usersIsOpen, setUsersIsOpen] = useState(false)
+  const open = () => setUsersIsOpen(!usersIsOpen)
+  const close = () => setUsersIsOpen(false)
+  const userCount = () => Object.entries(users).length
 
+  return (
+    <Popover
+      isOpen={usersIsOpen}
+      onClose={close}
+      placement='bottom'
+      closeOnBlur={false}
+    >
+      <PopoverTrigger>
+        <Button size="xs"
+          onClick={open}
+          rounded="none"
+          bgColor={
+            usersIsOpen ?
+              (darkMode ? "#444444" : "#dddddd") :
+              (darkMode ? "#333333" : "#e8e8e8")
+          }
+          _hover={{}} >
+          <Icon as={userCount() > 0 ? HiUserGroup : HiUser} />
+          <Text px={1}>{userCount() > 0 ?
+            (userCount() + " other editor" + (userCount() > 1 ? "s" : "")) :
+            "editing alone"}</Text>
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent
+        borderColor={darkMode ? "#222222" : "#999999"}
+        bgColor={darkMode ? "#333333" : "#e8e8e8"}
+        color={darkMode ? "#cbcaca" : "inherit"}
+        paddingBottom={5}
+      >
+        <PopoverCloseButton />
+        <Container>
           <Heading mt={4} mb={1.5} size="sm">
-            Language
+            Active Users
           </Heading>
-          <Select
-            size="sm"
-            bgColor={darkMode ? "#3c3c3c" : "white"}
-            borderColor={darkMode ? "#3c3c3c" : "white"}
-            value={language}
-            onChange={(event) => handleChangeLanguage(event.target.value)}
-          >
-            {languages.map((lang) => (
-              <option key={lang} value={lang} style={{ color: "black" }}>
-                {lang}
-              </option>
-            ))}
-          </Select>
+          <UserList
+            users={users}
+            me={me}
+            onChangeName={(name) => name.length > 0 && setName(name)}
+            onChangeColor={() => setHue(generateHue())}
+            darkMode={darkMode}
+          />
 
           <Heading mt={4} mb={1.5} size="sm">
             Share Link
           </Heading>
           <InputGroup size="sm">
+            <InputLeftElement>
+              <Icon as={VscLink} color="grey.500" />
+            </InputLeftElement>
             <Input
               readOnly
               pr="3.5rem"
-              variant="outline"
-              bgColor={darkMode ? "#3c3c3c" : "white"}
-              borderColor={darkMode ? "#3c3c3c" : "white"}
+              variant="flushed"
               value={`${window.location.origin}/#${id}`}
             />
             <InputRightElement width="3.5rem">
@@ -235,26 +388,31 @@ function App() {
               </Button>
             </InputRightElement>
           </InputGroup>
+        </Container>
+      </PopoverContent>
+    </Popover>
+  )
+}
 
-          <Heading mt={4} mb={1.5} size="sm">
-            Active Users
-          </Heading>
-          <Stack spacing={0} mb={1.5} fontSize="sm">
-            <User
-              info={{ name, hue }}
-              isMe
-              onChangeName={(name) => name.length > 0 && setName(name)}
-              onChangeColor={() => setHue(generateHue())}
-              darkMode={darkMode}
-            />
-            {Object.entries(users).map(([id, info]) => (
-              <User key={id} info={info} darkMode={darkMode} />
-            ))}
-          </Stack>
+type AboutBoxProps = {
+  loadSample: () => unknown;
+  darkMode: boolean;
+}
+
+
+function AboutDrawer({ darkMode, loadSample }: AboutBoxProps) {
+  return (
+    <>
+      <DrawerOverlay />
+      <DrawerContent
+        bgColor={darkMode ? "#1e1e1e" : "white"}
+        color={darkMode ? "#cbcaca" : "inherit"}
+      >
+        <DrawerCloseButton />
+        <DrawerHeader>About <Link href="/">Rustpad</Link></DrawerHeader>
+
+        <DrawerBody>
 
-          <Heading mt={4} mb={1.5} size="sm">
-            About
-          </Heading>
           <Text fontSize="sm" mb={1.5}>
             <strong>Rustpad</strong> is an open-source collaborative text editor
             based on the <em>operational transformation</em> algorithm.
@@ -276,51 +434,64 @@ function App() {
             for details.
           </Text>
 
-          <Button
-            size="sm"
-            colorScheme={darkMode ? "whiteAlpha" : "blackAlpha"}
-            borderColor={darkMode ? "purple.400" : "purple.600"}
-            color={darkMode ? "purple.400" : "purple.600"}
-            variant="outline"
-            leftIcon={<VscRepoPull />}
-            mt={1}
-            onClick={handleLoadSample}
-          >
-            Read the code
-          </Button>
-        </Container>
-        <Flex flex={1} minW={0} h="100%" direction="column" overflow="hidden">
-          <HStack
-            h={6}
-            spacing={1}
-            color="#888888"
-            fontWeight="medium"
-            fontSize="13px"
-            px={3.5}
-            flexShrink={0}
-          >
-            <Icon as={VscFolderOpened} fontSize="md" color="blue.500" />
-            <Text>documents</Text>
-            <Icon as={VscChevronRight} fontSize="md" />
-            <Icon as={VscGist} fontSize="md" color="purple.500" />
-            <Text>{id}</Text>
-          </HStack>
-          <Box flex={1} minH={0}>
-            <Editor
-              theme={darkMode ? "vs-dark" : "vs"}
-              language={language}
-              options={{
-                automaticLayout: true,
-                fontSize: 13,
-              }}
-              onMount={(editor) => setEditor(editor)}
-            />
-          </Box>
-        </Flex>
-      </Flex>
-      <Footer />
-    </Flex>
-  );
+          <LoadSampleButton darkMode={darkMode} loadSample={loadSample} />
+
+        </DrawerBody>
+      </DrawerContent>
+    </>
+  )
 }
 
-export default App;
+function LoadSampleButton({ darkMode, loadSample }: AboutBoxProps) {
+  const { isOpen, onOpen, onClose } = useDisclosure()
+  const cancelRef = useRef<HTMLButtonElement>(null)
+
+  return (
+    <>
+      <Button
+        size="sm"
+        colorScheme={darkMode ? "whiteAlpha" : "blackAlpha"}
+        borderColor={darkMode ? "purple.400" : "purple.600"}
+        color={darkMode ? "purple.400" : "purple.600"}
+        variant="outline"
+        leftIcon={<VscRepoPull />}
+        mt={1}
+        onClick={onOpen}
+      >
+        Read the code
+      </Button>
+
+      <AlertDialog
+        isOpen={isOpen}
+        leastDestructiveRef={cancelRef}
+        onClose={onClose}
+      >
+        <AlertDialogOverlay>
+          <AlertDialogContent
+
+            bgColor={darkMode ? "#333333" : "#e8e8e8"}
+            color={darkMode ? "#cccccc" : "#383838"}
+          >
+            <AlertDialogHeader fontSize='lg' fontWeight='bold'>
+              Load Rustpad code
+            </AlertDialogHeader>
+
+            <AlertDialogBody>
+              Are you sure? This will overwrite the current session.
+            </AlertDialogBody>
+
+            <AlertDialogFooter>
+              <Button
+                colorScheme={darkMode ? "whiteAlpha" : "blackAlpha"} ref={cancelRef} onClick={onClose}>
+                Cancel
+              </Button>
+              <Button colorScheme='red' onClick={() => { loadSample(); onClose() }} ml={3}>
+                Yes
+              </Button>
+            </AlertDialogFooter>
+          </AlertDialogContent>
+        </AlertDialogOverlay>
+      </AlertDialog>
+    </>
+  )
+}

+ 19 - 17
src/ConnectionStatus.tsx

@@ -1,5 +1,4 @@
-import { HStack, Icon, Text } from "@chakra-ui/react";
-import { VscCircleFilled } from "react-icons/vsc";
+import { Tooltip, Spinner } from "@chakra-ui/react";
 
 type ConnectionStatusProps = {
   connection: "connected" | "disconnected" | "desynchronized";
@@ -8,9 +7,16 @@ type ConnectionStatusProps = {
 
 function ConnectionStatus({ connection, darkMode }: ConnectionStatusProps) {
   return (
-    <HStack spacing={1}>
-      <Icon
-        as={VscCircleFilled}
+    <Tooltip label={
+      {
+        connected: "Connected",
+        disconnected: "Connecting...",
+        desynchronized: "Disconnected, please refresh",
+      }[connection]
+    }>
+      <Spinner
+        marginRight={2}
+        size="xs"
         color={
           {
             connected: "green.500",
@@ -18,21 +24,17 @@ function ConnectionStatus({ connection, darkMode }: ConnectionStatusProps) {
             desynchronized: "red.500",
           }[connection]
         }
-      />
-      <Text
-        fontSize="sm"
-        fontStyle="italic"
-        color={darkMode ? "gray.300" : "gray.600"}
-      >
-        {
+        bgColor={
           {
-            connected: "You are connected!",
-            disconnected: "Connecting to the server...",
-            desynchronized: "Disconnected, please refresh.",
+            connected: "green.500",
+            disconnected: "",
+            desynchronized: "",
           }[connection]
         }
-      </Text>
-    </HStack>
+        emptyColor={connection == "disconnected" ? 'transparent' : ''}
+        speed={connection == "disconnected" ? '0.5s' : '0s'}
+      />
+    </Tooltip>
   );
 }
 

+ 99 - 89
src/User.tsx

@@ -1,112 +1,122 @@
 import {
-  Button,
-  ButtonGroup,
+  Editable,
+  EditablePreview,
+  EditableInput,
   HStack,
   Icon,
-  Input,
-  Popover,
-  PopoverArrow,
-  PopoverBody,
-  PopoverCloseButton,
-  PopoverContent,
-  PopoverFooter,
-  PopoverHeader,
-  PopoverTrigger,
+  Spacer,
+  Stack,
   Text,
-  useDisclosure,
+  useEditableControls,
+  IconButton,
+  Tooltip,
+  Box,
 } from "@chakra-ui/react";
-import { useRef } from "react";
 import { FaPalette } from "react-icons/fa";
-import { VscAccount } from "react-icons/vsc";
+import { VscAccount, VscClose, VscEdit } from "react-icons/vsc";
 import { UserInfo } from "./rustpad";
+import React from "react";
 
 type UserProps = {
   info: UserInfo;
-  isMe?: boolean;
-  onChangeName?: (name: string) => unknown;
-  onChangeColor?: () => unknown;
   darkMode: boolean;
 };
 
+function makeColor(hue: number, darkMode: boolean): string {
+  return `hsl(${hue}, 90%, ${darkMode ? "70%" : "25%"})`;
+}
+
 function User({
   info,
-  isMe = false,
+  darkMode,
+}: UserProps) {
+
+  return (
+    <HStack>
+      <Icon as={VscAccount}></Icon>
+      <Text fontWeight="medium" color={makeColor(info.hue, darkMode)}>
+        {info.name}
+      </Text>
+    </HStack>
+  );
+}
+
+function EditableControls({ darkMode }: UserProps) {
+  const {
+    isEditing,
+    getEditButtonProps,
+    getCancelButtonProps,
+  } = useEditableControls();
+
+  return isEditing ? (
+    <IconButton aria-label="cancel" colorScheme={darkMode ? "white" : "gray"} size="xs" icon={<VscClose />} {...getCancelButtonProps()} />
+  ) : (
+    <IconButton aria-label="edit" colorScheme={darkMode ? "white" : "gray"} size="xs" icon={<VscEdit />} {...getEditButtonProps()} />
+  )
+}
+
+type UserEditProps = {
+  me: UserInfo;
+  onChangeName: (name: string) => unknown;
+  onChangeColor?: () => unknown;
+  darkMode: boolean;
+}
+
+function UserEdit({
+  me,
   onChangeName,
   onChangeColor,
   darkMode,
-}: UserProps) {
-  const inputRef = useRef<HTMLInputElement>(null);
-  const { isOpen, onOpen, onClose } = useDisclosure();
+}: UserEditProps) {
+  const colorScheme = darkMode ? "white" : "gray"
+
 
-  const nameColor = `hsl(${info.hue}, 90%, ${darkMode ? "70%" : "25%"})`;
   return (
-    <Popover
-      placement="right"
-      isOpen={isOpen}
-      onClose={onClose}
-      initialFocusRef={inputRef}
-    >
-      <PopoverTrigger>
-        <HStack
-          p={2}
-          rounded="md"
-          _hover={{
-            bgColor: darkMode ? "#464647" : "gray.200",
-            cursor: "pointer",
-          }}
-          onClick={() => isMe && onOpen()}
-        >
-          <Icon as={VscAccount} />
-          <Text fontWeight="medium" color={nameColor}>
-            {info.name}
-          </Text>
-          {isMe && <Text>(you)</Text>}
-        </HStack>
-      </PopoverTrigger>
-      <PopoverContent
-        bgColor={darkMode ? "#333333" : "white"}
-        borderColor={darkMode ? "#464647" : "gray.200"}
-      >
-        <PopoverHeader
-          fontWeight="semibold"
-          borderColor={darkMode ? "#464647" : "gray.200"}
-        >
-          Update Info
-        </PopoverHeader>
-        <PopoverArrow bgColor={darkMode ? "#333333" : "white"} />
-        <PopoverCloseButton />
-        <PopoverBody borderColor={darkMode ? "#464647" : "gray.200"}>
-          <Input
-            ref={inputRef}
-            mb={2}
-            value={info.name}
-            maxLength={25}
-            onChange={(event) => onChangeName?.(event.target.value)}
-          />
-          <Button
-            size="sm"
-            w="100%"
-            leftIcon={<FaPalette />}
-            colorScheme={darkMode ? "whiteAlpha" : "gray"}
-            onClick={onChangeColor}
-          >
-            Change Color
-          </Button>
-        </PopoverBody>
-        <PopoverFooter
-          d="flex"
-          justifyContent="flex-end"
-          borderColor={darkMode ? "#464647" : "gray.200"}
-        >
-          <ButtonGroup size="sm">
-            <Button colorScheme="blue" onClick={onClose}>
-              Done
-            </Button>
-          </ButtonGroup>
-        </PopoverFooter>
-      </PopoverContent>
-    </Popover>
+    <Editable placeholder={me.name} defaultValue={me.name} submitOnBlur={true} onSubmit={onChangeName}>
+      <HStack>
+        <Tooltip label="Change your color">
+          <IconButton
+            colorScheme={colorScheme}
+            size="xxs"
+            aria-label="change color"
+            color={makeColor(me.hue, darkMode)}
+            icon={<FaPalette />}
+            onClick={onChangeColor}></IconButton>
+        </Tooltip>
+        <EditableInput fontWeight="medium" color={makeColor(me.hue, darkMode)} textAlign="left" maxLength={32} />
+        <Tooltip label="Edit your display name">
+          <EditablePreview fontWeight="medium" color={makeColor(me.hue, darkMode)} textAlign="left" />
+        </Tooltip>
+        <YouLabel />
+        <Spacer />
+        <EditableControls info={me} darkMode={darkMode} />
+      </HStack >
+    </Editable>
   );
 }
+type UserListProps = {
+  users: Record<number, UserInfo>;
+} & UserEditProps;
+
+function YouLabel() {
+  const { isEditing, getEditButtonProps } = useEditableControls()
+  return <Box {...getEditButtonProps()} textAlign="left"> {isEditing ? "" : "(you)"}</Box>
+}
+
+function UserList({ users, me, onChangeName, onChangeColor, darkMode }: UserListProps) {
+  return (
+    <Stack spacing={0} mb={1.5} fontSize="sm">
+      {Object.entries(users).map(([id, info]) => (
+        <User key={id} info={info} darkMode={darkMode} />
+      ))}
+      <UserEdit
+        me={me}
+        onChangeName={onChangeName}
+        onChangeColor={onChangeColor}
+        darkMode={darkMode}
+      />
+    </Stack>
+  )
+}
 
-export default User;
+export default UserList;