Browse Source

A lot of refactors, mostly moving code out of App

* Create a SideBarGroup that makes groups in the sidebar easier to spot
* Create a seperate component for every sidebar group
* move MOST of the handling functions for these sidebar groups into that
  component to make clearer what theyre for
* move a few other Components and helper functions to where they make more sense or their
  own file
Samuel Maier 3 years ago
parent
commit
a4d8500ccc

+ 91 - 281
src/App.tsx

@@ -1,47 +1,33 @@
-import { useEffect, useRef, useState } from "react";
+import React, { useEffect, useRef, useState } from "react";
 import {
   Box,
-  Button,
   Container,
   Flex,
   Heading,
   HStack,
   Icon,
-  Input,
-  InputGroup,
-  ButtonGroup,
-  InputRightElement,
-  Link,
-  Select,
-  Stack,
   Switch,
   Text,
-  useToast,
 } from "@chakra-ui/react";
-import {
-  VscChevronRight,
-  VscCloudDownload,
-  VscCloudUpload,
-  VscFolderOpened,
-  VscGist,
-  VscRepoPull,
-} from "react-icons/vsc";
+import { VscChevronRight, VscFolderOpened, VscGist } 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 {
-  getFileExtension,
-  getLanguage,
-  Language,
-  languages,
-} from "./languages";
+import { getFileExtension, getLanguage, Language } from "./languages";
 import animals from "./animals.json";
 import Rustpad, { UserInfo } from "./rustpad";
 import useHash from "./useHash";
-import ConnectionStatus from "./ConnectionStatus";
+import ConnectionStatus, { ConnectionStatusState } from "./ConnectionStatus";
 import Footer from "./Footer";
-import User from "./User";
+import { generateHue, DisplayUsers } from "./sidebarComponents/DisplayUsers";
+import { ShareLink } from "./sidebarComponents/ShareLink";
+import { LanguageSelection } from "./sidebarComponents/LanguageSelection";
+import { About } from "./sidebarComponents/About";
+import { DownloadUpload } from "./sidebarComponents/DownloadUpload";
+import { useCustomToasts } from "./useCustomToasts";
+import { useKeyboardCtrlIntercept } from "./useKeyboardCtrlIntercept";
+import { downloadText } from "./downloadUploadWrappers";
 
 function getWsUri(id: string) {
   return (
@@ -51,72 +37,15 @@ function getWsUri(id: string) {
   );
 }
 
-function useKeyboardCtrlIntercept(
-  key: string,
-  reaction: (event: KeyboardEvent) => unknown
-) {
-  useEffect(() => {
-    const wrappedReaction: typeof reaction = (event) => {
-      if (!(event.metaKey || event.ctrlKey)) return;
-      if (event.key.toLowerCase() !== key.toLowerCase()) return;
-      event.preventDefault();
-      reaction(event);
-    };
-    const controller = new AbortController();
-    window.addEventListener("keydown", wrappedReaction, {signal: controller.signal});
-
-    return () => controller.abort();
-  }, [key, reaction]);
-}
-
 function generateName() {
   return "Anonymous " + animals[Math.floor(Math.random() * animals.length)];
 }
 
-function generateHue() {
-  return Math.floor(Math.random() * 360);
-}
-
-/**
- * This appears to still be the best way to download a file while suggesting a filename.
- *
- * According to [mdn](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-download)
- * the download attribute gets ignored on URIs that are not either `same-origin` or use the `blob` or `data` schemes.
- */
-function downloadUri(uri: string, filename: string) {
-  const downloadAnchor = document.createElement("a");
-  downloadAnchor.download = filename;
-  downloadAnchor.href = uri;
-  downloadAnchor.click();
-}
-
-function downloadText(text: string, fileName: string) {
-  const file = new File([text], fileName);
-  const url = URL.createObjectURL(file);
-  downloadUri(url, fileName);
-  URL.revokeObjectURL(url)
-}
-
-async function getFileUploadWithDialog() {
-  const uploadInput = document.createElement("input");
-  uploadInput.type = "file";
-  uploadInput.click();
-  const controller = new AbortController();
-  // await the user-input (selecting the file)
-  await new Promise((resolve) => uploadInput.addEventListener("change", resolve, {signal: controller.signal}));
-  controller.abort();
-  console.log("reached", uploadInput.files?.length);
-  const files = uploadInput.files;
-  if (files?.length !== 1) return;
-  return files[0];
-}
-
 function App() {
-  const toast = useToast();
+  const toasts = useCustomToasts();
   const [language, setLanguage] = useState<Language>("plaintext");
-  const [connection, setConnection] = useState<
-    "connected" | "disconnected" | "desynchronized"
-  >("disconnected");
+  const [connection, setConnection] =
+    useState<ConnectionStatusState>("disconnected");
   const [users, setUsers] = useState<Record<number, UserInfo>>({});
   const [name, setName] = useStorage("name", generateName);
   const [hue, setHue] = useStorage("hue", generateHue);
@@ -137,18 +66,9 @@ function App() {
         onDisconnected: () => setConnection("disconnected"),
         onDesynchronized: () => {
           setConnection("desynchronized");
-          toast({
-            title: "Desynchronized with server",
-            description: "Please save your work and refresh the page.",
-            status: "error",
-            duration: null,
-          });
-        },
-        onChangeLanguage: (language) => {
-          if (languages.includes(language)) {
-            setLanguage(language);
-          }
+          toasts.desynchronized();
         },
+        onChangeLanguage: setLanguage,
         onChangeUsers: setUsers,
       });
       return () => {
@@ -156,7 +76,7 @@ function App() {
         rustpad.current = undefined;
       };
     }
-  }, [id, editor, toast, setUsers]);
+  }, [id, editor, toasts, setUsers]);
 
   useEffect(() => {
     if (connection === "connected") {
@@ -164,38 +84,6 @@ function App() {
     }
   }, [connection, name, hue]);
 
-  function handleChangeLanguage(language: Language) {
-    setLanguage(language);
-    if (rustpad.current?.setLanguage(language)) {
-      toast({
-        title: "Language updated",
-        description: (
-          <>
-            All users are now editing in{" "}
-            <Text as="span" fontWeight="semibold">
-              {language}
-            </Text>
-            .
-          </>
-        ),
-        status: "info",
-        duration: 2000,
-        isClosable: true,
-      });
-    }
-  }
-
-  async function handleCopy() {
-    await navigator.clipboard.writeText(`${window.location.origin}/#${id}`);
-    toast({
-      title: "Copied!",
-      description: "Link copied to clipboard",
-      status: "success",
-      duration: 2000,
-      isClosable: true,
-    });
-  }
-
   function setText(newText: string) {
     const model = editor?.getModel();
     if (!model || !editor) return;
@@ -212,33 +100,29 @@ function App() {
     editor.setPosition({ column: 0, lineNumber: 0 });
   }
 
-  function handleLoadSample() {
-    setText(rustpadRaw);
-    if (language !== "rust") {
-      handleChangeLanguage("rust");
-    }
-  }
-
-  async function handleUploadFile(file: File) {
+  async function uploadFile(file: File) {
     const text = await file.text();
     setText(text);
     const newLanguage = getLanguage(file.name);
-    if (newLanguage !== language) handleChangeLanguage(newLanguage);
+    setLanguage(newLanguage);
+    toasts.fileUpload(name);
   }
 
-  function handleDownloadFile() {
+  function downloadFile() {
     const model = editor?.getModel();
-    if (!model || !editor) return;
-    downloadText(
-      model.getValue(),
-      `rustpad.${getFileExtension(language)}`,
-    )
+    if (!model) return;
+    downloadText(model.getValue(), `rustpad.${getFileExtension(language)}`);
   }
 
-  useKeyboardCtrlIntercept("s", handleDownloadFile);
+  useKeyboardCtrlIntercept("s", downloadFile);
 
-  function handleDarkMode() {
-    setDarkMode(!darkMode);
+  function toggleDarkMode() {
+    setDarkMode((darkMode) => !darkMode);
+  }
+
+  function loadRustpadSourceSample() {
+    setText(rustpadRaw);
+    setLanguage("rust");
   }
 
   return (
@@ -255,7 +139,7 @@ function App() {
         if (dragItems.length !== 1) return;
         const file = dragItems[0].getAsFile();
         if (file === null) return;
-        handleUploadFile(file);
+        uploadFile(file);
       }}
     >
       <Box
@@ -281,147 +165,59 @@ function App() {
 
           <Flex justifyContent="space-between" mt={4} mb={1.5} w="full">
             <Heading size="sm">Dark Mode</Heading>
-            <Switch isChecked={darkMode} onChange={handleDarkMode} />
+            <Switch isChecked={darkMode} onChange={toggleDarkMode} />
           </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 as Language)
-            }
-          >
-            {languages.map((lang) => (
-              <option key={lang} value={lang} style={{ color: "black" }}>
-                {lang}
-              </option>
-            ))}
-          </Select>
-
-          <Heading mt={4} mb={1.5} size="sm">
-            Share Link
-          </Heading>
-          <InputGroup size="sm">
-            <Input
-              readOnly
-              pr="3.5rem"
-              variant="outline"
-              bgColor={darkMode ? "#3c3c3c" : "white"}
-              borderColor={darkMode ? "#3c3c3c" : "white"}
-              value={`${window.location.origin}/#${id}`}
+          <SideBarGroup title="Language">
+            <LanguageSelection
+              {...{
+                language,
+                setLanguage,
+                darkMode,
+              }}
             />
-            <InputRightElement width="3.5rem">
-              <Button
-                h="1.4rem"
-                size="xs"
-                onClick={handleCopy}
-                _hover={{ bg: darkMode ? "#575759" : "gray.200" }}
-                bgColor={darkMode ? "#575759" : "gray.200"}
-              >
-                Copy
-              </Button>
-            </InputRightElement>
-          </InputGroup>
+          </SideBarGroup>
 
-          <Heading mt={4} mb={1.5} size="sm">
-            Upload & Download
-          </Heading>
-          <Text>
-            You can also upload with drag&drop and downlod with Ctrl + S
-          </Text>
-          <ButtonGroup size="sm" display="flex">
-            <Button
-              size="sm"
-              colorScheme={darkMode ? "whiteAlpha" : "blackAlpha"}
-              borderColor={darkMode ? "purple.400" : "purple.600"}
-              color={darkMode ? "purple.400" : "purple.600"}
-              variant="outline"
-              leftIcon={<VscCloudUpload />}
-              mt={1}
-              flex="auto"
-              onClick={async () => {
-                const file = await getFileUploadWithDialog();
-                if (!file) return;
-                handleUploadFile(file);
-              }}
-            >
-              Upload
-            </Button>
-            <Button
-              size="sm"
-              colorScheme={darkMode ? "whiteAlpha" : "blackAlpha"}
-              borderColor={darkMode ? "purple.400" : "purple.600"}
-              color={darkMode ? "purple.400" : "purple.600"}
-              variant="outline"
-              leftIcon={<VscCloudDownload />}
-              mt={1}
-              flex="auto"
-              onClick={(event) => {
-                event.preventDefault();
-                handleDownloadFile();
+          <SideBarGroup title="Share Link">
+            <ShareLink
+              {...{
+                id,
+                darkMode,
               }}
-            >
-              Download
-            </Button>
-          </ButtonGroup>
+            />
+          </SideBarGroup>
 
-          <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}
+          <SideBarGroup title="Upload and Download">
+            <DownloadUpload
+              {...{
+                uploadFile,
+                downloadFile,
+                darkMode,
+              }}
             />
-            {Object.entries(users).map(([id, info]) => (
-              <User key={id} info={info} darkMode={darkMode} />
-            ))}
-          </Stack>
+          </SideBarGroup>
 
-          <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.
-          </Text>
-          <Text fontSize="sm" mb={1.5}>
-            Share a link to this pad with others, and they can edit from their
-            browser while seeing your changes in real time.
-          </Text>
-          <Text fontSize="sm" mb={1.5}>
-            Built using Rust and TypeScript. See the{" "}
-            <Link
-              color="blue.600"
-              fontWeight="semibold"
-              href="https://github.com/ekzhang/rustpad"
-              isExternal
-            >
-              GitHub repository
-            </Link>{" "}
-            for details.
-          </Text>
+          <SideBarGroup title="Active Users">
+            <DisplayUsers
+              {...{
+                users,
+                name,
+                setName,
+                hue,
+                setHue,
+                darkMode,
+              }}
+            />
+          </SideBarGroup>
 
-          <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>
+          <SideBarGroup title="About">
+            <About
+              {...{
+                loadRustpadSourceSample,
+                darkMode,
+              }}
+            />
+          </SideBarGroup>
         </Container>
         <Flex flex={1} minW={0} h="100%" direction="column" overflow="hidden">
           <HStack
@@ -457,4 +253,18 @@ function App() {
   );
 }
 
+function SideBarGroup({
+  title,
+  children,
+}: React.PropsWithChildren<{ title: string }>) {
+  return (
+    <>
+      <Heading mt={4} mb={1.5} size="sm">
+        {title}
+      </Heading>
+      {children}
+    </>
+  );
+}
+
 export default App;

+ 6 - 1
src/ConnectionStatus.tsx

@@ -1,8 +1,13 @@
 import { HStack, Icon, Text } from "@chakra-ui/react";
 import { VscCircleFilled } from "react-icons/vsc";
 
+export type ConnectionStatusState =
+  | "connected"
+  | "disconnected"
+  | "desynchronized";
+
 type ConnectionStatusProps = {
-  connection: "connected" | "disconnected" | "desynchronized";
+  connection: ConnectionStatusState;
   darkMode: boolean;
 };
 

+ 37 - 0
src/downloadUploadWrappers.ts

@@ -0,0 +1,37 @@
+/**
+ * This appears to still be the best way to download a file while suggesting a filename.
+ *
+ * According to [mdn](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-download)
+ * the download attribute gets ignored on URIs that are not either `same-origin` or use the `blob` or `data` schemes.
+ */
+export function downloadUri(uri: string, filename: string) {
+  const downloadAnchor = document.createElement("a");
+  downloadAnchor.download = filename;
+  downloadAnchor.href = uri;
+  downloadAnchor.click();
+}
+
+export function downloadText(text: string, fileName: string) {
+  const file = new File([text], fileName);
+  const url = URL.createObjectURL(file);
+  downloadUri(url, fileName);
+  URL.revokeObjectURL(url);
+}
+
+export async function getFileUploadWithDialog() {
+  const uploadInput = document.createElement("input");
+  uploadInput.type = "file";
+  uploadInput.click();
+  const controller = new AbortController();
+  // await the user-input (selecting the file)
+  await new Promise((resolve) =>
+    uploadInput.addEventListener("change", resolve, {
+      signal: controller.signal,
+    })
+  );
+  controller.abort();
+  console.log("reached", uploadInput.files?.length);
+  const files = uploadInput.files;
+  if (files?.length !== 1) return;
+  return files[0];
+}

+ 17 - 12
src/languages.ts

@@ -85,19 +85,19 @@ export type Language = typeof languages[number];
 
 type LanguageExtensionDecls = {
   /** the default extension (for download and to recognize uploads)*/
-  extension: string,
+  extension: string;
   /** Other known extensions for that language (to detect it in uploads etc) */
-  aliasExtensions: string[],
+  aliasExtensions: string[];
 };
 
-const languagesAndExtensions: {[key in Language]?: LanguageExtensionDecls} = {
+const languagesAndExtensions: { [key in Language]?: LanguageExtensionDecls } = {
   typescript: {
     extension: "ts",
     aliasExtensions: ["tsx"],
   },
   rust: {
     extension: "rs",
-    aliasExtensions: []
+    aliasExtensions: [],
   },
   cpp: {
     extension: "cpp",
@@ -105,7 +105,7 @@ const languagesAndExtensions: {[key in Language]?: LanguageExtensionDecls} = {
   },
   yaml: {
     extension: "yaml",
-    aliasExtensions:["yml"],
+    aliasExtensions: ["yml"],
   },
   html: {
     extension: "html",
@@ -117,19 +117,24 @@ type LanguageExtensions = { [key in string]: Language };
 
 /** Used on download to determine file extension. */
 export function getFileExtension(language: Language) {
-  return languagesAndExtensions[language as keyof typeof languagesAndExtensions]?.extension ?? "txt";
+  return (
+    languagesAndExtensions[language as keyof typeof languagesAndExtensions]
+      ?.extension ?? "txt"
+  );
 }
 
 const defaultExtensionsToLanguage = {} as LanguageExtensions;
 
 const additionalExtensionsToLanguage = {} as LanguageExtensions;
 
-Object.entries(languagesAndExtensions).forEach(([language, {extension, aliasExtensions}]) => {
-  defaultExtensionsToLanguage[extension] = language as Language;
-  aliasExtensions.forEach((aliasExtension) => {
-    additionalExtensionsToLanguage[aliasExtension] = language as Language;
-  })
-})
+Object.entries(languagesAndExtensions).forEach(
+  ([language, { extension, aliasExtensions }]) => {
+    defaultExtensionsToLanguage[extension] = language as Language;
+    aliasExtensions.forEach((aliasExtension) => {
+      additionalExtensionsToLanguage[aliasExtension] = language as Language;
+    });
+  }
+);
 
 const extensionsToLanguage: LanguageExtensions = {
   ...additionalExtensionsToLanguage,

+ 44 - 0
src/sidebarComponents/About.tsx

@@ -0,0 +1,44 @@
+import { Link, Text } from "@chakra-ui/layout";
+import "react";
+import { VscRepoPull } from "react-icons/vsc";
+import { DecoratedButton } from "./DecoratedButton";
+
+type AboutProps = {
+  loadRustpadSourceSample: () => unknown;
+  darkMode: boolean;
+};
+
+export function About({ loadRustpadSourceSample, darkMode }: AboutProps) {
+  return (
+    <>
+      <Text fontSize="sm" mb={1.5}>
+        <strong>Rustpad</strong> is an open-source collaborative text editor
+        based on the <em>operational transformation</em> algorithm.
+      </Text>
+      <Text fontSize="sm" mb={1.5}>
+        Share a link to this pad with others, and they can edit from their
+        browser while seeing your changes in real time.
+      </Text>
+      <Text fontSize="sm" mb={1.5}>
+        Built using Rust and TypeScript. See the{" "}
+        <Link
+          color="blue.600"
+          fontWeight="semibold"
+          href="https://github.com/ekzhang/rustpad"
+          isExternal
+        >
+          GitHub repository
+        </Link>{" "}
+        for details.
+      </Text>
+
+      <DecoratedButton
+        onClick={loadRustpadSourceSample}
+        icon={<VscRepoPull />}
+        darkMode={darkMode}
+      >
+        Read the code
+      </DecoratedButton>
+    </>
+  );
+}

+ 28 - 0
src/sidebarComponents/DecoratedButton.tsx

@@ -0,0 +1,28 @@
+import { Button, ButtonOptions } from "@chakra-ui/button";
+
+export function DecoratedButton({
+  onClick,
+  darkMode,
+  icon,
+  children,
+}: React.PropsWithChildren<{
+  onClick: React.MouseEventHandler<HTMLButtonElement>;
+  icon: ButtonOptions["leftIcon"];
+  darkMode?: boolean;
+}>) {
+  return (
+    <Button
+      size="sm"
+      colorScheme={darkMode ? "whiteAlpha" : "blackAlpha"}
+      borderColor={darkMode ? "purple.400" : "purple.600"}
+      color={darkMode ? "purple.400" : "purple.600"}
+      variant="outline"
+      leftIcon={icon}
+      mt={1}
+      flex="auto"
+      onClick={onClick}
+    >
+      {children}
+    </Button>
+  );
+}

+ 37 - 2
src/User.tsx → src/sidebarComponents/DisplayUsers.tsx

@@ -1,3 +1,6 @@
+import { Stack } from "@chakra-ui/layout";
+import "react";
+import { UserInfo } from "../rustpad";
 import {
   Button,
   ButtonGroup,
@@ -18,7 +21,6 @@ import {
 import { useRef } from "react";
 import { FaPalette } from "react-icons/fa";
 import { VscAccount } from "react-icons/vsc";
-import { UserInfo } from "./rustpad";
 
 type UserProps = {
   info: UserInfo;
@@ -109,4 +111,37 @@ function User({
   );
 }
 
-export default User;
+type DisplayUserProps = UserInfo & {
+  darkMode: boolean;
+  setName: (newName: string) => unknown;
+  setHue: (newHue: number) => unknown;
+  users: Record<number, UserInfo>;
+};
+
+export function generateHue() {
+  return Math.floor(Math.random() * 360);
+}
+
+export function DisplayUsers({
+  name,
+  hue,
+  setName,
+  setHue,
+  users,
+  darkMode,
+}: DisplayUserProps) {
+  return (
+    <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>
+  );
+}

+ 48 - 0
src/sidebarComponents/DownloadUpload.tsx

@@ -0,0 +1,48 @@
+import { ButtonGroup } from "@chakra-ui/button";
+import { Text } from "@chakra-ui/layout";
+import "react";
+import { VscCloudDownload, VscCloudUpload } from "react-icons/vsc";
+import { getFileUploadWithDialog } from "../downloadUploadWrappers";
+import { DecoratedButton } from "./DecoratedButton";
+
+type DownloadUploadProps = {
+  downloadFile: () => unknown;
+  uploadFile: (file: File) => unknown;
+  darkMode: boolean;
+};
+
+export function DownloadUpload({
+  downloadFile,
+  uploadFile,
+  darkMode,
+}: DownloadUploadProps) {
+  return (
+    <>
+      <Text>You can also upload with drag and drop and download with Ctrl + S</Text>
+      <ButtonGroup size="sm" display="flex">
+        <DecoratedButton
+          onClick={async (event) => {
+            event.preventDefault();
+            const file = await getFileUploadWithDialog();
+            if (!file) return;
+            uploadFile(file);
+          }}
+          icon={<VscCloudUpload />}
+          darkMode={darkMode}
+        >
+          Upload
+        </DecoratedButton>
+        <DecoratedButton
+          onClick={(event) => {
+            event.preventDefault();
+            downloadFile();
+          }}
+          icon={<VscCloudDownload />}
+          darkMode={darkMode}
+        >
+          Download
+        </DecoratedButton>
+      </ButtonGroup>
+    </>
+  );
+}

+ 39 - 0
src/sidebarComponents/LanguageSelection.tsx

@@ -0,0 +1,39 @@
+import { Select } from "@chakra-ui/select";
+import "react";
+import { useCustomToasts } from "../useCustomToasts";
+import { Language, languages } from "../languages";
+
+type LanguageSelectionProps = {
+  language: Language;
+  setLanguage: (newLanguage: Language) => unknown;
+  darkMode: boolean;
+};
+
+export function LanguageSelection({
+  language,
+  setLanguage,
+  darkMode,
+}: LanguageSelectionProps) {
+  const toasts = useCustomToasts();
+  function handleChangeLanguage(language: Language) {
+    setLanguage(language);
+    // if (rustpad.current?.setLanguage(language)) {
+    toasts.languageChange(language);
+    // }
+  }
+  return (
+    <Select
+      size="sm"
+      bgColor={darkMode ? "#3c3c3c" : "white"}
+      borderColor={darkMode ? "#3c3c3c" : "white"}
+      value={language}
+      onChange={(event) => handleChangeLanguage(event.target.value as Language)}
+    >
+      {languages.map((lang) => (
+        <option key={lang} value={lang} style={{ color: "black" }}>
+          {lang}
+        </option>
+      ))}
+    </Select>
+  );
+}

+ 39 - 0
src/sidebarComponents/ShareLink.tsx

@@ -0,0 +1,39 @@
+import { Button } from "@chakra-ui/button";
+import { Input, InputGroup, InputRightElement } from "@chakra-ui/input";
+import "react";
+import { useCustomToasts } from "../useCustomToasts";
+
+type ShareLinkProps = { darkMode: boolean; id: string };
+
+export function ShareLink({ darkMode, id }: ShareLinkProps) {
+  const toasts = useCustomToasts();
+
+  async function handleCopy() {
+    await navigator.clipboard.writeText(`${window.location.origin}/#${id}`);
+    toasts.copyToClipBoard();
+  }
+
+  return (
+    <InputGroup size="sm">
+      <Input
+        readOnly
+        pr="3.5rem"
+        variant="outline"
+        bgColor={darkMode ? "#3c3c3c" : "white"}
+        borderColor={darkMode ? "#3c3c3c" : "white"}
+        value={`${window.location.origin}/#${id}`}
+      />
+      <InputRightElement width="3.5rem">
+        <Button
+          h="1.4rem"
+          size="xs"
+          onClick={handleCopy}
+          _hover={{ bg: darkMode ? "#575759" : "gray.200" }}
+          bgColor={darkMode ? "#575759" : "gray.200"}
+        >
+          Copy
+        </Button>
+      </InputRightElement>
+    </InputGroup>
+  );
+}

+ 53 - 0
src/useCustomToasts.tsx

@@ -0,0 +1,53 @@
+import { Language } from "./languages";
+import "react";
+import { useToast, Text } from "@chakra-ui/react";
+import { useMemo } from "react";
+
+export function useCustomToasts() {
+  const toast = useToast();
+  const messages = useMemo(
+    () => ({
+      copyToClipBoard: () =>
+        toast({
+          title: "Copied!",
+          description: "Link copied to clipboard",
+          status: "success",
+          duration: 2000,
+          isClosable: true,
+        }),
+      languageChange: (newLanguage: Language) =>
+        toast({
+          title: "Language updated",
+          description: (
+            <>
+              All users are now editing in{" "}
+              <Text as="span" fontWeight="semibold">
+                {newLanguage}
+              </Text>
+              .
+            </>
+          ),
+          status: "info",
+          duration: 2000,
+          isClosable: true,
+        }),
+      desynchronized: () =>
+        toast({
+          title: "Desynchronized with server",
+          description: "Please save your work and refresh the page.",
+          status: "error",
+          duration: null,
+        }),
+      fileUpload: (user: string) =>
+        toast({
+          title: "File uploaded",
+          description: `A file has been uploaded by ${user}, the edited text has been changed and the language updated accordingly`,
+          status: "info",
+          duration: 5000,
+          isClosable: true,
+        }),
+    }),
+    [toast]
+  );
+  return messages;
+}

+ 21 - 0
src/useKeyboardCtrlIntercept.ts

@@ -0,0 +1,21 @@
+import { useEffect } from "react";
+
+export function useKeyboardCtrlIntercept(
+  key: string,
+  reaction: (event: KeyboardEvent) => unknown
+) {
+  useEffect(() => {
+    const wrappedReaction: typeof reaction = (event) => {
+      if (!(event.metaKey || event.ctrlKey)) return;
+      if (event.key.toLowerCase() !== key.toLowerCase()) return;
+      event.preventDefault();
+      reaction(event);
+    };
+    const controller = new AbortController();
+    window.addEventListener("keydown", wrappedReaction, {
+      signal: controller.signal,
+    });
+
+    return () => controller.abort();
+  }, [key, reaction]);
+}