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
This commit is contained in:
Jonathan Goren 2022-03-20 18:21:58 +02:00
parent 124ff15d3d
commit 06344f9759
3 changed files with 415 additions and 232 deletions

View file

@ -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 flex="1 0" minH={0}>
<Container
w="xs"
bgColor={darkMode ? "#252526" : "#f3f3f3"}
overflowY="auto"
maxW="full"
lineHeight={1.4}
py={4}
>
<Flex spacing={0} px={2} alignItems="center">
<ConnectionStatus darkMode={darkMode} connection={connection} />
<Flex justifyContent="space-between" mt={4} mb={1.5} w="full">
<Heading size="sm">Dark Mode</Heading>
<Switch isChecked={darkMode} onChange={handleDarkMode} />
</Flex>
<Heading mt={4} mb={1.5} size="sm">
Language
</Heading>
<Select
size="sm"
bgColor={darkMode ? "#3c3c3c" : "white"}
borderColor={darkMode ? "#3c3c3c" : "white"}
value={language}
onChange={(event) => handleChangeLanguage(event.target.value)}
<Button
h="1.4rem"
fontWeight="normal"
rounded="none"
_hover={{ bg: darkMode ? "#575759" : "gray.200" }}
bgColor={darkMode ? "#333333" : "gray.200"}
px={2}
onClick={aboutDrawer.onOpen}
>
{languages.map((lang) => (
<option key={lang} value={lang} style={{ color: "black" }}>
{lang}
</option>
))}
</Select>
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}>
<Drawer
isOpen={aboutDrawer.isOpen}
onClose={aboutDrawer.onClose}
placement="left"
>
<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;
};
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">
Active Users
</Heading>
<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>
</>
)
}

View file

@ -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>
);
}

View file

@ -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,
onChangeName,
onChangeColor,
darkMode,
}: UserProps) {
const inputRef = useRef<HTMLInputElement>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
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>
<HStack>
<Icon as={VscAccount}></Icon>
<Text fontWeight="medium" color={makeColor(info.hue, darkMode)}>
{info.name}
</Text>
</HStack>
);
}
export default User;
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,
}: UserEditProps) {
const colorScheme = darkMode ? "white" : "gray"
return (
<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 UserList;