Sfoglia il codice sorgente

refactor: finish settings, add icons and stuff

poeti8 6 anni fa
parent
commit
4639f645af

+ 1 - 0
.babelrc

@@ -5,6 +5,7 @@
       "styled-components",
       { "ssr": true, "displayName": true, "preprocess": false }
     ],
+    "inline-react-svg",
     "@babel/plugin-proposal-optional-chaining",
     "@babel/plugin-proposal-nullish-coalescing-operator"
   ]

+ 28 - 24
client/actions/settings.js

@@ -1,5 +1,5 @@
-import axios from 'axios';
-import cookie from 'js-cookie';
+import axios from "axios";
+import cookie from "js-cookie";
 import {
   DELETE_DOMAIN,
   DOMAIN_ERROR,
@@ -8,8 +8,8 @@ import {
   SET_DOMAIN,
   SET_APIKEY,
   SHOW_DOMAIN_INPUT,
-  BAN_URL,
-} from './actionTypes';
+  BAN_URL
+} from "./actionTypes";
 
 const deleteDomain = () => ({ type: DELETE_DOMAIN });
 const setDomainError = payload => ({ type: DOMAIN_ERROR, payload });
@@ -24,9 +24,9 @@ export const showDomainInput = () => ({ type: SHOW_DOMAIN_INPUT });
 export const getUserSettings = () => async dispatch => {
   try {
     const {
-      data: { apikey, customDomain, homepage },
-    } = await axios.get('/api/auth/usersettings', {
-      headers: { Authorization: cookie.get('token') },
+      data: { apikey, customDomain, homepage }
+    } = await axios.get("/api/auth/usersettings", {
+      headers: { Authorization: cookie.get("token") }
     });
     dispatch(setDomain({ customDomain, homepage }));
     dispatch(setApiKey(apikey));
@@ -39,9 +39,9 @@ export const setCustomDomain = params => async dispatch => {
   dispatch(showDomainLoading());
   try {
     const {
-      data: { customDomain, homepage },
-    } = await axios.post('/api/url/customdomain', params, {
-      headers: { Authorization: cookie.get('token') },
+      data: { customDomain, homepage }
+    } = await axios.post("/api/url/customdomain", params, {
+      headers: { Authorization: cookie.get("token") }
     });
     dispatch(setDomain({ customDomain, homepage }));
   } catch ({ response }) {
@@ -49,22 +49,26 @@ export const setCustomDomain = params => async dispatch => {
   }
 };
 
-export const deleteCustomDomain = () => async dispatch => {
-  try {
-    await axios.delete('/api/url/customdomain', {
-      headers: { Authorization: cookie.get('token') },
-    });
-    dispatch(deleteDomain());
-  } catch ({ response }) {
-    dispatch(setDomainError(response.data.error));
-  }
-};
+export const deleteCustomDomain = () => dispatch =>
+  new Promise(async (res, rej) => {
+    try {
+      await axios.delete("/api/url/customdomain", {
+        headers: { Authorization: cookie.get("token") }
+      });
+      setTimeout(() => {
+        res();
+      }, 4000);
+      dispatch(deleteDomain());
+    } catch ({ response }) {
+      dispatch(setDomainError(response.data.error));
+    }
+  });
 
 export const generateApiKey = () => async dispatch => {
   dispatch(showApiLoading());
   try {
-    const { data } = await axios.post('/api/auth/generateapikey', null, {
-      headers: { Authorization: cookie.get('token') },
+    const { data } = await axios.post("/api/auth/generateapikey", null, {
+      headers: { Authorization: cookie.get("token") }
     });
     dispatch(setApiKey(data.apikey));
   } catch (error) {
@@ -74,8 +78,8 @@ export const generateApiKey = () => async dispatch => {
 
 export const banUrl = params => async dispatch => {
   try {
-    const { data } = await axios.post('/api/url/admin/ban', params, {
-      headers: { Authorization: cookie.get('token') },
+    const { data } = await axios.post("/api/url/admin/ban", params, {
+      headers: { Authorization: cookie.get("token") }
     });
     dispatch(urlBanned());
     return data.message;

+ 5 - 1
client/components/Button.tsx

@@ -25,7 +25,11 @@ const StyledButton = styled(Flex)<Props>`
   text-align: center;
   line-height: 1;
   word-break: keep-all;
-  color: white;
+  color: ${switchProp(prop("color", "blue"), {
+    blue: "white",
+    purple: "white",
+    gray: "#444"
+  })};
   background: ${switchProp(prop("color", "blue"), {
     blue: "linear-gradient(to right, #42a5f5, #2979ff)",
     purple: "linear-gradient(to right, #7e57c2, #6200ea)",

+ 54 - 0
client/components/CustomTable.ts

@@ -0,0 +1,54 @@
+import styled from "styled-components";
+import { Flex } from "reflexbox/styled-components";
+
+const Table = styled(Flex).attrs({
+  as: "table",
+  flex: "1 1 auto",
+  flexDirection: "column",
+  width: 1
+})`
+  background-color: white;
+  border-radius: 12px;
+  box-shadow: 0 6px 30px rgba(50, 50, 50, 0.2);
+  text-align: center;
+
+  tr,
+  th,
+  td,
+  tbody,
+  thead,
+  tfoot {
+    display: flex;
+    flex: 1 1 auto;
+  }
+
+  tr {
+    border-bottom: 1px solid #eaeaea;
+  }
+  tbody tr:last-child {
+    border-bottom-right-radius: 12px;
+    border-bottom-left-radius: 12px;
+  }
+  tbody tr:last-child + tfoot {
+    border: none;
+  }
+  tbody tr:hover {
+    background-color: #f8f8f8;
+  }
+  thead {
+    background-color: #f1f1f1;
+    border-top-right-radius: 12px;
+    border-top-left-radius: 12px;
+    font-weight: bold;
+    tr {
+      border-bottom: 1px solid #dedede;
+    }
+  }
+  tfoot {
+    background-color: #f1f1f1;
+    border-bottom-right-radius: 12px;
+    border-bottom-left-radius: 12px;
+  }
+`;
+
+export default Table;

+ 12 - 0
client/components/Divider.tsx

@@ -0,0 +1,12 @@
+import { Flex } from "reflexbox/styled-components";
+import styled from "styled-components";
+
+const Divider = styled(Flex).attrs({ as: "hr" })`
+  width: 100%;
+  height: 1px;
+  outline: none;
+  border: none;
+  background-color: #e3e3e3;
+`;
+
+export default Divider;

+ 76 - 0
client/components/Icon/Icon.tsx

@@ -0,0 +1,76 @@
+import { Flex } from "reflexbox/styled-components";
+import styled, { css } from "styled-components";
+import { prop, ifProp } from "styled-tools";
+import React, { FC } from "react";
+
+import Trash from "./Trash";
+import Spinner from "./Spinner";
+import Plus from "./Plus";
+import Lock from "./Lock";
+import Refresh from "./Refresh";
+import Zap from "./Zap";
+
+export interface IIcons {
+  lock: JSX.Element;
+  refresh: JSX.Element;
+  zap: JSX.Element;
+  plus: JSX.Element;
+  spinner: JSX.Element;
+  trash: JSX.Element;
+}
+
+const icons = {
+  lock: Lock,
+  refresh: Refresh,
+  zap: Zap,
+  plus: Plus,
+  spinner: Spinner,
+  trash: Trash,
+};
+
+interface Props extends React.ComponentProps<typeof Flex> {
+  name: keyof typeof icons;
+}
+
+const CustomIcon: FC<React.ComponentProps<typeof Flex>> = styled(Flex)`
+  position: relative;
+  fill: ${prop("color")};
+
+  svg {
+    width: 100%;
+    height: 100%;
+  }
+
+  ${ifProp(
+    { as: "button" },
+    css`
+      border: none;
+      outline: none;
+      transition: transform 0.4s ease-out;
+      background-color: transparent;
+      cursor: pointer;
+      box-sizing: content-box;
+
+      :hover,
+      :focus {
+        transform: translateY(-2px) scale(1.02, 1.02);
+      }
+      :focus {
+        outline: 3px solid rgba(65, 164, 245, 0.5);
+      }
+    `
+  )}
+`;
+
+const Icon: FC<Props> = ({ name, ...rest }) => (
+  <CustomIcon {...rest}>{React.createElement(icons[name])}</CustomIcon>
+);
+
+Icon.defaultProps = {
+  size: 16,
+  alignItems: "center",
+  justifyContent: "center",
+  color: "#888"
+};
+
+export default Icon;

+ 23 - 0
client/components/Icon/Lock.tsx

@@ -0,0 +1,23 @@
+import React from "react";
+
+function Lock() {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="24"
+      height="24"
+      fill="none"
+      stroke="currentColor"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      strokeWidth="2"
+      className="feather feather-lock"
+      viewBox="0 0 24 24"
+    >
+      <rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect>
+      <path d="M7 11V7a5 5 0 0110 0v4"></path>
+    </svg>
+  );
+}
+
+export default React.memo(Lock);

+ 23 - 0
client/components/Icon/Plus.tsx

@@ -0,0 +1,23 @@
+import React from "react";
+
+function Plus() {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="24"
+      height="24"
+      fill="none"
+      stroke="currentColor"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      strokeWidth="2"
+      className="feather feather-plus"
+      viewBox="0 0 24 24"
+    >
+      <path d="M12 5L12 19"></path>
+      <path d="M5 12L19 12"></path>
+    </svg>
+  );
+}
+
+export default React.memo(Plus);

+ 24 - 0
client/components/Icon/Refresh.tsx

@@ -0,0 +1,24 @@
+import React from "react";
+
+function Refresh() {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="24"
+      height="24"
+      fill="none"
+      stroke="currentColor"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      strokeWidth="2"
+      className="feather feather-refresh-ccw"
+      viewBox="0 0 24 24"
+    >
+      <path d="M1 4L1 10 7 10"></path>
+      <path d="M23 20L23 14 17 14"></path>
+      <path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15"></path>
+    </svg>
+  );
+}
+
+export default React.memo(Refresh);

+ 43 - 0
client/components/Icon/Spinner.tsx

@@ -0,0 +1,43 @@
+import React from "react";
+import styled, { keyframes } from "styled-components";
+
+const spin = keyframes`
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+`;
+
+const Svg = styled.svg`
+ animation: ${spin} 1s linear infinite;
+`
+
+function Spinner() {
+  return (
+    <Svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="24"
+      height="24"
+      fill="none"
+      stroke="currentColor"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      strokeWidth="2"
+      className="feather feather-loader"
+      viewBox="0 0 24 24"
+    >
+      <path d="M12 2L12 6"></path>
+      <path d="M12 18L12 22"></path>
+      <path d="M4.93 4.93L7.76 7.76"></path>
+      <path d="M16.24 16.24L19.07 19.07"></path>
+      <path d="M2 12L6 12"></path>
+      <path d="M18 12L22 12"></path>
+      <path d="M4.93 19.07L7.76 16.24"></path>
+      <path d="M16.24 7.76L19.07 4.93"></path>
+    </Svg>
+  );
+}
+
+export default React.memo(Spinner);

+ 25 - 0
client/components/Icon/Trash.tsx

@@ -0,0 +1,25 @@
+import React from "react";
+
+function Trash() {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="24"
+      height="24"
+      fill="none"
+      stroke="currentColor"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      strokeWidth="2"
+      className="feather feather-trash-2"
+      viewBox="0 0 24 24"
+    >
+      <path d="M3 6L5 6 21 6"></path>
+      <path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
+      <path d="M10 11L10 17"></path>
+      <path d="M14 11L14 17"></path>
+    </svg>
+  );
+}
+
+export default React.memo(Trash);

+ 22 - 0
client/components/Icon/Zap.tsx

@@ -0,0 +1,22 @@
+import React from "react";
+
+function Zap() {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="24"
+      height="24"
+      fill="none"
+      stroke="currentColor"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      strokeWidth="2"
+      className="feather feather-zap"
+      viewBox="0 0 24 24"
+    >
+      <path d="M13 2L3 14 12 14 11 22 21 10 12 10 13 2z"></path>
+    </svg>
+  );
+}
+
+export default React.memo(Zap);

+ 1 - 0
client/components/Icon/index.ts

@@ -0,0 +1 @@
+export { default } from "./Icon";

+ 27 - 43
client/components/Modal.tsx

@@ -1,13 +1,11 @@
-import React, { FC } from 'react';
-import styled from 'styled-components';
-import { Flex } from 'reflexbox/styled-components';
-
-import Button from './Button';
+import React, { FC } from "react";
+import styled from "styled-components";
+import { Flex } from "reflexbox/styled-components";
 
 interface Props {
-  close: any; // TODO: typing
-  handler?: any; // TODO: typing
-  show?: boolean;
+  show: boolean;
+  id?: string;
+  closeHandler?: () => unknown;
 }
 
 const Wrapper = styled.div`
@@ -23,45 +21,31 @@ const Wrapper = styled.div`
   z-index: 1000;
 `;
 
-const Content = styled.div`
-  padding: 48px 64px;
-  text-align: center;
-  border-radius: 8px;
-  background-color: white;
-
-  @media only screen and (max-width: 768px) {
-    width: 90%;
-    padding: 32px;
-  }
-`;
-
-const ButtonsWrapper = styled(Flex).attrs({
-  justifyContent: 'center',
-  mt: 40,
-})`
-  button {
-    margin: 0 16px;
-  }
-`;
-
-const Modal: FC<Props> = ({ children, handler, show, close }) =>
-  show ? (
-    <Wrapper>
-      <Content>
+const Modal: FC<Props> = ({ children, id, show, closeHandler }) => {
+  if (!show) return null;
+
+  const onClickOutside = e => {
+    if (e.target.id === id) closeHandler();
+  };
+
+  return (
+    <Wrapper id={id} onClick={onClickOutside}>
+      <Flex
+        minWidth={[400, 450]}
+        maxWidth={0.9}
+        py={[32, 32, 48]}
+        px={[24, 24, 32]}
+        style={{ borderRadius: 8, backgroundColor: "white" }}
+        flexDirection="column"
+      >
         {children}
-        <ButtonsWrapper>
-          <Button color="gray" onClick={close}>
-            {handler ? 'No' : 'Close'}
-          </Button>
-          {handler && <Button onClick={handler}>Yes</Button>}
-        </ButtonsWrapper>
-      </Content>
+      </Flex>
     </Wrapper>
-  ) : null;
+  );
+};
 
 Modal.defaultProps = {
-  show: false,
-  handler: null,
+  show: false
 };
 
 export default Modal;

+ 0 - 208
client/components/Settings/Settings.tsx

@@ -1,208 +0,0 @@
-import React, { Component, Fragment, FC, useEffect, useState } from "react";
-import Router from "next/router";
-import { bindActionCreators } from "redux";
-import { connect } from "react-redux";
-import styled from "styled-components";
-import cookie from "js-cookie";
-import axios from "axios";
-import { Flex } from "reflexbox/styled-components";
-
-import SettingsWelcome from "./SettingsWelcome";
-import SettingsDomain from "./SettingsDomain";
-import SettingsPassword from "./SettingsPassword";
-import SettingsBan from "./SettingsBan";
-import SettingsApi from "./SettingsApi";
-import Modal from "../Modal";
-import { fadeIn } from "../../helpers/animations";
-import {
-  deleteCustomDomain,
-  generateApiKey,
-  getUserSettings,
-  setCustomDomain,
-  showDomainInput,
-  banUrl
-} from "../../actions";
-
-// TODO: types
-interface Props {
-  auth: {
-    admin: boolean;
-    isAuthenticated: boolean;
-    user: string;
-  };
-  apiLoading?: boolean;
-  deleteCustomDomain: any;
-  domainLoading?: boolean;
-  banUrl: any;
-  setCustomDomain: any;
-  generateApiKey: any;
-  getUserSettings: any;
-  settings: {
-    apikey: string;
-    customDomain: string;
-    homepage: string;
-    domainInput: boolean;
-  };
-  showDomainInput: any;
-}
-
-const Wrapper = styled(Flex).attrs({
-  width: 600,
-  maxWidth: "90%",
-  flexDirection: "column",
-  alignItems: "flex-start",
-  pb: 80
-})`
-  position: relative;
-  animation: ${fadeIn} 0.8s ease;
-
-  hr {
-    width: 100%;
-    height: 1px;
-    outline: none;
-    border: none;
-    background-color: #e3e3e3;
-    margin: 24px 0;
-
-    @media only screen and (max-width: 768px) {
-      margin: 12px 0;
-    }
-  }
-  a {
-    margin: 32px 0 0;
-    color: #2196f3;
-    text-decoration: none;
-
-    :hover {
-      color: #2196f3;
-      border-bottom: 1px dotted #2196f3;
-    }
-  }
-`;
-
-const Settings: FC<Props> = props => {
-  const [modal, setModal] = useState(false);
-  const [password, setPassword] = useState({ message: "", error: "" });
-
-  // useEffect(() => {
-  //   // FIXME: probably should be moved somewhere else
-  //   if (!props.auth.isAuthenticated) {
-  //     Router.push("/login");
-  //   } else {
-  //     props.getUserSettings();
-  //   }
-  // }, []);
-
-  const handleCustomDomain = e => {
-    e.preventDefault();
-    if (props.domainLoading) return null;
-    const customDomain = e.currentTarget.elements.customdomain.value;
-    const homepage = e.currentTarget.elements.homepage.value;
-    return props.setCustomDomain({ customDomain, homepage });
-  };
-
-  const showModal = () => {
-    setModal(true);
-  };
-
-  const closeModal = () => {
-    setModal(false);
-  };
-
-  const deleteDomain = () => {
-    showModal();
-    props.deleteCustomDomain();
-  };
-
-  const changePassword = e => {
-    e.preventDefault();
-    const form = e.target;
-    const password = form.elements.password.value;
-
-    if (password.length < 8) {
-      setPassword(s => ({
-        ...s,
-        error: "Password must be at least 8 chars long."
-      }));
-      setTimeout(() => {
-        setPassword(s => ({ ...s, error: "" }));
-      }, 1500);
-      return;
-    }
-
-    return axios
-      .post(
-        "/api/auth/changepassword",
-        { password },
-        { headers: { Authorization: cookie.get("token") } }
-      )
-      .then(res => {
-        setPassword(s => ({ ...s, message: res.data.message }));
-        setTimeout(() => {
-          setPassword(s => ({ ...s, message: "" }));
-        }, 1500);
-        form.reset();
-      })
-      .catch(err => {
-        setPassword(s => ({ ...s, error: err.response.data.error }));
-        setTimeout(() => {
-          setPassword(s => ({ ...s, error: "" }));
-        }, 1500);
-        form.reset();
-      });
-  };
-
-  return (
-    <Wrapper>
-      <SettingsWelcome user={props.auth.user} />
-      <hr />
-      {props.auth.admin && (
-        <Fragment>
-          <SettingsBan />
-          <hr />
-        </Fragment>
-      )}
-      <SettingsDomain
-        handleCustomDomain={handleCustomDomain}
-        loading={props.domainLoading}
-        settings={props.settings}
-        showDomainInput={props.showDomainInput}
-        showModal={showModal}
-      />
-      <hr />
-      <SettingsPassword />
-      <hr />
-      <SettingsApi />
-      <Modal show={modal} close={closeModal} handler={deleteDomain}>
-        Are you sure do you want to delete the domain?
-      </Modal>
-    </Wrapper>
-  );
-};
-
-Settings.defaultProps = {
-  apiLoading: false,
-  domainLoading: false
-};
-
-const mapStateToProps = ({
-  auth,
-  loading: { api: apiLoading, domain: domainLoading },
-  settings
-}) => ({
-  auth,
-  apiLoading,
-  domainLoading,
-  settings
-});
-
-const mapDispatchToProps = dispatch => ({
-  banUrl: bindActionCreators(banUrl, dispatch),
-  deleteCustomDomain: bindActionCreators(deleteCustomDomain, dispatch),
-  setCustomDomain: bindActionCreators(setCustomDomain, dispatch),
-  generateApiKey: bindActionCreators(generateApiKey, dispatch),
-  getUserSettings: bindActionCreators(getUserSettings, dispatch),
-  showDomainInput: bindActionCreators(showDomainInput, dispatch)
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(Settings);

+ 8 - 9
client/components/Settings/SettingsApi.tsx

@@ -1,12 +1,13 @@
-import React, { FC, useState } from "react";
-import styled from "styled-components";
 import { CopyToClipboard } from "react-copy-to-clipboard";
 import { Flex } from "reflexbox/styled-components";
+import React, { FC, useState } from "react";
+import styled from "styled-components";
 
+import { useStoreState, useStoreActions } from "../../store";
 import Button from "../Button";
-import Text from "../Text";
 import ALink from "../ALink";
-import { useStoreState, useStoreActions } from "../../store";
+import Icon from "../Icon";
+import Text from "../Text";
 
 const ApiKey = styled(Text).attrs({
   mr: 3,
@@ -31,6 +32,7 @@ const SettingsApi: FC = () => {
   };
 
   const onSubmit = async () => {
+    if (loading) return;
     setLoading(true);
     await generateApiKey();
     setLoading(false);
@@ -82,11 +84,8 @@ const SettingsApi: FC = () => {
           </Flex>
         </Flex>
       )}
-      <Button
-        color="purple"
-        icon={loading ? "loader" : "zap"}
-        onClick={onSubmit}
-      >
+      <Button color="purple" onClick={onSubmit} disabled={loading}>
+        <Icon name={loading ? "spinner" : "zap"} mr={2} color="white" />
         {loading ? "Generating..." : apikey ? "Regenerate" : "Generate"} key
       </Button>
     </Flex>

+ 23 - 12
client/components/Settings/SettingsBan.tsx

@@ -4,12 +4,13 @@ import { useFormState } from "react-use-form-state";
 import axios from "axios";
 
 import { getAxiosConfig } from "../../utils";
+import { useMessage } from "../../hooks";
 import TextInput from "../TextInput";
 import Checkbox from "../Checkbox";
-import { API } from '../../consts';
+import { API } from "../../consts";
 import Button from "../Button";
+import Icon from "../Icon";
 import Text from "../Text";
-import { useMessage } from "../../hooks";
 
 interface BanForm {
   id: string;
@@ -26,9 +27,13 @@ const SettingsBan: FC = () => {
   const onSubmit = async e => {
     e.preventDefault();
     setSubmitting(true);
-    setMessage()
+    setMessage();
     try {
-      const { data } = await axios.post(API.BAN_LINK, formState.values, getAxiosConfig());
+      const { data } = await axios.post(
+        API.BAN_LINK,
+        formState.values,
+        getAxiosConfig()
+      );
       setMessage(data.message, "green");
       formState.clear();
     } catch (err) {
@@ -39,8 +44,15 @@ const SettingsBan: FC = () => {
 
   return (
     <Flex flexDirection="column">
-      <Text as="h2" fontWeight={700} mb={4}>Ban link</Text>
-      <Flex as="form" flexDirection="column" onSubmit={onSubmit} alignItems="flex-start">
+      <Text as="h2" fontWeight={700} mb={4}>
+        Ban link
+      </Text>
+      <Flex
+        as="form"
+        flexDirection="column"
+        onSubmit={onSubmit}
+        alignItems="flex-start"
+      >
         <Flex mb={24} alignItems="center">
           <TextInput
             {...text("id")}
@@ -53,11 +65,8 @@ const SettingsBan: FC = () => {
             pr={24}
             width={[1, 3 / 5]}
           />
-          <Button
-            type="submit"
-            icon={submitting ? "loader" : "lock"}
-            disabled={submitting}
-          >
+          <Button type="submit" disabled={submitting}>
+            <Icon name={submitting ? "spinner" : "lock"} color="white" mr={2} />
             {submitting ? "Banning..." : "Ban"}
           </Button>
         </Flex>
@@ -68,7 +77,9 @@ const SettingsBan: FC = () => {
         />
         <Checkbox {...checkbox("domain")} label="Ban Domain" mb={12} />
         <Checkbox {...checkbox("host")} label="Ban Host/IP" />
-        <Text color={message.color} mt={3}>{message.text}</Text>
+        <Text color={message.color} mt={3}>
+          {message.text}
+        </Text>
       </Flex>
     </Flex>
   );

+ 96 - 79
client/components/Settings/SettingsDomain.tsx

@@ -1,70 +1,33 @@
+import { Flex } from "reflexbox/styled-components";
 import React, { FC, useState } from "react";
 import styled from "styled-components";
-import { Flex } from "reflexbox/styled-components";
 
 import { useStoreState, useStoreActions } from "../../store";
 import { useFormState } from "react-use-form-state";
+import { Domain } from "../../store/settings";
 import { useMessage } from "../../hooks";
 import TextInput from "../TextInput";
+import Table from "../CustomTable";
 import Button from "../Button";
+import Modal from "../Modal";
+import Icon from "../Icon";
 import Text from "../Text";
 
-// TODO: types
-interface Props {
-  settings: {
-    customDomain: string;
-    domainInput: boolean;
-    homepage: string;
-  };
-  handleCustomDomain: any;
-  loading: boolean;
-  showDomainInput: any;
-  showModal: any;
-}
-
-const ButtonWrapper = styled(Flex).attrs({
-  justifyContent: ["column", "column", "row"],
-  alignItems: ["flex-start", "flex-start", "center"],
-  my: 32
-})`
-  display: flex;
-
-  button {
-    margin-right: 16px;
-  }
-
-  @media only screen and (max-width: 768px) {
-    > * {
-      margin: 8px 0;
-    }
-  }
+const Th = styled(Flex).attrs({ as: "th", py: 3, px: 3 })`
+  font-size: 15px;
 `;
-
-const Domain = styled.h4`
-  margin: 0 16px 0 0;
-  font-size: 20px;
-  font-weight: bold;
-
-  span {
-    border-bottom: 2px dotted #999;
-  }
+const Td = styled(Flex).attrs({ as: "td", py: 12, px: 3 })`
+  font-size: 15px;
 `;
 
-const Homepage = styled.h6`
-  margin: 0 16px 0 0;
-  font-size: 14px;
-  font-weight: 300;
-
-  span {
-    border-bottom: 2px dotted #999;
-  }
-`;
-
-const SettingsDomain: FC<Props> = ({ showDomainInput, showModal }) => {
+const SettingsDomain: FC = () => {
+  const [modal, setModal] = useState(false);
   const [loading, setLoading] = useState(false);
+  const [deleteLoading, setDeleteLoading] = useState(false);
+  const [domainToDelete, setDomainToDelete] = useState<Domain>(null);
   const [message, setMessage] = useMessage(2000);
   const domains = useStoreState(s => s.settings.domains);
-  const { saveDomain } = useStoreActions(s => s.settings);
+  const { saveDomain, deleteDomain } = useStoreActions(s => s.settings);
   const [formState, { text }] = useFormState<{
     customDomain: string;
     homepage: string;
@@ -79,9 +42,27 @@ const SettingsDomain: FC<Props> = ({ showDomainInput, showModal }) => {
     } catch (err) {
       setMessage(err?.response?.data?.error || "Couldn't add domain.");
     }
+    formState.clear();
     setLoading(false);
   };
 
+  const closeModal = () => {
+    setDomainToDelete(null);
+    setModal(false);
+  };
+
+  const onDelete = async () => {
+    setDeleteLoading(true);
+    try {
+      await deleteDomain();
+      setMessage("Domain has been deleted successfully.", "green");
+    } catch (err) {
+      setMessage(err?.response?.data?.error || "Couldn't delete the domain.");
+    }
+    closeModal();
+    setDeleteLoading(false);
+  };
+
   return (
     <Flex alignItems="flex-start" flexDirection="column">
       <Text as="h2" fontWeight={700} mb={4}>
@@ -96,27 +77,37 @@ const SettingsDomain: FC<Props> = ({ showDomainInput, showModal }) => {
         via form below:
       </Text>
       {domains.length ? (
-        domains.map(d => (
-          <Flex key={d.customDomain}>
-            <Flex alignItems="center">
-              <Domain>
-                <span>{d.customDomain}</span>
-              </Domain>
-              <Homepage>
-                (Homepage redirects to{" "}
-                <span>{d.homepage || window.location.hostname}</span>)
-              </Homepage>
-            </Flex>
-            <ButtonWrapper>
-              <Button icon="edit" onClick={showDomainInput}>
-                Change
-              </Button>
-              <Button color="gray" icon="x" onClick={showModal}>
-                Delete
-              </Button>
-            </ButtonWrapper>
-          </Flex>
-        ))
+        <Table my={3}>
+          <thead>
+            <tr>
+              <Th width={2 / 5}>Domain</Th>
+              <Th width={2 / 5}>Homepage</Th>
+              <Th width={1 / 5}></Th>
+            </tr>
+          </thead>
+          <tbody>
+            {domains.map(d => (
+              <tr>
+                <Td width={2 / 5}>{d.customDomain}</Td>
+                <Td width={2 / 5}>{d.homepage || "default"}</Td>
+                <Td width={1 / 5} justifyContent="center">
+                  <Icon
+                    as="button"
+                    name="trash"
+                    color="#f2392c"
+                    py={0}
+                    px="2px"
+                    size={15}
+                    onClick={() => {
+                      setDomainToDelete(d);
+                      setModal(true);
+                    }}
+                  />
+                </Td>
+              </tr>
+            ))}
+          </tbody>
+        </Table>
       ) : (
         <Flex
           alignItems="flex-start"
@@ -155,17 +146,43 @@ const SettingsDomain: FC<Props> = ({ showDomainInput, showModal }) => {
               />
             </Flex>
           </Flex>
-          <Button
-            type="submit"
-            color="purple"
-            icon={loading ? "loader" : ""}
-            mt={3}
-          >
-            Set domain
+          <Button type="submit" color="purple" mt={3} disabled={loading}>
+            <Icon name={loading ? "spinner" : "plus"} mr={2} color="white" />
+            {loading ? "Setting..." : "Set domain"}
           </Button>
         </Flex>
       )}
       <Text color={message.color}>{message.text}</Text>
+      <Modal id="delete-custom-domain" show={modal} closeHandler={closeModal}>
+        <Text as="h2" fontWeight={700} mb={24} textAlign="center">
+          Delete domain?
+        </Text>
+        <Text as="p" textAlign="center">
+          Are you sure do you want to delete the domain{" "}
+          <Text as="span" fontWeight={700}>
+            "{domainToDelete && domainToDelete.customDomain}""
+          </Text>
+          ?
+        </Text>
+        {/* FIXME: user a proper loading */}
+        <Flex justifyContent="center" mt={44}>
+          {deleteLoading ? (
+            <>
+              <Icon name="spinner" size={20} />
+            </>
+          ) : (
+            <>
+              <Button color="gray" mr={3} onClick={closeModal}>
+                Cancel
+              </Button>
+              <Button color="blue" ml={3} onClick={onDelete}>
+                <Icon name="trash" color="white" mr={2} />
+                Delete
+              </Button>
+            </>
+          )}
+        </Flex>
+      </Modal>
     </Flex>
   );
 };

+ 4 - 2
client/components/Settings/SettingsPassword.tsx

@@ -8,6 +8,7 @@ import { useMessage } from "../../hooks";
 import TextInput from "../TextInput";
 import { API } from "../../consts";
 import Button from "../Button";
+import Icon from "../Icon";
 import Text from "../Text";
 
 const SettingsPassword: FC = () => {
@@ -62,11 +63,12 @@ const SettingsPassword: FC = () => {
           mr={3}
           required
         />
-        <Button type="submit" icon={loading ? "loader" : "refresh"}>
+        <Button type="submit" disabled={loading}>
+          <Icon name={loading ? "spinner" : "refresh"} mr={2} color="white" />
           {loading ? "Updating..." : "Update"}
         </Button>
       </Flex>
-      <Text color={message.color} mt={3} fontSize={14}>
+      <Text color={message.color} mt={3} fontSize={15}>
         {message.text}
       </Text>
     </Flex>

+ 0 - 28
client/components/Settings/SettingsWelcome.tsx

@@ -1,28 +0,0 @@
-import React, { FC } from 'react';
-import styled from 'styled-components';
-
-interface Props {
-  user: string;
-}
-
-const Title = styled.h2`
-  font-size: 28px;
-  font-weight: 300;
-
-  span {
-    padding-bottom: 2px;
-    border-bottom: 2px dotted #999;
-  }
-
-  @media only screen and (max-width: 768px) {
-    font-size: 22px;
-  }
-`;
-
-const SettingsWelcome: FC<Props> = ({ user }) => (
-  <Title>
-    Welcome, <span>{user}</span>.
-  </Title>
-);
-
-export default SettingsWelcome;

+ 16 - 16
client/components/Shortener/ShortenerResult.tsx

@@ -1,14 +1,14 @@
-import React, { FC, useState } from 'react';
-import styled from 'styled-components';
-import { CopyToClipboard } from 'react-copy-to-clipboard';
-import QRCode from 'qrcode.react';
-import { Flex } from 'reflexbox/styled-components';
-
-import Button from '../Button';
-import Loading from '../PageLoading';
-import { fadeIn } from '../../helpers/animations';
-import TBodyButton from '../Table/TBody/TBodyButton';
-import Modal from '../Modal';
+import React, { FC, useState } from "react";
+import styled from "styled-components";
+import { CopyToClipboard } from "react-copy-to-clipboard";
+import QRCode from "qrcode.react";
+import { Flex } from "reflexbox/styled-components";
+
+import Button from "../Button";
+import Loading from "../PageLoading";
+import { fadeIn } from "../../helpers/animations";
+import TBodyButton from "../Table/TBody/TBodyButton";
+import Modal from "../Modal";
 
 // TODO: types
 interface Props {
@@ -21,8 +21,8 @@ interface Props {
 }
 
 const Wrapper = styled(Flex).attrs({
-  justifyContent: 'center',
-  alignItems: 'center',
+  justifyContent: "center",
+  alignItems: "center"
 })`
   position: relative;
 
@@ -87,7 +87,7 @@ const ShortenerResult: FC<Props> = ({
   copyHandler,
   isCopied,
   loading,
-  url,
+  url
 }) => {
   const [qrModal, setQrModal] = useState(false);
   const toggleQrCodeModal = () => setQrModal(current => !current);
@@ -100,7 +100,7 @@ const ShortenerResult: FC<Props> = ({
     <Wrapper>
       {isCopied && <CopyMessage>Copied to clipboard.</CopyMessage>}
       <CopyToClipboard text={url.list[0].shortLink} onCopy={copyHandler}>
-        <Url>{url.list[0].shortLink.replace(/^https?:\/\//, '')}</Url>
+        <Url>{url.list[0].shortLink.replace(/^https?:\/\//, "")}</Url>
       </CopyToClipboard>
       <CopyToClipboard text={url.list[0].shortLink} onCopy={copyHandler}>
         <Button icon="copy">Copy</Button>
@@ -110,7 +110,7 @@ const ShortenerResult: FC<Props> = ({
           <Icon src="/images/qrcode.svg" />
         </QRButton>
       )}
-      <Modal show={qrModal} close={toggleQrCodeModal}>
+      <Modal show={qrModal}>
         <QRCode value={url.list[0].shortLink} size={196} />
       </Modal>
     </Wrapper>

+ 18 - 21
client/components/Table.tsx

@@ -1,14 +1,14 @@
-import React, { Component, FC, useState } from 'react';
-import { bindActionCreators } from 'redux';
-import { connect } from 'react-redux';
-import styled from 'styled-components';
-import { Flex } from 'reflexbox/styled-components';
-
-import THead from './Table/THead';
-import TBody from './Table/TBody';
-import TableOptions from './TableOptions';
-import { deleteShortUrl, getUrlsList } from '../actions';
-import Modal from './Modal';
+import React, { Component, FC, useState } from "react";
+import { bindActionCreators } from "redux";
+import { connect } from "react-redux";
+import styled from "styled-components";
+import { Flex } from "reflexbox/styled-components";
+
+import THead from "./Table/THead";
+import TBody from "./Table/TBody";
+import TableOptions from "./TableOptions";
+import { deleteShortUrl, getUrlsList } from "../actions";
+import Modal from "./Modal";
 
 interface Props {
   deleteShortUrl: any; // TODO: types
@@ -72,9 +72,9 @@ const TFoot = styled.tfoot`
 `;
 
 const defualtModal = {
-  id: '',
-  domain: '',
-  show: false,
+  id: "",
+  domain: "",
+  show: false
 };
 
 const Table: FC<Props> = ({ deleteShortUrl, url }) => {
@@ -94,7 +94,7 @@ const Table: FC<Props> = ({ deleteShortUrl, url }) => {
       setModal({
         id: url.address,
         domain: url.domain,
-        show: true,
+        show: true
       });
     };
   }
@@ -126,7 +126,7 @@ const Table: FC<Props> = ({ deleteShortUrl, url }) => {
           <TableOptions nosearch />
         </TFoot>
       </TableWrapper>
-      <Modal show={modal.show} handler={deleteUrl} close={closeModal}>
+      <Modal show={modal.show}>
         Are you sure do you want to delete the short URL and its stats?
       </Modal>
     </Flex>
@@ -137,10 +137,7 @@ const mapStateToProps = ({ url }) => ({ url });
 
 const mapDispatchToProps = dispatch => ({
   deleteShortUrl: bindActionCreators(deleteShortUrl, dispatch),
-  getUrlsList: bindActionCreators(getUrlsList, dispatch),
+  getUrlsList: bindActionCreators(getUrlsList, dispatch)
 });
 
-export default connect(
-  mapStateToProps,
-  mapDispatchToProps
-)(Table);
+export default connect(mapStateToProps, mapDispatchToProps)(Table);

+ 1 - 1
client/components/Table/TBody/TBodyCount.tsx

@@ -70,7 +70,7 @@ const TBodyCount: FC<Props> = ({ url }) => {
           <Icon src="/images/trash.svg" />
         </TBodyButton>
       </Actions>
-      <Modal show={showModal} close={toggleQrCodeModal}>
+      <Modal show={showModal}>
         <QRCode value={url.shortLink} size={196} />
       </Modal>
     </Flex>

+ 12 - 0
client/components/Tooltip.tsx

@@ -0,0 +1,12 @@
+import ReactTooltip from "react-tooltip";
+import styled from "styled-components";
+
+const Tooltip = styled(ReactTooltip).attrs({
+  effect: "solid"
+})`
+  padding: 4px 8px;
+  border-radius: 4px;
+  font-size: 12px;
+`;
+
+export default Tooltip;

+ 2 - 1
client/consts/consts.ts

@@ -6,5 +6,6 @@ export enum API {
   CHANGE_PASSWORD = "/api/auth/changepassword",
   BAN_LINK = "/api/url/admin/ban",
   CUSTOM_DOMAIN = "/api/url/customdomain",
-  GENERATE_APIKEY = "/api/auth/generateapikey"
+  GENERATE_APIKEY = "/api/auth/generateapikey",
+  SETTINGS = "/api/auth/usersettings"
 }

+ 2 - 0
client/module.d.ts

@@ -1,6 +1,8 @@
 import "next";
 import { initializeStore } from "./store";
 
+declare module "*.svg";
+
 declare global {
   interface Window {
     GA_INITIALIZED: boolean;

+ 44 - 9
client/pages/settings.tsx

@@ -1,20 +1,55 @@
-import React from "react";
+import { Flex } from "reflexbox/styled-components";
+import { NextPage } from "next";
+import React, { useState, useEffect } from "react";
 
+import SettingsPassword from "../components/Settings/SettingsPassword";
+import SettingsDomain from "../components/Settings/SettingsDomain";
+import SettingsBan from "../components/Settings/SettingsBan";
+import SettingsApi from "../components/Settings/SettingsApi";
 import BodyWrapper from "../components/BodyWrapper";
-import Settings from "../components/Settings";
+import Divider from "../components/Divider";
 import Footer from "../components/Footer";
-import { useStoreState } from "../store";
+import { useStoreState, useStoreActions } from "../store";
+import Text from "../components/Text";
 
-const SettingsPage = () => {
-  const { isAuthenticated } = useStoreState(s => s.auth);
+const SettingsPage: NextPage = () => {
+  const { isAuthenticated, email, isAdmin } = useStoreState(s => s.auth);
+  const getSettings = useStoreActions(s => s.settings.getSettings);
 
-  if (!isAuthenticated) {
-    return null;
-  }
+  useEffect(() => {
+    getSettings();
+  }, []);
 
   return (
     <BodyWrapper>
-      <Settings />
+      <Flex
+        width={600}
+        maxWidth="90%"
+        flexDirection="column"
+        alignItems="flex-start"
+        pb={80}
+        mt={4}
+      >
+        <Text as="h1" alignItems="center" fontWeight={300} fontSize={[24, 28]}>
+          Welcome,{" "}
+          <Text as="span" pb="2px" style={{ borderBottom: "2px dotted #999" }}>
+            {email}
+          </Text>
+          .
+        </Text>
+        <Divider my={[4, 48]} />
+        {isAdmin && (
+          <>
+            <SettingsBan />
+            <Divider my={[12, 24]} />
+          </>
+        )}
+        <SettingsDomain />
+        <Divider my={[12, 24]} />
+        <SettingsPassword />
+        <Divider my={[12, 24]} />
+        <SettingsApi />
+      </Flex>
       <Footer />
     </BodyWrapper>
   );

+ 26 - 2
client/store/settings.ts

@@ -1,17 +1,24 @@
 import { action, Action, thunk, Thunk } from "easy-peasy";
 import axios from "axios";
 
-import { API } from "../consts";
 import { getAxiosConfig } from "../utils";
+import { StoreModel } from "./store";
+import { API } from "../consts";
 
-interface Domain {
+export interface Domain {
   customDomain: string;
   homepage: string;
 }
 
+export interface SettingsResp extends Domain {
+  apikey: string;
+}
+
 export interface Settings {
   domains: Array<Domain>;
   apikey: string;
+  setSettings: Action<Settings, SettingsResp>;
+  getSettings: Thunk<Settings, null, null, StoreModel>;
   setApiKey: Action<Settings, string>;
   generateApiKey: Thunk<Settings>;
   addDomain: Action<Settings, Domain>;
@@ -23,6 +30,23 @@ export interface Settings {
 export const settings: Settings = {
   domains: [],
   apikey: null,
+  setSettings: action((state, payload) => {
+    state.apikey = payload.apikey;
+    if (payload.customDomain) {
+      state.domains = [
+        {
+          customDomain: payload.customDomain,
+          homepage: payload.homepage
+        }
+      ];
+    }
+  }),
+  getSettings: thunk(async (actions, payload, { getStoreActions }) => {
+    getStoreActions().loading.show();
+    const res = await axios.get(API.SETTINGS, getAxiosConfig());
+    actions.setSettings(res.data);
+    getStoreActions().loading.hide();
+  }),
   setApiKey: action((state, payload) => {
     state.apikey = payload;
   }),

BIN
dump.rdb


+ 0 - 11
next.config.js

@@ -1,11 +0,0 @@
-const withTypescript = require('@zeit/next-typescript');
-const { parsed: localEnv } = require('dotenv').config();
-const webpack = require('webpack'); // eslint-disable-line
-
-module.exports = withTypescript({
-  webpack(config) {
-    config.plugins.push(new webpack.EnvironmentPlugin(localEnv));
-
-    return config;
-  }
-});

+ 178 - 0
package-lock.json

@@ -1904,6 +1904,15 @@
         "@types/react": "*"
       }
     },
+    "@types/react-tooltip": {
+      "version": "3.11.0",
+      "resolved": "https://registry.npmjs.org/@types/react-tooltip/-/react-tooltip-3.11.0.tgz",
+      "integrity": "sha512-TkXMgkZ5aAKkFE9Wvt8OlOiPtF9ufgBOL9xWlRSzLBaoL12qSOBiyMcU4/8TyED1fuWkm5VTVarScwOPLSArYw==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*"
+      }
+    },
     "@types/redis": {
       "version": "2.8.13",
       "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.13.tgz",
@@ -3287,6 +3296,28 @@
         }
       }
     },
+    "babel-plugin-inline-react-svg": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/babel-plugin-inline-react-svg/-/babel-plugin-inline-react-svg-1.1.0.tgz",
+      "integrity": "sha512-Y/tBMi7Jh7Jh+DGcSNsY9/RW33nvcR067HFK0Dp+03jpidil1sJAffBdajK72xn3tbwMsgFLJubxW5xpQLJytA==",
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.0.0",
+        "@babel/parser": "^7.0.0",
+        "lodash.isplainobject": "^4.0.6",
+        "resolve": "^1.10.0",
+        "svgo": "^0.7.2"
+      },
+      "dependencies": {
+        "resolve": {
+          "version": "1.14.1",
+          "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.14.1.tgz",
+          "integrity": "sha512-fn5Wobh4cxbLzuHaE+nphztHy43/b++4M6SsGFC2gB8uYwf0C8LcarfCz1un7UTW8OFQg9iNjZ4xpcFVGebDPg==",
+          "requires": {
+            "path-parse": "^1.0.6"
+          }
+        }
+      }
+    },
     "babel-plugin-macros": {
       "version": "2.8.0",
       "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz",
@@ -4463,6 +4494,38 @@
       "integrity": "sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A==",
       "dev": true
     },
+    "clap": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/clap/-/clap-1.2.3.tgz",
+      "integrity": "sha512-4CoL/A3hf90V3VIEjeuhSvlGFEHKzOz+Wfc2IVZc+FaUgU0ZQafJTP49fvnULipOPcAfqhyI2duwQyns6xqjYA==",
+      "requires": {
+        "chalk": "^1.1.3"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "2.2.1",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+          "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+        },
+        "chalk": {
+          "version": "1.1.3",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+          "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+          "requires": {
+            "ansi-styles": "^2.2.1",
+            "escape-string-regexp": "^1.0.2",
+            "has-ansi": "^2.0.0",
+            "strip-ansi": "^3.0.0",
+            "supports-color": "^2.0.0"
+          }
+        },
+        "supports-color": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+          "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+        }
+      }
+    },
     "class-utils": {
       "version": "0.3.6",
       "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz",
@@ -4574,6 +4637,14 @@
       "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
       "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw=="
     },
+    "coa": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/coa/-/coa-1.0.4.tgz",
+      "integrity": "sha1-qe8VNmDWqGqL3sAomlxoTSF0Mv0=",
+      "requires": {
+        "q": "^1.1.2"
+      }
+    },
     "collection-visit": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz",
@@ -5174,6 +5245,15 @@
         "postcss": "^7.0.18"
       }
     },
+    "csso": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/csso/-/csso-2.3.2.tgz",
+      "integrity": "sha1-3dUsWHAz9J6Utx/FVWnyUuj/X4U=",
+      "requires": {
+        "clap": "^1.0.9",
+        "source-map": "^0.5.3"
+      }
+    },
     "csstype": {
       "version": "2.6.7",
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.7.tgz",
@@ -10015,6 +10095,15 @@
         }
       }
     },
+    "next-images": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/next-images/-/next-images-1.2.0.tgz",
+      "integrity": "sha512-NhOiUk5StO82074KpnrcITM0OdONUu0T15Sd0VbipxHJueofKAw9zdmAejht0EhwgQDFt63+1OLFYA5web0x9w==",
+      "requires": {
+        "file-loader": "^4.0.0",
+        "url-loader": "^2.0.0"
+      }
+    },
     "next-redux-wrapper": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/next-redux-wrapper/-/next-redux-wrapper-2.1.0.tgz",
@@ -11056,6 +11145,11 @@
         "ts-pnp": "^1.1.2"
       }
     },
+    "popper.js": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.0.tgz",
+      "integrity": "sha512-+G+EkOPoE5S/zChTpmBSSDYmhXJ5PsW8eMhH8cP/CQHMFPBG/kC9Y5IIw6qNYgdJ+/COf0ddY2li28iHaZRSjw=="
+    },
     "posix-character-classes": {
       "version": "0.1.1",
       "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz",
@@ -11827,6 +11921,11 @@
       "integrity": "sha1-mpVopa9+ZXuEYqbp1TKHQ1YM7/Y=",
       "dev": true
     },
+    "q": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
+      "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc="
+    },
     "qr.js": {
       "version": "0.0.0",
       "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz",
@@ -12056,6 +12155,23 @@
         "react-transition-group": "^2.5.0"
       }
     },
+    "react-tippy": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/react-tippy/-/react-tippy-1.3.1.tgz",
+      "integrity": "sha512-WdpdqmZpu7U9vd3MKFQL2GynQBY2HW7AKrzSY9yVDcd2CA/R29e32y9BklMJjg8EcyD+i0SBzW6XSmeC5Mz6Jw==",
+      "requires": {
+        "popper.js": "^1.11.1"
+      }
+    },
+    "react-tooltip": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-3.11.1.tgz",
+      "integrity": "sha512-YCMVlEC2KuHIzOQhPplTK5jmBBwoL+PYJJdJKXj7M/h7oevupd/QSVq6z5U7/ehIGXyHsAqvwpdxexDfyQ0o3A==",
+      "requires": {
+        "classnames": "^2.2.5",
+        "prop-types": "^15.6.0"
+      }
+    },
     "react-transition-group": {
       "version": "2.9.0",
       "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
@@ -12581,6 +12697,11 @@
       "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
       "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
     },
+    "sax": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
+      "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
+    },
     "scheduler": {
       "version": "0.13.6",
       "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz",
@@ -13279,6 +13400,41 @@
         "has-flag": "^3.0.0"
       }
     },
+    "svgo": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/svgo/-/svgo-0.7.2.tgz",
+      "integrity": "sha1-n1dyQTlSE1xv779Ar+ak+qiLS7U=",
+      "requires": {
+        "coa": "~1.0.1",
+        "colors": "~1.1.2",
+        "csso": "~2.3.1",
+        "js-yaml": "~3.7.0",
+        "mkdirp": "~0.5.1",
+        "sax": "~1.2.1",
+        "whet.extend": "~0.9.9"
+      },
+      "dependencies": {
+        "colors": {
+          "version": "1.1.2",
+          "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz",
+          "integrity": "sha1-FopHAXVran9RoSzgyXv6KMCE7WM="
+        },
+        "esprima": {
+          "version": "2.7.3",
+          "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz",
+          "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE="
+        },
+        "js-yaml": {
+          "version": "3.7.0",
+          "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.7.0.tgz",
+          "integrity": "sha1-XJZ93YN6m/3KXy3oQlOr6KHAO4A=",
+          "requires": {
+            "argparse": "^1.0.7",
+            "esprima": "^2.6.0"
+          }
+        }
+      }
+    },
     "symbol-observable": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
@@ -13933,6 +14089,23 @@
         }
       }
     },
+    "url-loader": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-2.3.0.tgz",
+      "integrity": "sha512-goSdg8VY+7nPZKUEChZSEtW5gjbS66USIGCeSJ1OVOJ7Yfuh/36YxCwMi5HVEJh6mqUYOoy3NJ0vlOMrWsSHog==",
+      "requires": {
+        "loader-utils": "^1.2.3",
+        "mime": "^2.4.4",
+        "schema-utils": "^2.5.0"
+      },
+      "dependencies": {
+        "mime": {
+          "version": "2.4.4",
+          "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz",
+          "integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA=="
+        }
+      }
+    },
     "url-parse-lax": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz",
@@ -14227,6 +14400,11 @@
       "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz",
       "integrity": "sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q=="
     },
+    "whet.extend": {
+      "version": "0.9.9",
+      "resolved": "https://registry.npmjs.org/whet.extend/-/whet.extend-0.9.9.tgz",
+      "integrity": "sha1-+HfVv2SMl+WqVC+twW1qJZucEaE="
+    },
     "which": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",

+ 5 - 0
package.json

@@ -33,6 +33,7 @@
   "homepage": "https://github.com/TheDevs-Network/kutt#readme",
   "dependencies": {
     "axios": "^0.19.0",
+    "babel-plugin-inline-react-svg": "^1.1.0",
     "bcryptjs": "^2.4.3",
     "bull": "^3.11.0",
     "cookie-parser": "^1.4.4",
@@ -55,6 +56,7 @@
     "nanoid": "^1.3.4",
     "neo4j-driver": "^1.7.5",
     "next": "^9.1.4",
+    "next-images": "^1.2.0",
     "next-redux-wrapper": "^2.1.0",
     "node-cron": "^2.0.3",
     "nodemailer": "^6.3.0",
@@ -74,6 +76,8 @@
     "react-ga": "^2.5.7",
     "react-inlinesvg": "^0.7.5",
     "react-redux": "^6.0.0",
+    "react-tippy": "^1.3.1",
+    "react-tooltip": "^3.11.1",
     "react-use-form-state": "^0.12.0",
     "recharts": "^1.4.3",
     "redis": "^2.8.0",
@@ -117,6 +121,7 @@
     "@types/pg-query-stream": "^1.0.3",
     "@types/react": "^16.9.16",
     "@types/react-dom": "^16.9.4",
+    "@types/react-tooltip": "^3.11.0",
     "@types/redis": "^2.8.10",
     "@types/reflexbox": "^4.0.0",
     "@types/styled-components": "^4.1.8",