poeti8 6 лет назад
Родитель
Сommit
2c243edf9e
66 измененных файлов с 1585 добавлено и 1206 удалено
  1. 11 11
      client/actions/url.js
  2. 2 0
      client/components/ALink.tsx
  3. 17 0
      client/components/Animation.ts
  4. 47 2
      client/components/Button.tsx
  5. 1 1
      client/components/Checkbox.tsx
  6. 38 16
      client/components/CustomTable.ts
  7. 149 24
      client/components/Header.tsx
  8. 0 49
      client/components/HeaderLeftMenu.tsx
  9. 0 34
      client/components/HeaderMenuItem.tsx
  10. 0 83
      client/components/HeaderRightMenu.tsx
  11. 21 0
      client/components/Icon/Check.tsx
  12. 22 0
      client/components/Icon/ChevronLeft.tsx
  13. 22 0
      client/components/Icon/ChevronRight.tsx
  14. 23 0
      client/components/Icon/Clipboard.tsx
  15. 23 0
      client/components/Icon/Copy.tsx
  16. 86 11
      client/components/Icon/Icon.tsx
  17. 22 0
      client/components/Icon/Key.tsx
  18. 4 5
      client/components/Icon/Lock.tsx
  19. 21 0
      client/components/Icon/PieChart.tsx
  20. 19 0
      client/components/Icon/QRCode.tsx
  21. 18 0
      client/components/Icon/Send.tsx
  22. 37 0
      client/components/Layout.tsx
  23. 385 0
      client/components/LinksTable.tsx
  24. 11 6
      client/components/Modal.tsx
  25. 12 12
      client/components/NeedToLogin.tsx
  26. 4 12
      client/components/PageLoading.tsx
  27. 2 2
      client/components/Settings/SettingsApi.tsx
  28. 7 2
      client/components/Settings/SettingsBan.tsx
  29. 20 14
      client/components/Settings/SettingsDomain.tsx
  30. 2 2
      client/components/Settings/SettingsPassword.tsx
  31. 254 0
      client/components/Shortener.tsx
  32. 0 176
      client/components/Shortener/Shortener.tsx
  33. 0 88
      client/components/Shortener/ShortenerInput.tsx
  34. 0 128
      client/components/Shortener/ShortenerOptions.tsx
  35. 0 120
      client/components/Shortener/ShortenerResult.tsx
  36. 0 25
      client/components/Shortener/ShortenerTitle.tsx
  37. 0 1
      client/components/Shortener/index.tsx
  38. 2 5
      client/components/Stats/Stats.tsx
  39. 12 12
      client/components/Stats/StatsHead.tsx
  40. 0 143
      client/components/Table.tsx
  41. 15 15
      client/components/Table/THead/THead.tsx
  42. 2 1
      client/components/Text.tsx
  43. 11 68
      client/components/TextInput.tsx
  44. 2 2
      client/components/Tooltip.tsx
  45. 4 1
      client/consts/consts.ts
  46. 0 19
      client/helpers/animations.js
  47. 30 0
      client/helpers/animations.ts
  48. 4 2
      client/hooks.ts
  49. 10 0
      client/next.config.js
  50. 7 7
      client/pages/_document.tsx
  51. 0 62
      client/pages/index.js
  52. 27 0
      client/pages/index.tsx
  53. 13 20
      client/pages/login.tsx
  54. 7 1
      client/pages/logout.tsx
  55. 1 1
      client/pages/report.tsx
  56. 3 2
      client/pages/reset-password.tsx
  57. 2 2
      client/pages/settings.tsx
  58. 1 1
      client/pages/url-password.tsx
  59. 1 1
      client/pages/verify.tsx
  60. 0 3
      client/store/auth.ts
  61. 89 0
      client/store/links.ts
  62. 15 6
      client/store/store.ts
  63. 6 0
      client/utils.ts
  64. BIN
      dump.rdb
  65. 39 8
      package-lock.json
  66. 2 0
      package.json

+ 11 - 11
client/actions/url.js

@@ -1,5 +1,5 @@
-import axios from 'axios';
-import cookie from 'js-cookie';
+import axios from "axios";
+import cookie from "js-cookie";
 import {
 import {
   ADD_URL,
   ADD_URL,
   LIST_URLS,
   LIST_URLS,
@@ -7,8 +7,8 @@ import {
   DELETE_URL,
   DELETE_URL,
   SHORTENER_LOADING,
   SHORTENER_LOADING,
   TABLE_LOADING,
   TABLE_LOADING,
-  SHORTENER_ERROR,
-} from './actionTypes';
+  SHORTENER_ERROR
+} from "./actionTypes";
 
 
 const addUrl = payload => ({ type: ADD_URL, payload });
 const addUrl = payload => ({ type: ADD_URL, payload });
 const listUrls = payload => ({ type: LIST_URLS, payload });
 const listUrls = payload => ({ type: LIST_URLS, payload });
@@ -18,15 +18,15 @@ const showTableLoading = () => ({ type: TABLE_LOADING });
 
 
 export const setShortenerFormError = payload => ({
 export const setShortenerFormError = payload => ({
   type: SHORTENER_ERROR,
   type: SHORTENER_ERROR,
-  payload,
+  payload
 });
 });
 
 
 export const showShortenerLoading = () => ({ type: SHORTENER_LOADING });
 export const showShortenerLoading = () => ({ type: SHORTENER_LOADING });
 
 
 export const createShortUrl = params => async dispatch => {
 export const createShortUrl = params => async dispatch => {
   try {
   try {
-    const { data } = await axios.post('/api/url/submit', params, {
-      headers: { Authorization: cookie.get('token') },
+    const { data } = await axios.post("/api/url/submit", params, {
+      headers: { Authorization: cookie.get("token") }
     });
     });
     dispatch(addUrl(data));
     dispatch(addUrl(data));
   } catch ({ response }) {
   } catch ({ response }) {
@@ -45,12 +45,12 @@ export const getUrlsList = params => async (dispatch, getState) => {
   const { list, ...queryParams } = url;
   const { list, ...queryParams } = url;
   const query = Object.keys(queryParams).reduce(
   const query = Object.keys(queryParams).reduce(
     (string, item) => `${string + item}=${queryParams[item]}&`,
     (string, item) => `${string + item}=${queryParams[item]}&`,
-    '?'
+    "?"
   );
   );
 
 
   try {
   try {
     const { data } = await axios.get(`/api/url/geturls${query}`, {
     const { data } = await axios.get(`/api/url/geturls${query}`, {
-      headers: { Authorization: cookie.get('token') },
+      headers: { Authorization: cookie.get("token") }
     });
     });
     dispatch(listUrls(data));
     dispatch(listUrls(data));
   } catch (error) {
   } catch (error) {
@@ -61,8 +61,8 @@ export const getUrlsList = params => async (dispatch, getState) => {
 export const deleteShortUrl = params => async dispatch => {
 export const deleteShortUrl = params => async dispatch => {
   dispatch(showTableLoading());
   dispatch(showTableLoading());
   try {
   try {
-    await axios.post('/api/url/deleteurl', params, {
-      headers: { Authorization: cookie.get('token') },
+    await axios.post("/api/url/deleteurl", params, {
+      headers: { Authorization: cookie.get("token") }
     });
     });
     dispatch(deleteUrl(params.id));
     dispatch(deleteUrl(params.id));
   } catch ({ response }) {
   } catch ({ response }) {

+ 2 - 0
client/components/ALink.tsx

@@ -5,6 +5,7 @@ interface Props extends BoxProps {
   href?: string;
   href?: string;
   title?: string;
   title?: string;
   target?: string;
   target?: string;
+  rel?: string;
 }
 }
 const ALink = styled(Box).attrs({
 const ALink = styled(Box).attrs({
   as: "a"
   as: "a"
@@ -13,6 +14,7 @@ const ALink = styled(Box).attrs({
   color: #2196f3;
   color: #2196f3;
   border-bottom: 1px dotted transparent;
   border-bottom: 1px dotted transparent;
   text-decoration: none;
   text-decoration: none;
+  transition: all 0.2s ease-out;
 
 
   :hover {
   :hover {
     border-bottom-color: #2196f3;
     border-bottom-color: #2196f3;

+ 17 - 0
client/components/Animation.ts

@@ -0,0 +1,17 @@
+import { fadeInVertical } from "../helpers/animations";
+import { Flex } from "reflexbox/styled-components";
+import styled from "styled-components";
+import { prop } from "styled-tools";
+import { FC } from "react";
+
+interface Props extends React.ComponentProps<typeof Flex> {
+  offset: string;
+  duration?: string;
+}
+
+const Animation: FC<Props> = styled(Flex)<Props>`
+  animation: ${props => fadeInVertical(props.offset)}
+    ${prop("duration", "0.3s")} ease-out;
+`;
+
+export default Animation;

+ 47 - 2
client/components/Button.tsx

@@ -107,7 +107,7 @@ const Icon = styled(SVG)`
   }
   }
 `;
 `;
 
 
-const Button: FC<Props> = props => {
+export const Button: FC<Props> = props => {
   const SVGIcon = props.icon ? (
   const SVGIcon = props.icon ? (
     <Icon
     <Icon
       icon={props.icon}
       icon={props.icon}
@@ -139,4 +139,49 @@ Button.defaultProps = {
   isRound: false
   isRound: false
 };
 };
 
 
-export default Button;
+interface NavButtonProps extends BoxProps {
+  disabled?: boolean;
+  onClick?: any; // TODO: better typing
+  type?: "button" | "submit" | "reset";
+}
+
+export const NavButton = styled(Flex)<NavButtonProps>`
+  display: flex;
+  border: none;
+  border-radius: 4px;
+  box-shadow: 0 0px 10px rgba(100, 100, 100, 0.1);
+  background-color: white;
+  cursor: pointer;
+  transition: all 0.2s ease-out;
+  box-sizing: border-box;
+
+  :hover {
+    transform: translateY(-2px);
+  }
+
+  ${ifProp(
+    "disabled",
+    css`
+      background-color: #f5f5f5;
+      box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1);
+      cursor: default;
+
+      :hover {
+        transform: none;
+      }
+    `
+  )}
+`;
+
+NavButton.defaultProps = {
+  as: "button",
+  type: "button",
+  flex: "0 0 auto",
+  alignItems: "center",
+  justifyContent: "center",
+  width: "auto",
+  height: [26, 28],
+  py: 0,
+  px: ["6px", "8px"],
+  fontSize: [12]
+};

+ 1 - 1
client/components/Checkbox.tsx

@@ -82,7 +82,7 @@ const Checkbox: FC<Props> = ({
     >
     >
       <Input name={name} id={id} checked={checked} />
       <Input name={name} id={id} checked={checked} />
       <Box checked={checked} width={width} height={height} />
       <Box checked={checked} width={width} height={height} />
-      <Text as="span" ml={2}>
+      <Text as="span" ml={12} color="#555">
         {label}
         {label}
       </Text>
       </Text>
     </Flex>
     </Flex>

+ 38 - 16
client/components/CustomTable.ts

@@ -1,16 +1,13 @@
-import styled from "styled-components";
 import { Flex } from "reflexbox/styled-components";
 import { Flex } from "reflexbox/styled-components";
+import styled, { css } from "styled-components";
+import { ifProp, prop } from "styled-tools";
 
 
-const Table = styled(Flex).attrs({
-  as: "table",
-  flex: "1 1 auto",
-  flexDirection: "column",
-  width: 1
-})`
+const Table = styled(Flex)<{ scrollWidth?: string }>`
   background-color: white;
   background-color: white;
   border-radius: 12px;
   border-radius: 12px;
-  box-shadow: 0 6px 30px rgba(50, 50, 50, 0.2);
+  box-shadow: 0 6px 15px hsla(200, 20%, 70%, 0.3);
   text-align: center;
   text-align: center;
+  overflow: scroll;
 
 
   tr,
   tr,
   th,
   th,
@@ -19,36 +16,61 @@ const Table = styled(Flex).attrs({
   thead,
   thead,
   tfoot {
   tfoot {
     display: flex;
     display: flex;
-    flex: 1 1 auto;
+    overflow: hidden;
+  }
+
+  tbody,
+  thead,
+  tfoot {
+    flex-direction: column;
   }
   }
 
 
   tr {
   tr {
-    border-bottom: 1px solid #eaeaea;
+    border-bottom: 1px solid hsl(200, 14%, 94%);
   }
   }
-  tbody tr:last-child {
+  tbody {
     border-bottom-right-radius: 12px;
     border-bottom-right-radius: 12px;
     border-bottom-left-radius: 12px;
     border-bottom-left-radius: 12px;
+    overflow: hidden;
   }
   }
-  tbody tr:last-child + tfoot {
+  tbody + tfoot {
     border: none;
     border: none;
   }
   }
   tbody tr:hover {
   tbody tr:hover {
-    background-color: #f8f8f8;
+    background-color: hsl(200, 14%, 98%);
   }
   }
   thead {
   thead {
-    background-color: #f1f1f1;
+    background-color: hsl(200, 14%, 96%);
     border-top-right-radius: 12px;
     border-top-right-radius: 12px;
     border-top-left-radius: 12px;
     border-top-left-radius: 12px;
     font-weight: bold;
     font-weight: bold;
     tr {
     tr {
-      border-bottom: 1px solid #dedede;
+      border-bottom: 1px solid hsl(200, 14%, 90%);
     }
     }
   }
   }
   tfoot {
   tfoot {
-    background-color: #f1f1f1;
+    background-color: hsl(200, 14%, 96%);
     border-bottom-right-radius: 12px;
     border-bottom-right-radius: 12px;
     border-bottom-left-radius: 12px;
     border-bottom-left-radius: 12px;
   }
   }
+
+  ${ifProp(
+    "scrollWidth",
+    css`
+      thead,
+      tbody,
+      tfoot {
+        min-width: ${prop("scrollWidth")};
+      }
+    `
+  )}
 `;
 `;
 
 
+Table.defaultProps = {
+  as: "table",
+  flex: "1 1 auto",
+  flexDirection: "column",
+  width: 1
+};
+
 export default Table;
 export default Table;

+ 149 - 24
client/components/Header.tsx

@@ -1,32 +1,157 @@
+import { Flex } from "reflexbox/styled-components";
 import React, { FC } from "react";
 import React, { FC } from "react";
-import { bindActionCreators } from "redux";
-import { connect } from "react-redux";
+import Router from "next/router";
+import Link from "next/link";
+
+import { useStoreState } from "../store";
 import styled from "styled-components";
 import styled from "styled-components";
-import { Flex } from "reflexbox/styled-components";
+import { RowCenterV } from "./Layout";
+import { Button } from "./Button";
+import ALink from "./ALink";
+
+const Li = styled(Flex).attrs({ ml: [16, 32] })`
+  a {
+    color: inherit;
+
+    :hover {
+      color: #2196f3;
+    }
+  }
+`;
+
+const LogoImage = styled.div`
+  & > a {
+    position: relative;
+    display: flex;
+    align-items: center;
+    margin: 0 8px 0 0;
+    font-size: 22px;
+    font-weight: bold;
+    text-decoration: none;
+    color: inherit;
+    transition: border-color 0.2s ease-out;
+  }
+
+  @media only screen and (max-width: 488px) {
+    a {
+      font-size: 18px;
+    }
+  }
+
+  img {
+    width: 18px;
+    margin-right: 11px;
+  }
+`;
+
+const Header: FC = () => {
+  const { isAuthenticated } = useStoreState(s => s.auth);
+
+  const login = !isAuthenticated && (
+    <Li>
+      <Link href="/login">
+        <ALink href="/login" title="login / signup">
+          <Button>Login / Sign up</Button>
+        </ALink>
+      </Link>
+    </Li>
+  );
+  const logout = isAuthenticated && (
+    <Li>
+      <Link href="/logout">
+        <ALink href="/logout" title="logout">
+          Log out
+        </ALink>
+      </Link>
+    </Li>
+  );
+  const settings = isAuthenticated && (
+    <Li>
+      <Link href="/settings">
+        <ALink href="/settings" title="settings">
+          <Button>Settings</Button>
+        </ALink>
+      </Link>
+    </Li>
+  );
 
 
-import HeaderLogo from "./HeaderLogo";
-import HeaderLeftMenu from "./HeaderLeftMenu";
-import HeaderRightMenu from "./HeaderRightMenu";
-
-const Header: FC = () => (
-  <Flex
-    width={1232}
-    maxWidth="100%"
-    p={[16, 16, "0 32px"]}
-    mb={[32, 32, 0]}
-    height={["auto", "auto", 102]}
-    justifyContent="space-between"
-    alignItems={["flex-start", "flex-start", "center"]}
-  >
+  return (
     <Flex
     <Flex
-      flexDirection={["column", "column", "row"]}
-      alignItems={["flex-start", "flex-start", "stretch"]}
+      width={1232}
+      maxWidth="100%"
+      p={[16, 16, "0 32px"]}
+      mb={[32, 32, 0]}
+      height={["auto", "auto", 102]}
+      justifyContent="space-between"
+      alignItems={["flex-start", "flex-start", "center"]}
     >
     >
-      <HeaderLogo />
-      <HeaderLeftMenu />
+      <Flex
+        flexDirection={["column", "column", "row"]}
+        alignItems={["flex-start", "flex-start", "stretch"]}
+      >
+        <LogoImage>
+          <a
+            href="/"
+            title="Homepage"
+            onClick={e => {
+              e.preventDefault();
+              if (window.location.pathname !== "/") Router.push("/");
+            }}
+          >
+            <img src="/images/logo.svg" alt="" />
+            Kutt.it
+          </a>
+        </LogoImage>
+        <Flex
+          style={{ listStyle: "none" }}
+          display={["none", "flex"]}
+          alignItems="flex-end"
+          as="ul"
+          mb="3px"
+          m={0}
+          p={0}
+        >
+          <Li>
+            <ALink
+              href="//github.com/thedevs-network/kutt"
+              target="_blank"
+              rel="noopener noreferrer"
+              title="GitHub"
+            >
+              GitHub
+            </ALink>
+          </Li>
+          <Li>
+            <Link href="/report">
+              <ALink href="/report" title="Report abuse">
+                Report
+              </ALink>
+            </Link>
+          </Li>
+        </Flex>
+      </Flex>
+      <RowCenterV
+        m={0}
+        p={0}
+        justifyContent="flex-end"
+        as="ul"
+        style={{ listStyle: "none" }}
+      >
+        <Li>
+          <Flex display={["flex", "none"]}>
+            <Link href="/report">
+              <ALink href="/report" title="Report">
+                Report
+              </ALink>
+            </Link>
+          </Flex>
+        </Li>
+        {logout}
+        {settings}
+        {login}
+      </RowCenterV>
     </Flex>
     </Flex>
-    <HeaderRightMenu />
-  </Flex>
-);
+  );
+};
 
 
 export default Header;
 export default Header;

+ 0 - 49
client/components/HeaderLeftMenu.tsx

@@ -1,49 +0,0 @@
-import React, { FC } from "react";
-import { bindActionCreators } from "redux";
-import { connect } from "react-redux";
-import styled from "styled-components";
-import Router from "next/router";
-
-import HeaderMenuItem from "./HeaderMenuItem";
-
-const List = styled.ul`
-  display: flex;
-  align-items: flex-end;
-  list-style: none;
-  margin: 0 0 3px;
-  padding: 0;
-
-  @media only screen and (max-width: 488px) {
-    display: none;
-  }
-`;
-
-const HeaderLeftMenu: FC = () => {
-  const goTo = e => {
-    e.preventDefault();
-    const path = e.currentTarget.getAttribute("href");
-    if (!path || window.location.pathname === path) return;
-    Router.push(path);
-  };
-  return (
-    <List>
-      <HeaderMenuItem>
-        <a
-          href="//github.com/thedevs-network/kutt"
-          target="_blank"
-          rel="noopener noreferrer"
-          title="GitHub"
-        >
-          GitHub
-        </a>
-      </HeaderMenuItem>
-      <HeaderMenuItem>
-        <a href="/report" title="Report abuse" onClick={goTo}>
-          Report
-        </a>
-      </HeaderMenuItem>
-    </List>
-  );
-};
-
-export default HeaderLeftMenu;

+ 0 - 34
client/components/HeaderMenuItem.tsx

@@ -1,34 +0,0 @@
-import React, { FC } from 'react';
-import styled from 'styled-components';
-
-import { fadeIn } from '../helpers/animations';
-
-const ListItem = styled.li`
-  margin-left: 32px;
-  animation: ${fadeIn} 0.8s ease;
-
-  @media only screen and (max-width: 488px) {
-    margin-left: 16px;
-    font-size: 13px;
-  }
-`;
-
-const ListLink = styled.div`
-  & > a {
-    padding-bottom: 1px;
-    color: inherit;
-    text-decoration: none;
-  }
-  & > a:hover {
-    color: #2196f3;
-    border-bottom: 1px dotted #2196f3;
-  }
-`;
-
-const HeaderMenuItem: FC = ({ children }) => (
-  <ListItem>
-    <ListLink>{children}</ListLink>
-  </ListItem>
-);
-
-export default HeaderMenuItem;

+ 0 - 83
client/components/HeaderRightMenu.tsx

@@ -1,83 +0,0 @@
-import React, { FC } from "react";
-import { bindActionCreators } from "redux";
-import { connect } from "react-redux";
-import Router from "next/router";
-import styled from "styled-components";
-import { Flex } from "reflexbox/styled-components";
-
-import HeaderMenuItem from "./HeaderMenuItem";
-import Button from "./Button";
-
-interface Props {
-  isAuthenticated: boolean;
-}
-
-const List = styled(Flex).attrs({
-  as: "ul",
-  justifyContent: "flex-end",
-  flexDirection: "row",
-  alignItems: "center",
-  m: 0,
-  p: 0
-})`
-  list-style: none;
-`;
-
-const ReportLink = styled.a`
-  display: none;
-  @media only screen and (max-width: 488px) {
-    display: block;
-  }
-`;
-
-const HeaderMenu: FC<Props> = ({ isAuthenticated }) => {
-  const goTo = e => {
-    e.preventDefault();
-    const path = e.currentTarget.getAttribute("href");
-    if (!path || window.location.pathname === path) return;
-    Router.push(path);
-  };
-
-  const login = !isAuthenticated && (
-    <HeaderMenuItem>
-      <a href="/login" title="login / signup" onClick={goTo}>
-        <Button>Login / Sign up</Button>
-      </a>
-    </HeaderMenuItem>
-  );
-  const logout = isAuthenticated && (
-    <HeaderMenuItem>
-      <a href="/logout" title="logout" onClick={goTo}>
-        Log out
-      </a>
-    </HeaderMenuItem>
-  );
-  const settings = isAuthenticated && (
-    <HeaderMenuItem>
-      <a href="/settings" title="settings" onClick={goTo}>
-        <Button>Settings</Button>
-      </a>
-    </HeaderMenuItem>
-  );
-  return (
-    <List>
-      <HeaderMenuItem>
-        <ReportLink href="/report" title="Report" onClick={goTo}>
-          Report
-        </ReportLink>
-      </HeaderMenuItem>
-      {logout}
-      {settings}
-      {login}
-    </List>
-  );
-};
-
-const mapStateToProps = ({ auth: { isAuthenticated } }) => ({
-  isAuthenticated
-});
-
-export default connect(
-  mapStateToProps,
-  null
-)(HeaderMenu);

+ 21 - 0
client/components/Icon/Check.tsx

@@ -0,0 +1,21 @@
+import React from "react";
+
+function Check() {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="48"
+      height="48"
+      fill="none"
+      stroke="#000"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      strokeWidth="2"
+      viewBox="0 0 24 24"
+    >
+      <path d="M20 6L9 17 4 12"></path>
+    </svg>
+  );
+}
+
+export default React.memo(Check);

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

@@ -0,0 +1,22 @@
+import React from "react";
+
+function ChevronLeft() {
+  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-chevron-left"
+      viewBox="0 0 24 24"
+    >
+      <path d="M15 18L9 12 15 6"></path>
+    </svg>
+  );
+}
+
+export default React.memo(ChevronLeft);

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

@@ -0,0 +1,22 @@
+import React from "react";
+
+function ChevronRight() {
+  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-chevron-right"
+      viewBox="0 0 24 24"
+    >
+      <path d="M9 18L15 12 9 6"></path>
+    </svg>
+  );
+}
+
+export default React.memo(ChevronRight);

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

@@ -0,0 +1,23 @@
+import React from "react";
+
+function Clipboard() {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="auto"
+      height="auto"
+      fill="none"
+      stroke="currentColor"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      strokeWidth="2"
+      className="feather feather-clipboard"
+      viewBox="0 0 24 24"
+    >
+      <path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2"></path>
+      <rect width="8" height="4" x="8" y="2" rx="1" ry="1"></rect>
+    </svg>
+  );
+}
+
+export default React.memo(Clipboard);

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

@@ -0,0 +1,23 @@
+import React from "react";
+
+function Copy() {
+  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-copy"
+      viewBox="0 0 24 24"
+    >
+      <rect width="13" height="13" x="9" y="9" rx="2" ry="2"></rect>
+      <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
+    </svg>
+  );
+}
+
+export default React.memo(Copy);

+ 86 - 11
client/components/Icon/Icon.tsx

@@ -3,51 +3,127 @@ import styled, { css } from "styled-components";
 import { prop, ifProp } from "styled-tools";
 import { prop, ifProp } from "styled-tools";
 import React, { FC } from "react";
 import React, { FC } from "react";
 
 
-import Trash from "./Trash";
+import ChevronRight from "./ChevronRight";
+import ChevronLeft from "./ChevronLeft";
+import Clipboard from "./Clipboard";
+import PieChart from "./PieChart";
+import Refresh from "./Refresh";
 import Spinner from "./Spinner";
 import Spinner from "./Spinner";
+import QRCode from "./QRCode";
+import Trash from "./Trash";
+import Check from "./Check";
 import Plus from "./Plus";
 import Plus from "./Plus";
 import Lock from "./Lock";
 import Lock from "./Lock";
-import Refresh from "./Refresh";
+import Copy from "./Copy";
+import Send from "./Send";
+import Key from "./Key";
 import Zap from "./Zap";
 import Zap from "./Zap";
 
 
 export interface IIcons {
 export interface IIcons {
-  lock: JSX.Element;
-  refresh: JSX.Element;
-  zap: JSX.Element;
+  clipboard: JSX.Element;
+  chevronRight: JSX.Element;
+  chevronLeft: JSX.Element;
+  pieChart: JSX.Element;
+  key: JSX.Element;
   plus: JSX.Element;
   plus: JSX.Element;
+  Lock: JSX.Element;
+  copy: JSX.Element;
+  refresh: JSX.Element;
+  check: JSX.Element;
+  send: JSX.Element;
   spinner: JSX.Element;
   spinner: JSX.Element;
   trash: JSX.Element;
   trash: JSX.Element;
+  zap: JSX.Element;
+  qrcode: JSX.Element;
 }
 }
 
 
 const icons = {
 const icons = {
+  clipboard: Clipboard,
+  chevronRight: ChevronRight,
+  chevronLeft: ChevronLeft,
+  pieChart: PieChart,
+  key: Key,
   lock: Lock,
   lock: Lock,
-  refresh: Refresh,
-  zap: Zap,
+  check: Check,
   plus: Plus,
   plus: Plus,
+  copy: Copy,
+  refresh: Refresh,
+  send: Send,
   spinner: Spinner,
   spinner: Spinner,
   trash: Trash,
   trash: Trash,
+  zap: Zap,
+  qrcode: QRCode
 };
 };
 
 
 interface Props extends React.ComponentProps<typeof Flex> {
 interface Props extends React.ComponentProps<typeof Flex> {
   name: keyof typeof icons;
   name: keyof typeof icons;
+  stroke?: string;
+  fill?: string;
+  hoverFill?: string;
+  hoverStroke?: string;
+  strokeWidth?: string;
 }
 }
 
 
 const CustomIcon: FC<React.ComponentProps<typeof Flex>> = styled(Flex)`
 const CustomIcon: FC<React.ComponentProps<typeof Flex>> = styled(Flex)`
   position: relative;
   position: relative;
-  fill: ${prop("color")};
 
 
   svg {
   svg {
+    transition: all 0.2s ease-out;
     width: 100%;
     width: 100%;
     height: 100%;
     height: 100%;
+
+    ${ifProp(
+      "fill",
+      css`
+        fill: ${prop("fill")};
+      `
+    )}
+
+    ${ifProp(
+      "stroke",
+      css`
+        stroke: ${prop("stroke")};
+      `
+    )}
+
+    ${ifProp(
+      "strokeWidth",
+      css`
+        stroke-width: ${prop("strokeWidth")};
+      `
+    )}
   }
   }
 
 
+  ${ifProp(
+    "hoverFill",
+    css`
+      :hover {
+        svg {
+          fill: ${prop("hoverFill")};
+        }
+      }
+    `
+  )}
+
+  ${ifProp(
+    "hoverStroke",
+    css`
+      :hover {
+        svg {
+          stroke: ${prop("stroke")};
+        }
+      }
+    `
+  )}
+
   ${ifProp(
   ${ifProp(
     { as: "button" },
     { as: "button" },
     css`
     css`
       border: none;
       border: none;
       outline: none;
       outline: none;
       transition: transform 0.4s ease-out;
       transition: transform 0.4s ease-out;
-      background-color: transparent;
+      border-radius: 100%;
+      background-color: none !important;
       cursor: pointer;
       cursor: pointer;
       box-sizing: content-box;
       box-sizing: content-box;
 
 
@@ -69,8 +145,7 @@ const Icon: FC<Props> = ({ name, ...rest }) => (
 Icon.defaultProps = {
 Icon.defaultProps = {
   size: 16,
   size: 16,
   alignItems: "center",
   alignItems: "center",
-  justifyContent: "center",
-  color: "#888"
+  justifyContent: "center"
 };
 };
 
 
 export default Icon;
 export default Icon;

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

@@ -0,0 +1,22 @@
+import React from "react";
+
+function Key() {
+  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-key"
+      viewBox="0 0 24 24"
+    >
+      <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path>
+    </svg>
+  );
+}
+
+export default React.memo(Key);

+ 4 - 5
client/components/Icon/Lock.tsx

@@ -4,14 +4,13 @@ function Lock() {
   return (
   return (
     <svg
     <svg
       xmlns="http://www.w3.org/2000/svg"
       xmlns="http://www.w3.org/2000/svg"
-      width="24"
-      height="24"
+      width="48"
+      height="48"
       fill="none"
       fill="none"
-      stroke="currentColor"
+      stroke="#000"
       strokeLinecap="round"
       strokeLinecap="round"
       strokeLinejoin="round"
       strokeLinejoin="round"
-      strokeWidth="2"
-      className="feather feather-lock"
+      strokeWidth="3"
       viewBox="0 0 24 24"
       viewBox="0 0 24 24"
     >
     >
       <rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect>
       <rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect>

+ 21 - 0
client/components/Icon/PieChart.tsx

@@ -0,0 +1,21 @@
+import React from "react";
+
+function Icon() {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="48"
+      height="48"
+      fill="none"
+      stroke="#000"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      strokeWidth="2"
+      viewBox="0 0 24 24"
+    >
+      <path d="M21.21 15.89A10 10 0 118 2.83M22 12A10 10 0 0012 2v10z"></path>
+    </svg>
+  );
+}
+
+export default React.memo(Icon);

+ 19 - 0
client/components/Icon/QRCode.tsx

@@ -0,0 +1,19 @@
+import React from "react";
+
+function QRCOde() {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="32"
+      height="32"
+      fill="currentColor"
+      className="jam jam-qr-code"
+      preserveAspectRatio="xMinYMin"
+      viewBox="-2 -2 24 24"
+    >
+      <path d="M13 18h3a2 2 0 002-2v-3a1 1 0 012 0v3a4 4 0 01-4 4H4a4 4 0 01-4-4v-3a1 1 0 012 0v3a2 2 0 002 2h3a1 1 0 010 2h6a1 1 0 010-2zM2 7a1 1 0 11-2 0V4a4 4 0 014-4h3a1 1 0 110 2H4a2 2 0 00-2 2v3zm16 0V4a2 2 0 00-2-2h-3a1 1 0 010-2h3a4 4 0 014 4v3a1 1 0 01-2 0z"></path>
+    </svg>
+  );
+}
+
+export default React.memo(QRCOde);

+ 18 - 0
client/components/Icon/Send.tsx

@@ -0,0 +1,18 @@
+import React from "react";
+
+function Send() {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="32"
+      height="32"
+      fill="currentColor"
+      version="1.1"
+      viewBox="0 0 24 24"
+    >
+      <path d="M2 21l21-9L2 3v7l15 2-15 2v7z"></path>
+    </svg>
+  );
+}
+
+export default Send;

+ 37 - 0
client/components/Layout.tsx

@@ -0,0 +1,37 @@
+import { Flex } from "reflexbox/styled-components";
+import { FC } from "react";
+
+type Props = React.ComponentProps<typeof Flex>;
+
+export const Col: FC<Props> = props => (
+  <Flex flexDirection="column" {...props} />
+);
+
+export const RowCenterV: FC<Props> = props => (
+  <Flex alignItems="center" {...props} />
+);
+
+export const RowCenterH: FC<Props> = props => (
+  <Flex justifyContent="center" {...props} />
+);
+
+export const RowCenter: FC<Props> = props => (
+  <Flex alignItems="center" justifyContent="center" {...props} />
+);
+
+export const ColCenterV: FC<Props> = props => (
+  <Flex flexDirection="column" justifyContent="center" {...props} />
+);
+
+export const ColCenterH: FC<Props> = props => (
+  <Flex flexDirection="column" alignItems="center" {...props} />
+);
+
+export const ColCenter: FC<Props> = props => (
+  <Flex
+    flexDirection="column"
+    alignItems="center"
+    justifyContent="center"
+    {...props}
+  />
+);

+ 385 - 0
client/components/LinksTable.tsx

@@ -0,0 +1,385 @@
+import formatDistanceToNow from "date-fns/formatDistanceToNow";
+import { CopyToClipboard } from "react-copy-to-clipboard";
+import React, { FC, useState, useEffect } from "react";
+import { Flex } from "reflexbox/styled-components";
+import styled, { css } from "styled-components";
+import QRCode from "qrcode.react";
+import Link from "next/link";
+
+import { useStoreActions, useStoreState } from "../store";
+import { removeProtocol, withComma } from "../utils";
+import { useFormState } from "react-use-form-state";
+import { NavButton, Button } from "./Button";
+import { Col, RowCenter } from "./Layout";
+import { ifProp } from "styled-tools";
+import TextInput from "./TextInput";
+import Table from "./CustomTable";
+import Tooltip from "./Tooltip";
+import ALink from "./ALink";
+import Modal from "./Modal";
+import Text from "./Text";
+import Icon from "./Icon";
+import Animation from "./Animation";
+
+const Tr = styled(Flex).attrs({ as: "tr", px: [12, 12, 2] })``;
+const Th = styled(Flex)``;
+Th.defaultProps = { as: "th", flexBasis: 0, py: [12, 12, 3], px: [12, 12, 3] };
+
+const Td = styled(Flex)<{ withFade?: boolean }>`
+  position: relative;
+  white-space: nowrap;
+
+  ${ifProp(
+    "withFade",
+    css`
+      :after {
+        content: "";
+        position: absolute;
+        right: 0;
+        top: 0;
+        height: 100%;
+        width: 16px;
+        background: linear-gradient(to left, white, white, transparent);
+      }
+
+      tr:hover &:after {
+        background: linear-gradient(
+          to left,
+          hsl(200, 14%, 98%),
+          hsl(200, 14%, 98%),
+          transparent
+        );
+      }
+    `
+  )}
+`;
+Td.defaultProps = {
+  as: "td",
+  fontSize: [15, 16],
+  alignItems: "center",
+  flexBasis: 0,
+  py: [12, 12, 3],
+  px: [12, 12, 3]
+};
+
+const Action = (props: React.ComponentProps<typeof Icon>) => (
+  <Icon
+    as="button"
+    py={0}
+    px={0}
+    mr={2}
+    size={[23, 24]}
+    p={["4px", "5px"]}
+    stroke="#666"
+    {...props}
+  />
+);
+
+const ogLinkFlex = { flexGrow: [1, 3, 7], flexShrink: [1, 3, 7] };
+const createdFlex = { flexGrow: [1, 1, 3], flexShrink: [1, 1, 3] };
+const shortLinkFlex = { flexGrow: [1, 1, 3], flexShrink: [1, 1, 3] };
+const viewsFlex = {
+  flexGrow: [0.5, 0.5, 1],
+  flexShrink: [0.5, 0.5, 1],
+  justifyContent: "flex-end"
+};
+const actionsFlex = { flexGrow: [1, 1, 2.5], flexShrink: [1, 1, 2.5] };
+
+interface Form {
+  count?: string;
+  page?: string;
+  search?: string;
+}
+
+const LinksTable: FC = () => {
+  const links = useStoreState(s => s.links);
+  const { get, deleteOne } = useStoreActions(s => s.links);
+  const [copied, setCopied] = useState([]);
+  const [qrModal, setQRModal] = useState(-1);
+  const [deleteModal, setDeleteModal] = useState(-1);
+  const [deleteLoading, setDeleteLoading] = useState(false);
+  const [formState, { text }] = useFormState<Form>({ page: "1", count: "10" });
+
+  const options = formState.values;
+  const linkToDelete = links.items[deleteModal];
+
+  useEffect(() => {
+    get(options);
+  }, [options.count, options.page]);
+
+  const onSubmit = e => {
+    e.preventDefault();
+    get(options);
+  };
+
+  const onCopy = (index: number) => () => {
+    setCopied([index]);
+    setTimeout(() => {
+      setCopied(s => s.filter(i => i !== index));
+    }, 1500);
+  };
+
+  const onDelete = async () => {
+    setDeleteLoading(true);
+    await deleteOne({ id: linkToDelete.address, domain: linkToDelete.domain });
+    await get(options);
+    setDeleteLoading(false);
+    setDeleteModal(-1);
+  };
+
+  const onNavChange = (nextPage: number) => () => {
+    formState.setField("page", (parseInt(options.page) + nextPage).toString());
+  };
+
+  const Nav = (
+    <Th
+      alignItems="center"
+      justifyContent="flex-end"
+      flexGrow={1}
+      flexShrink={1}
+    >
+      <Flex as="ul" m={0} p={0} style={{ listStyle: "none" }}>
+        {["10", "25", "50"].map(c => (
+          <Flex key={c} ml={[10, 12]}>
+            <NavButton
+              disabled={options.count === c}
+              onClick={() => formState.setField("count", c)}
+            >
+              {c}
+            </NavButton>
+          </Flex>
+        ))}
+      </Flex>
+      <Flex
+        width="1px"
+        height={20}
+        mx={[3, 24]}
+        style={{ backgroundColor: "#ccc" }}
+      />
+      <Flex>
+        <NavButton
+          onClick={onNavChange(-1)}
+          disabled={options.page === "1"}
+          px={2}
+        >
+          <Icon name="chevronLeft" size={15} />
+        </NavButton>
+        <NavButton
+          onClick={onNavChange(1)}
+          disabled={
+            parseInt(options.page) * parseInt(options.count) > links.total
+          }
+          ml={12}
+          px={2}
+        >
+          <Icon name="chevronRight" size={15} />
+        </NavButton>
+      </Flex>
+    </Th>
+  );
+
+  return (
+    <Col width={1200} maxWidth="95%" margin="40px 0 120px" my={6}>
+      <Text as="h2" fontWeight={300} mb={3}>
+        Recent shortened links.
+      </Text>
+      <Table scrollWidth="700px">
+        <thead>
+          <Tr justifyContent="space-between">
+            <Th flexGrow={1} flexShrink={1}>
+              <form onSubmit={onSubmit}>
+                <TextInput
+                  {...text("search")}
+                  placeholder="Search..."
+                  height={[30, 32]}
+                  placeholderSize={[13, 13, 13, 13]}
+                  fontSize={[14]}
+                  pl={12}
+                  pr={12}
+                  width={[1]}
+                  br="3px"
+                  bbw="2px"
+                />
+              </form>
+            </Th>
+            {Nav}
+          </Tr>
+          <Tr>
+            <Th {...ogLinkFlex}>Original URL</Th>
+            <Th {...createdFlex}>Created</Th>
+            <Th {...shortLinkFlex}>Short URL</Th>
+            <Th {...viewsFlex}>Views</Th>
+            <Th {...actionsFlex}></Th>
+          </Tr>
+        </thead>
+        <tbody style={{ opacity: links.loading ? 0.4 : 1 }}>
+          {!links.items.length ? (
+            <Tr width={1} justifyContent="center">
+              <Td flex="1 1 auto" justifyContent="center">
+                <Text as="p" fontWeight={300} fontSize={18}>
+                  {links.loading ? "Loading links..." : "No links to show."}
+                </Text>
+              </Td>
+            </Tr>
+          ) : (
+            <>
+              {links.items.map((l, index) => (
+                <Tr>
+                  <Td {...ogLinkFlex} withFade>
+                    <ALink href={l.target}>{l.target}</ALink>
+                  </Td>
+                  <Td {...createdFlex}>{`${formatDistanceToNow(
+                    new Date(l.created_at)
+                  )} ago`}</Td>
+                  <Td {...shortLinkFlex} withFade>
+                    {copied.includes(index) ? (
+                      <Animation
+                        offset="10px"
+                        duration="0.2s"
+                        alignItems="center"
+                      >
+                        <Icon
+                          size={[15, 24]}
+                          py={0}
+                          px={0}
+                          mr={2}
+                          p="3px"
+                          name="check"
+                          strokeWidth="3"
+                          stroke="hsl(144, 50%, 60%)"
+                        />
+                      </Animation>
+                    ) : (
+                      <Animation offset="-10px" duration="0.2s">
+                        <CopyToClipboard
+                          text={l.shortLink}
+                          onCopy={onCopy(index)}
+                        >
+                          <Action
+                            name="copy"
+                            strokeWidth="2.5"
+                            stroke="hsl(144, 40%, 57%)"
+                            backgroundColor="hsl(144, 100%, 96%)"
+                          />
+                        </CopyToClipboard>
+                      </Animation>
+                    )}
+                    <ALink href={l.shortLink}>
+                      {removeProtocol(l.shortLink)}
+                    </ALink>
+                  </Td>
+                  <Td {...viewsFlex}>{withComma(l.visit_count)}</Td>
+                  <Td {...actionsFlex} justifyContent="flex-end">
+                    {l.password && (
+                      <>
+                        <Tooltip id={`${index}-tooltip-password`}>
+                          Password protected
+                        </Tooltip>
+                        <Action
+                          as="span"
+                          data-tip
+                          data-for={`${index}-tooltip-password`}
+                          name="key"
+                          stroke="#bbb"
+                          strokeWidth="2.5"
+                          backgroundColor="none"
+                        />
+                      </>
+                    )}
+                    {l.visit_count > 0 && (
+                      <Link
+                        href={`/stats?id=${l.id}${
+                          l.domain ? `&domain=${l.domain}` : ""
+                        }`}
+                      >
+                        <Action
+                          name="pieChart"
+                          stroke="hsl(260, 100%, 69%)"
+                          strokeWidth="2.5"
+                          backgroundColor="hsl(260, 100%, 96%)"
+                        />
+                      </Link>
+                    )}
+                    <Action
+                      name="qrcode"
+                      stroke="none"
+                      fill="hsl(0, 0%, 35%)"
+                      backgroundColor="hsl(0, 0%, 94%)"
+                      onClick={() => setQRModal(index)}
+                    />
+                    <Action
+                      mr={0}
+                      name="trash"
+                      stroke="hsl(0, 100%, 69%)"
+                      strokeWidth="2"
+                      backgroundColor="hsl(0, 100%, 96%)"
+                      onClick={() => setDeleteModal(index)}
+                    />
+                  </Td>
+                </Tr>
+              ))}
+            </>
+          )}
+        </tbody>
+        <tfoot>
+          <Tr justifyContent="flex-end">{Nav}</Tr>
+        </tfoot>
+      </Table>
+      <Modal
+        id="table-qrcode-modal"
+        minWidth="max-content"
+        show={qrModal > -1}
+        closeHandler={() => setQRModal(-1)}
+      >
+        {links.items[qrModal] && (
+          <RowCenter width={192}>
+            <QRCode size={192} value={links.items[qrModal].shortLink} />
+          </RowCenter>
+        )}
+      </Modal>
+      <Modal
+        id="delete-custom-domain"
+        show={deleteModal > -1}
+        closeHandler={() => setDeleteModal(-1)}
+      >
+        {linkToDelete && (
+          <>
+            <Text as="h2" fontWeight={700} mb={24} textAlign="center">
+              Delete link?
+            </Text>
+            <Text as="p" textAlign="center">
+              Are you sure do you want to delete the link{" "}
+              <Text as="span" fontWeight={700}>
+                "{removeProtocol(linkToDelete.shortLink)}"
+              </Text>
+              ?
+            </Text>
+            <Flex justifyContent="center" mt={44}>
+              {deleteLoading ? (
+                <>
+                  <Icon name="spinner" size={20} stroke="#888" />
+                </>
+              ) : (
+                <>
+                  <Button
+                    color="gray"
+                    mr={3}
+                    onClick={() => setDeleteModal(-1)}
+                  >
+                    Cancel
+                  </Button>
+                  <Button color="blue" ml={3} onClick={onDelete}>
+                    <Icon name="trash" stroke="white" mr={2} />
+                    Delete
+                  </Button>
+                </>
+              )}
+            </Flex>
+          </>
+        )}
+      </Modal>
+    </Col>
+  );
+};
+
+export default LinksTable;

+ 11 - 6
client/components/Modal.tsx

@@ -1,8 +1,10 @@
-import React, { FC } from "react";
-import styled from "styled-components";
 import { Flex } from "reflexbox/styled-components";
 import { Flex } from "reflexbox/styled-components";
+import styled from "styled-components";
+import React, { FC } from "react";
+
+import Animation from "./Animation";
 
 
-interface Props {
+interface Props extends React.ComponentProps<typeof Flex> {
   show: boolean;
   show: boolean;
   id?: string;
   id?: string;
   closeHandler?: () => unknown;
   closeHandler?: () => unknown;
@@ -21,7 +23,7 @@ const Wrapper = styled.div`
   z-index: 1000;
   z-index: 1000;
 `;
 `;
 
 
-const Modal: FC<Props> = ({ children, id, show, closeHandler }) => {
+const Modal: FC<Props> = ({ children, id, show, closeHandler, ...rest }) => {
   if (!show) return null;
   if (!show) return null;
 
 
   const onClickOutside = e => {
   const onClickOutside = e => {
@@ -30,16 +32,19 @@ const Modal: FC<Props> = ({ children, id, show, closeHandler }) => {
 
 
   return (
   return (
     <Wrapper id={id} onClick={onClickOutside}>
     <Wrapper id={id} onClick={onClickOutside}>
-      <Flex
+      <Animation
+        offset="-20px"
+        duration="0.2s"
         minWidth={[400, 450]}
         minWidth={[400, 450]}
         maxWidth={0.9}
         maxWidth={0.9}
         py={[32, 32, 48]}
         py={[32, 32, 48]}
         px={[24, 24, 32]}
         px={[24, 24, 32]}
         style={{ borderRadius: 8, backgroundColor: "white" }}
         style={{ borderRadius: 8, backgroundColor: "white" }}
         flexDirection="column"
         flexDirection="column"
+        {...rest}
       >
       >
         {children}
         {children}
-      </Flex>
+      </Animation>
     </Wrapper>
     </Wrapper>
   );
   );
 };
 };

+ 12 - 12
client/components/NeedToLogin.tsx

@@ -1,17 +1,17 @@
-import React from 'react';
-import Link from 'next/link';
-import styled from 'styled-components';
-import { Flex } from 'reflexbox/styled-components';
+import React from "react";
+import Link from "next/link";
+import styled from "styled-components";
+import { Flex } from "reflexbox/styled-components";
 
 
-import Button from './Button';
-import { fadeIn } from '../helpers/animations';
+import { Button } from "./Button";
+import { fadeIn } from "../helpers/animations";
 
 
 const Wrapper = styled(Flex).attrs({
 const Wrapper = styled(Flex).attrs({
   width: 1200,
   width: 1200,
-  maxWidth: '98%',
-  alignItems: 'center',
-  margin: '16px 0 0',
-  flexDirection: ['column', 'column', 'row'],
+  maxWidth: "98%",
+  alignItems: "center",
+  margin: "16px 0 0",
+  flexDirection: ["column", "column", "row"]
 })`
 })`
   animation: ${fadeIn} 0.8s ease-out;
   animation: ${fadeIn} 0.8s ease-out;
   box-sizing: border-box;
   box-sizing: border-box;
@@ -58,14 +58,14 @@ const NeedToLogin = () => (
   <Wrapper>
   <Wrapper>
     <Flex
     <Flex
       flexDirection="column"
       flexDirection="column"
-      alignItems={['center', 'center', 'flex-start']}
+      alignItems={["center", "center", "flex-start"]}
       mt={-32}
       mt={-32}
       mb={[32, 32, 0]}
       mb={[32, 32, 0]}
     >
     >
       <Title>
       <Title>
         Manage links, set custom <b>domains</b> and view <b>stats</b>.
         Manage links, set custom <b>domains</b> and view <b>stats</b>.
       </Title>
       </Title>
-      <Link href="/login" prefetch>
+      <Link href="/login">
         <a href="/login" title="login / signup">
         <a href="/login" title="login / signup">
           <Button>Login / Signup</Button>
           <Button>Login / Signup</Button>
         </a>
         </a>

+ 4 - 12
client/components/PageLoading.tsx

@@ -1,15 +1,7 @@
-import React from 'react';
-import styled from 'styled-components';
-import { Flex } from 'reflexbox/styled-components';
+import { Flex } from "reflexbox/styled-components";
+import React from "react";
 
 
-import { spin } from '../helpers/animations';
-
-const Icon = styled.img`
-  display: block;
-  width: 28px;
-  height: 28px;
-  animation: ${spin} 1s linear infinite;
-`;
+import Icon from "./Icon";
 
 
 const pageLoading = () => (
 const pageLoading = () => (
   <Flex
   <Flex
@@ -19,7 +11,7 @@ const pageLoading = () => (
     justifyContent="center"
     justifyContent="center"
     margin="0 0 48px"
     margin="0 0 48px"
   >
   >
-    <Icon src="/images/loader.svg" />
+    <Icon name="spinner" size={24} stroke="#888" />
   </Flex>
   </Flex>
 );
 );
 
 

+ 2 - 2
client/components/Settings/SettingsApi.tsx

@@ -4,7 +4,7 @@ import React, { FC, useState } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 
 
 import { useStoreState, useStoreActions } from "../../store";
 import { useStoreState, useStoreActions } from "../../store";
-import Button from "../Button";
+import { Button } from "../Button";
 import ALink from "../ALink";
 import ALink from "../ALink";
 import Icon from "../Icon";
 import Icon from "../Icon";
 import Text from "../Text";
 import Text from "../Text";
@@ -85,7 +85,7 @@ const SettingsApi: FC = () => {
         </Flex>
         </Flex>
       )}
       )}
       <Button color="purple" onClick={onSubmit} disabled={loading}>
       <Button color="purple" onClick={onSubmit} disabled={loading}>
-        <Icon name={loading ? "spinner" : "zap"} mr={2} color="white" />
+        <Icon name={loading ? "spinner" : "zap"} mr={2} stroke="white" />
         {loading ? "Generating..." : apikey ? "Regenerate" : "Generate"} key
         {loading ? "Generating..." : apikey ? "Regenerate" : "Generate"} key
       </Button>
       </Button>
     </Flex>
     </Flex>

+ 7 - 2
client/components/Settings/SettingsBan.tsx

@@ -8,7 +8,7 @@ import { useMessage } from "../../hooks";
 import TextInput from "../TextInput";
 import TextInput from "../TextInput";
 import Checkbox from "../Checkbox";
 import Checkbox from "../Checkbox";
 import { API } from "../../consts";
 import { API } from "../../consts";
-import Button from "../Button";
+import { Button } from "../Button";
 import Icon from "../Icon";
 import Icon from "../Icon";
 import Text from "../Text";
 import Text from "../Text";
 
 
@@ -64,9 +64,14 @@ const SettingsBan: FC = () => {
             pl={24}
             pl={24}
             pr={24}
             pr={24}
             width={[1, 3 / 5]}
             width={[1, 3 / 5]}
+            required
           />
           />
           <Button type="submit" disabled={submitting}>
           <Button type="submit" disabled={submitting}>
-            <Icon name={submitting ? "spinner" : "lock"} color="white" mr={2} />
+            <Icon
+              name={submitting ? "spinner" : "lock"}
+              stroke="white"
+              mr={2}
+            />
             {submitting ? "Banning..." : "Ban"}
             {submitting ? "Banning..." : "Ban"}
           </Button>
           </Button>
         </Flex>
         </Flex>

+ 20 - 14
client/components/Settings/SettingsDomain.tsx

@@ -8,7 +8,7 @@ import { Domain } from "../../store/settings";
 import { useMessage } from "../../hooks";
 import { useMessage } from "../../hooks";
 import TextInput from "../TextInput";
 import TextInput from "../TextInput";
 import Table from "../CustomTable";
 import Table from "../CustomTable";
-import Button from "../Button";
+import { Button } from "../Button";
 import Modal from "../Modal";
 import Modal from "../Modal";
 import Icon from "../Icon";
 import Icon from "../Icon";
 import Text from "../Text";
 import Text from "../Text";
@@ -28,10 +28,10 @@ const SettingsDomain: FC = () => {
   const [message, setMessage] = useMessage(2000);
   const [message, setMessage] = useMessage(2000);
   const domains = useStoreState(s => s.settings.domains);
   const domains = useStoreState(s => s.settings.domains);
   const { saveDomain, deleteDomain } = useStoreActions(s => s.settings);
   const { saveDomain, deleteDomain } = useStoreActions(s => s.settings);
-  const [formState, { text }] = useFormState<{
+  const [formState, { label, text }] = useFormState<{
     customDomain: string;
     customDomain: string;
     homepage: string;
     homepage: string;
-  }>();
+  }>(null, { withIds: true });
 
 
   const onSubmit = async e => {
   const onSubmit = async e => {
     e.preventDefault();
     e.preventDefault();
@@ -94,10 +94,13 @@ const SettingsDomain: FC = () => {
                   <Icon
                   <Icon
                     as="button"
                     as="button"
                     name="trash"
                     name="trash"
-                    color="#f2392c"
+                    stroke="hsl(0, 100%, 69%)"
+                    strokeWidth="2.5"
+                    backgroundColor="hsl(0, 100%, 96%)"
                     py={0}
                     py={0}
-                    px="2px"
-                    size={15}
+                    px={0}
+                    size={[23, 24]}
+                    p={["4px", "5px"]}
                     onClick={() => {
                     onClick={() => {
                       setDomainToDelete(d);
                       setDomainToDelete(d);
                       setModal(true);
                       setModal(true);
@@ -119,7 +122,12 @@ const SettingsDomain: FC = () => {
         >
         >
           <Flex width={1}>
           <Flex width={1}>
             <Flex flexDirection="column" mr={2} flex="1 1 auto">
             <Flex flexDirection="column" mr={2} flex="1 1 auto">
-              <Text as="label" htmlFor="customdomain" fontWeight={700} mb={3}>
+              <Text
+                {...label("customDomain")}
+                as="label"
+                fontWeight={700}
+                mb={3}
+              >
                 Domain
                 Domain
               </Text>
               </Text>
               <TextInput
               <TextInput
@@ -132,12 +140,11 @@ const SettingsDomain: FC = () => {
               />
               />
             </Flex>
             </Flex>
             <Flex flexDirection="column" ml={2} flex="1 1 auto">
             <Flex flexDirection="column" ml={2} flex="1 1 auto">
-              <Text as="label" htmlFor="customdomain" fontWeight={700} mb={3}>
+              <Text {...label("homepage")} as="label" fontWeight={700} mb={3}>
                 Homepage (optional)
                 Homepage (optional)
               </Text>
               </Text>
               <TextInput
               <TextInput
                 {...text("homepage")}
                 {...text("homepage")}
-                type="text"
                 placeholder="Homepage URL"
                 placeholder="Homepage URL"
                 flex="1 1 auto"
                 flex="1 1 auto"
                 height={44}
                 height={44}
@@ -147,7 +154,7 @@ const SettingsDomain: FC = () => {
             </Flex>
             </Flex>
           </Flex>
           </Flex>
           <Button type="submit" color="purple" mt={3} disabled={loading}>
           <Button type="submit" color="purple" mt={3} disabled={loading}>
-            <Icon name={loading ? "spinner" : "plus"} mr={2} color="white" />
+            <Icon name={loading ? "spinner" : "plus"} mr={2} stroke="white" />
             {loading ? "Setting..." : "Set domain"}
             {loading ? "Setting..." : "Set domain"}
           </Button>
           </Button>
         </Flex>
         </Flex>
@@ -160,15 +167,14 @@ const SettingsDomain: FC = () => {
         <Text as="p" textAlign="center">
         <Text as="p" textAlign="center">
           Are you sure do you want to delete the domain{" "}
           Are you sure do you want to delete the domain{" "}
           <Text as="span" fontWeight={700}>
           <Text as="span" fontWeight={700}>
-            "{domainToDelete && domainToDelete.customDomain}""
+            "{domainToDelete && domainToDelete.customDomain}"
           </Text>
           </Text>
           ?
           ?
         </Text>
         </Text>
-        {/* FIXME: user a proper loading */}
         <Flex justifyContent="center" mt={44}>
         <Flex justifyContent="center" mt={44}>
           {deleteLoading ? (
           {deleteLoading ? (
             <>
             <>
-              <Icon name="spinner" size={20} />
+              <Icon name="spinner" size={20} stroke="#888" />
             </>
             </>
           ) : (
           ) : (
             <>
             <>
@@ -176,7 +182,7 @@ const SettingsDomain: FC = () => {
                 Cancel
                 Cancel
               </Button>
               </Button>
               <Button color="blue" ml={3} onClick={onDelete}>
               <Button color="blue" ml={3} onClick={onDelete}>
-                <Icon name="trash" color="white" mr={2} />
+                <Icon name="trash" stroke="white" mr={2} />
                 Delete
                 Delete
               </Button>
               </Button>
             </>
             </>

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

@@ -7,7 +7,7 @@ import { getAxiosConfig } from "../../utils";
 import { useMessage } from "../../hooks";
 import { useMessage } from "../../hooks";
 import TextInput from "../TextInput";
 import TextInput from "../TextInput";
 import { API } from "../../consts";
 import { API } from "../../consts";
-import Button from "../Button";
+import { Button } from "../Button";
 import Icon from "../Icon";
 import Icon from "../Icon";
 import Text from "../Text";
 import Text from "../Text";
 
 
@@ -64,7 +64,7 @@ const SettingsPassword: FC = () => {
           required
           required
         />
         />
         <Button type="submit" disabled={loading}>
         <Button type="submit" disabled={loading}>
-          <Icon name={loading ? "spinner" : "refresh"} mr={2} color="white" />
+          <Icon name={loading ? "spinner" : "refresh"} mr={2} stroke="white" />
           {loading ? "Updating..." : "Update"}
           {loading ? "Updating..." : "Update"}
         </Button>
         </Button>
       </Flex>
       </Flex>

+ 254 - 0
client/components/Shortener.tsx

@@ -0,0 +1,254 @@
+import { CopyToClipboard } from "react-copy-to-clipboard";
+import { Flex } from "reflexbox/styled-components";
+import React, { useState } from "react";
+import styled from "styled-components";
+import QRCode from "qrcode.react";
+
+import { useStoreActions, useStoreState } from "../store";
+import { Col, RowCenterH, RowCenter } from "./Layout";
+import { useFormState } from "react-use-form-state";
+import { removeProtocol } from "../utils";
+import { Link } from "../store/links";
+import { useMessage } from "../hooks";
+import TextInput from "./TextInput";
+import Animation from "./Animation";
+import Checkbox from "./Checkbox";
+import { Button } from "./Button";
+import Tooltip from "./Tooltip";
+import Modal from "./Modal";
+import Text from "./Text";
+import Icon from "./Icon";
+
+const SubmitIconWrapper = styled.div`
+  content: "";
+  position: absolute;
+  top: 0;
+  right: 12px;
+  width: 64px;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  cursor: pointer;
+
+  :hover svg {
+    fill: #673ab7;
+  }
+  @media only screen and (max-width: 448px) {
+    right: 8px;
+    width: 40px;
+  }
+`;
+
+const ShortenedLink = styled(Text)`
+  border-bottom: 2px dotted #aaa;
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  :hover {
+    opacity: 0.5;
+  }
+`;
+
+interface Form {
+  target: string;
+  customurl?: string;
+  password?: string;
+  showAdvanced?: boolean;
+}
+
+const Shortener = () => {
+  const { isAuthenticated } = useStoreState(s => s.auth);
+  const [domain] = useStoreState(s => s.settings.domains);
+  const submit = useStoreActions(s => s.links.submit);
+  const [link, setLink] = useState<Link | null>(null);
+  const [message, setMessage] = useMessage(3000);
+  const [loading, setLoading] = useState(false);
+  const [qrModal, setQRModal] = useState(false);
+  const [copied, setCopied] = useMessage(3000);
+  const [formState, { raw, password, text, label }] = useFormState<Form>(null, {
+    withIds: true,
+    onChange(e, stateValues, nextStateValues) {
+      if (stateValues.showAdvanced && !nextStateValues.showAdvanced) {
+        formState.clear();
+        formState.setField("target", stateValues.target);
+      }
+    }
+  });
+
+  const onSubmit = async e => {
+    e.preventDefault();
+    if (loading) return;
+    setCopied("");
+    setLoading(true);
+    try {
+      const link = await submit(formState.values);
+      setLink(link);
+      formState.clear();
+    } catch (err) {
+      setMessage(
+        err?.response?.data?.error || "Couldn't create the short link."
+      );
+    }
+    setLoading(false);
+  };
+
+  const title = !link && (
+    <Text as="h1" fontWeight={300}>
+      Kutt your links{" "}
+      <Text
+        as="span"
+        fontWeight={300}
+        style={{ borderBottom: "2px dotted #999" }}
+      >
+        shorter
+      </Text>
+      .
+    </Text>
+  );
+
+  const onCopy = () => setCopied("Copied to clipboard.", "green");
+
+  const result = link && (
+    <Animation
+      as={RowCenter}
+      offset="-20px"
+      duration="0.4s"
+      style={{ position: "relative" }}
+    >
+      <CopyToClipboard text={link.shortLink} onCopy={onCopy}>
+        <ShortenedLink as="h1" fontWeight={300} mr={3} mb={1} pb="2px">
+          {removeProtocol(link.shortLink)}
+        </ShortenedLink>
+      </CopyToClipboard>
+      <CopyToClipboard text={link.shortLink} onCopy={onCopy}>
+        <Button>
+          <Icon name="clipboard" stroke="white" mr={2} />
+          Copy
+        </Button>
+      </CopyToClipboard>
+      {copied && (
+        <Animation
+          as={Text}
+          offset="10px"
+          color={copied.color}
+          fontSize={15}
+          style={{ position: "absolute", left: 0, top: -24 }}
+        >
+          {copied.text}
+        </Animation>
+      )}
+    </Animation>
+  );
+
+  return (
+    <Col width={800} maxWidth="98%" flex="0 0 auto" mt={4}>
+      <RowCenterH mb={40}>
+        {title}
+        {result}
+      </RowCenterH>
+      <Flex
+        as="form"
+        id="shortenerform"
+        width={800}
+        maxWidth="100%"
+        alignItems="center"
+        justifyContent="center"
+        style={{ position: "relative" }}
+        onSubmit={onSubmit}
+      >
+        <TextInput
+          {...text("target")}
+          placeholder="Paste your long URL"
+          placeholderSize={[16, 18]}
+          fontSize={[20, 22]}
+          width={1}
+          height={[72]}
+          autoFocus
+        />
+        <SubmitIconWrapper onClick={onSubmit}>
+          <Icon
+            name={loading ? "spinner" : "send"}
+            size={28}
+            fill={loading ? "none" : "#aaa"}
+            stroke={loading ? "#888" : "none"}
+            mb={1}
+            mr={1}
+          />
+        </SubmitIconWrapper>
+      </Flex>
+      {message.text && (
+        <Text color={message.color} mt={24} mb={1} textAlign="center">
+          {message.text}
+        </Text>
+      )}
+      <Checkbox
+        {...raw({
+          name: "showAdvanced",
+          onChange: e => {
+            if (!isAuthenticated) {
+              setMessage(
+                "You need to log in or sign up to use advanced options."
+              );
+              return false;
+            }
+            return !formState.values.showAdvanced;
+          }
+        })}
+        checked={formState.values.showAdvanced}
+        label="Show advanced options"
+        mt={24}
+      />
+      {formState.values.showAdvanced && (
+        <Flex mt={4}>
+          <Col>
+            <Text
+              as="label"
+              {...label("customurl")}
+              fontWeight={700}
+              fontSize={15}
+              mb={2}
+            >
+              {(domain || {}).customDomain ||
+                (typeof window !== "undefined" && window.location.hostname)}
+              /
+            </Text>
+            <TextInput
+              {...text("customurl")}
+              placeholder="Custom address"
+              pl={24}
+              pr={24}
+              placeholderSize={[13, 14, 14, 14]}
+              fontSize={[14, 15]}
+              height={48}
+              width={240}
+            />
+          </Col>
+          <Col ml={4}>
+            <Text
+              as="label"
+              {...label("password")}
+              fontWeight={700}
+              fontSize={15}
+              mb={2}
+            >
+              Password:
+            </Text>
+            <TextInput
+              {...password("password")}
+              placeholder="Password"
+              pl={24}
+              pr={24}
+              placeholderSize={[13, 14, 14, 14]}
+              fontSize={[14, 15]}
+              height={48}
+              width={240}
+            />
+          </Col>
+        </Flex>
+      )}
+    </Col>
+  );
+};
+
+export default Shortener;

+ 0 - 176
client/components/Shortener/Shortener.tsx

@@ -1,176 +0,0 @@
-import React, { 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 ShortenerResult from './ShortenerResult';
-import ShortenerTitle from './ShortenerTitle';
-import ShortenerInput from './ShortenerInput';
-import {
-  createShortUrl,
-  setShortenerFormError,
-  showShortenerLoading,
-} from '../../actions';
-import { fadeIn } from '../../helpers/animations';
-
-// TODO: types
-interface Props {
-  isAuthenticated: boolean;
-  domain: string;
-  createShortUrl: any;
-  shortenerError: string;
-  shortenerLoading: boolean;
-  setShortenerFormError: any;
-  showShortenerLoading: any;
-  url: {
-    isShortened: boolean;
-    list: any[];
-  };
-}
-
-const Wrapper = styled(Flex).attrs({
-  width: 800,
-  maxWidth: '98%',
-  flex: '0 0 auto',
-  flexDirection: 'column',
-  mt: 16,
-  mb: 40,
-})`
-  position: relative;
-  padding-bottom: 125px;
-  animation: ${fadeIn} 0.8s ease-out;
-
-  @media only screen and (max-width: 800px) {
-    padding: 0 8px 96px;
-  }
-`;
-
-const ResultWrapper = styled(Flex).attrs({
-  justifyContent: 'center',
-  alignItems: 'flex-start',
-})`
-  position: relative;
-  height: 96px;
-
-  @media only screen and (max-width: 448px) {
-    height: 72px;
-  }
-`;
-
-const Shortener: FC<Props> = props => {
-  const [copied, setCopied] = useState(false);
-
-  const copyHandler = () => {
-    setCopied(true);
-    setTimeout(() => setCopied(false), 1500);
-  };
-
-  const handleSubmit = e => {
-    e.preventDefault();
-    const { isAuthenticated } = props;
-    props.showShortenerLoading();
-    const shortenerForm: any = document.getElementById('shortenerform');
-    const {
-      target: originalUrl,
-      customurl: customurlInput,
-      password: pwd,
-    } = shortenerForm.elements; // FIXME: types
-    const target = originalUrl.value.trim();
-    const customurl = customurlInput && customurlInput.value.trim();
-    const password = pwd && pwd.value;
-    const options = isAuthenticated && { customurl, password };
-    shortenerForm.reset();
-    if (process.env.NODE_ENV === 'production' && !isAuthenticated) {
-      // FIXME: types bro
-      (window as any).grecaptcha.execute((window as any).captchaId);
-      const getCaptchaToken = () => {
-        setTimeout(() => {
-          if ((window as any).isCaptchaReady) {
-            const reCaptchaToken = (window as any).grecaptcha.getResponse(
-              (window as any).captchaId
-            );
-            (window as any).isCaptchaReady = false;
-            (window as any).grecaptcha.reset((window as any).captchaId);
-            return props.createShortUrl({
-              target,
-              reCaptchaToken,
-              ...options,
-            });
-          }
-          return getCaptchaToken();
-        }, 200);
-      };
-      return getCaptchaToken();
-    }
-    return props.createShortUrl({ target, ...options });
-  };
-
-  return (
-    <Wrapper>
-      <ResultWrapper>
-        {!props.shortenerError &&
-        (props.url.isShortened || props.shortenerLoading) ? (
-          <ShortenerResult
-            copyHandler={copyHandler}
-            loading={props.shortenerLoading}
-            url={props.url}
-            isCopied={copied}
-          />
-        ) : (
-          <ShortenerTitle />
-        )}
-      </ResultWrapper>
-      <ShortenerInput
-        isAuthenticated={props.isAuthenticated}
-        handleSubmit={handleSubmit}
-        setShortenerFormError={props.setShortenerFormError}
-        domain={props.domain}
-      />
-    </Wrapper>
-  );
-};
-
-// TODO: check if needed
-// shouldComponentUpdate(nextProps, nextState) {
-//   const {
-//     isAuthenticated,
-//     domain,
-//     shortenerError,
-//     shortenerLoading,
-//     url: { isShortened },
-//   } = props;
-//   return (
-//     isAuthenticated !== nextProps.isAuthenticated ||
-//     shortenerError !== nextProps.shortenerError ||
-//     isShortened !== nextProps.url.isShortened ||
-//     shortenerLoading !== nextProps.shortenerLoading ||
-//     domain !== nextProps.domain ||
-//     state.isCopied !== nextState.isCopied
-//   );
-// }
-
-const mapStateToProps = ({
-  auth: { isAuthenticated },
-  error: { shortener: shortenerError },
-  loading: { shortener: shortenerLoading },
-  settings: { customDomain: domain },
-  url,
-}) => ({
-  isAuthenticated,
-  domain,
-  shortenerError,
-  shortenerLoading,
-  url,
-});
-
-const mapDispatchToProps = dispatch => ({
-  createShortUrl: bindActionCreators(createShortUrl, dispatch),
-  setShortenerFormError: bindActionCreators(setShortenerFormError, dispatch),
-  showShortenerLoading: bindActionCreators(showShortenerLoading, dispatch),
-});
-
-export default connect(
-  mapStateToProps,
-  mapDispatchToProps
-)(Shortener);

+ 0 - 88
client/components/Shortener/ShortenerInput.tsx

@@ -1,88 +0,0 @@
-import React, { FC } from 'react';
-import styled from 'styled-components';
-import SVG from 'react-inlinesvg';
-
-import ShortenerOptions from './ShortenerOptions';
-import TextInput from '../TextInput';
-import Error from '../Error';
-
-// TODO: types
-interface Props {
-  handleSubmit: any;
-  isAuthenticated: boolean;
-  domain: string;
-  setShortenerFormError: any;
-}
-
-const ShortenerForm = styled.form`
-  position: relative;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  width: 800px;
-  max-width: 100%;
-`;
-
-const Submit = styled.div`
-  content: '';
-  position: absolute;
-  top: 0;
-  right: 12px;
-  width: 64px;
-  height: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  cursor: pointer;
-
-  :hover svg {
-    fill: #673ab7;
-  }
-  @media only screen and (max-width: 448px) {
-    right: 8px;
-    width: 40px;
-  }
-`;
-
-const Icon = styled(SVG)`
-  svg {
-    width: 28px;
-    height: 28px;
-    margin-right: 8px;
-    margin-top: 2px;
-    fill: #aaa;
-    transition: all 0.2s ease-out;
-
-    @media only screen and (max-width: 448px) {
-      height: 22px;
-      width: 22px;
-    }
-  }
-`;
-
-const ShortenerInput: FC<Props> = ({
-  isAuthenticated,
-  domain,
-  handleSubmit,
-  setShortenerFormError,
-}) => (
-  <ShortenerForm id="shortenerform" onSubmit={handleSubmit}>
-    <TextInput
-      id="target"
-      name="target"
-      placeholder="Paste your long URL"
-      autoFocus
-    />
-    <Submit onClick={handleSubmit}>
-      <Icon src="/images/send.svg" />
-    </Submit>
-    <Error type="shortener" />
-    <ShortenerOptions
-      isAuthenticated={isAuthenticated}
-      setShortenerFormError={setShortenerFormError}
-      domain={domain}
-    />
-  </ShortenerForm>
-);
-
-export default ShortenerInput;

+ 0 - 128
client/components/Shortener/ShortenerOptions.tsx

@@ -1,128 +0,0 @@
-import React, { FC, useState } from "react";
-import styled from "styled-components";
-import { Flex } from "reflexbox/styled-components";
-
-// import Checkbox from "../Checkbox";
-import TextInput from "../TextInput";
-import { fadeIn } from "../../helpers/animations";
-
-interface Props {
-  isAuthenticated: boolean;
-  domain: string;
-  setShortenerFormError: any; // TODO: types
-}
-
-const Wrapper = styled(Flex).attrs({
-  flexDirection: "column",
-  alignSelf: "flex-start",
-  justifyContent: "flex-start"
-})`
-  position: absolute;
-  top: 74px;
-  left: 0;
-  z-index: 2;
-
-  @media only screen and (max-width: 448px) {
-    width: 100%;
-    top: 56px;
-  }
-`;
-
-const InputWrapper = styled(Flex).attrs({
-  flexDirection: ["column", "row"],
-  alignItems: ["flex-start", "center"]
-})`
-  @media only screen and (max-width: 448px) {
-    > * {
-      margin-bottom: 16px;
-    }
-  }
-`;
-
-const Label = styled.label`
-  font-size: 18px;
-  margin-right: 16px;
-  animation: ${fadeIn} 0.5s ease-out;
-
-  @media only screen and (max-width: 448px) {
-    font-size: 14px;
-    margin-right: 8px;
-  }
-`;
-
-const ShortenerOptions: FC<Props> = props => {
-  const [customurl, setCustomurl] = useState();
-  const [password, setPassword] = useState();
-
-  const checkAuth = () => {
-    if (!props.isAuthenticated) {
-      props.setShortenerFormError(
-        "Please login or sign up to use this feature."
-      );
-      return false;
-    }
-    return true;
-  };
-
-  const handleCustomUrl = e => {
-    if (!checkAuth()) return;
-    setCustomurl(e.target.value);
-  };
-
-  const handlePassword = e => {
-    if (!checkAuth()) return;
-    setPassword(e.target.value);
-  };
-
-  const customUrlInput = customurl && (
-    <div>
-      <Label htmlFor="customurl">
-        {props.domain || window.location.hostname}/
-      </Label>
-      <TextInput id="customurl" type="text" placeholder="custom name" small />
-    </div>
-  );
-  const passwordInput = password && (
-    <div>
-      <Label htmlFor="customurl">password:</Label>
-      <TextInput id="password" type="password" placeholder="password" small />
-    </div>
-  );
-  return (
-    <Wrapper>
-      <Flex justifyContent={["center", "flex-start"]}>
-        {/* <Checkbox
-          id="customurlCheckbox"
-          name="customurlCheckbox"
-          label="Set custom URL"
-          checked={customurl}
-          onClick={handleCustomUrl}
-        />
-        <Checkbox
-          id="passwordCheckbox"
-          name="passwordCheckbox"
-          label="Set password"
-          checked={password}
-          onClick={handlePassword}
-        /> */}
-      </Flex>
-      <InputWrapper>
-        {customUrlInput}
-        {passwordInput}
-      </InputWrapper>
-    </Wrapper>
-  );
-};
-
-// TODO: see if needed
-// shouldComponentUpdate(nextProps, nextState) {
-//   const { customurlCheckbox, passwordCheckbox } = state;
-//   return (
-//     props.isAuthenticated !== nextProps.isAuthenticated ||
-//     customurlCheckbox !== nextState.customurlCheckbox ||
-//     props.domain !== nextProps.domain ||
-//     passwordCheckbox !== nextState.passwordCheckbox
-//   );
-// }
-
-export default ShortenerOptions;

+ 0 - 120
client/components/Shortener/ShortenerResult.tsx

@@ -1,120 +0,0 @@
-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 {
-  copyHandler: any;
-  isCopied: boolean;
-  loading: boolean;
-  url: {
-    list: any[];
-  };
-}
-
-const Wrapper = styled(Flex).attrs({
-  justifyContent: "center",
-  alignItems: "center"
-})`
-  position: relative;
-
-  button {
-    margin-left: 24px;
-  }
-`;
-
-const Url = styled.h2`
-  margin: 8px 0;
-  font-size: 32px;
-  font-weight: 300;
-  border-bottom: 2px dotted #aaa;
-  cursor: pointer;
-  transition: all 0.2s ease;
-
-  :hover {
-    opacity: 0.5;
-  }
-
-  @media only screen and (max-width: 448px) {
-    font-size: 24px;
-  }
-`;
-
-const CopyMessage = styled.p`
-  position: absolute;
-  top: -32px;
-  left: 0;
-  font-size: 14px;
-  color: #689f38;
-  animation: ${fadeIn} 0.3s ease-out;
-`;
-
-const QRButton = styled(TBodyButton)`
-  width: 36px;
-  height: 36px;
-  margin-left: 12px !important;
-  box-shadow: 0 4px 10px rgba(100, 100, 100, 0.2);
-
-  :hover {
-    box-shadow: 0 4px 10px rgba(100, 100, 100, 0.3);
-  }
-
-  @media only screen and (max-width: 768px) {
-    height: 32px;
-    width: 32px;
-
-    img {
-      width: 14px;
-      height: 14px;
-    }
-  }
-`;
-
-const Icon = styled.img`
-  width: 16px;
-  height: 16px;
-`;
-
-const ShortenerResult: FC<Props> = ({
-  copyHandler,
-  isCopied,
-  loading,
-  url
-}) => {
-  const [qrModal, setQrModal] = useState(false);
-  const toggleQrCodeModal = () => setQrModal(current => !current);
-
-  const showQrCode = window.innerWidth > 420;
-
-  if (loading) return <Loading />;
-
-  return (
-    <Wrapper>
-      {isCopied && <CopyMessage>Copied to clipboard.</CopyMessage>}
-      <CopyToClipboard text={url.list[0].shortLink} onCopy={copyHandler}>
-        <Url>{url.list[0].shortLink.replace(/^https?:\/\//, "")}</Url>
-      </CopyToClipboard>
-      <CopyToClipboard text={url.list[0].shortLink} onCopy={copyHandler}>
-        <Button icon="copy">Copy</Button>
-      </CopyToClipboard>
-      {showQrCode && (
-        <QRButton onClick={toggleQrCodeModal}>
-          <Icon src="/images/qrcode.svg" />
-        </QRButton>
-      )}
-      <Modal show={qrModal}>
-        <QRCode value={url.list[0].shortLink} size={196} />
-      </Modal>
-    </Wrapper>
-  );
-};
-
-export default ShortenerResult;

+ 0 - 25
client/components/Shortener/ShortenerTitle.tsx

@@ -1,25 +0,0 @@
-import React from 'react';
-import styled from 'styled-components';
-
-const Title = styled.h1`
-  font-size: 32px;
-  font-weight: 300;
-  margin: 8px 0 0;
-  color: #333;
-
-  @media only screen and (max-width: 448px) {
-    font-size: 22px;
-  }
-`;
-
-const Underline = styled.span`
-  border-bottom: 2px dotted #999;
-`;
-
-const ShortenerTitle = () => (
-  <Title>
-    Kutt your links <Underline>shorter</Underline>.
-  </Title>
-);
-
-export default ShortenerTitle;

+ 0 - 1
client/components/Shortener/index.tsx

@@ -1 +0,0 @@
-export { default } from './Shortener';

+ 2 - 5
client/components/Stats/Stats.tsx

@@ -11,7 +11,7 @@ import StatsError from "./StatsError";
 import StatsHead from "./StatsHead";
 import StatsHead from "./StatsHead";
 import StatsCharts from "./StatsCharts";
 import StatsCharts from "./StatsCharts";
 import PageLoading from "../PageLoading";
 import PageLoading from "../PageLoading";
-import Button from "../Button";
+import { Button } from "../Button";
 
 
 interface Props {
 interface Props {
   isAuthenticated: boolean;
   isAuthenticated: boolean;
@@ -143,7 +143,4 @@ const mapStateToProps = ({ auth: { isAuthenticated } }) => ({
   isAuthenticated
   isAuthenticated
 });
 });
 
 
-export default connect(
-  mapStateToProps,
-  null
-)(Stats);
+export default connect(mapStateToProps, null)(Stats);

+ 12 - 12
client/components/Stats/StatsHead.tsx

@@ -1,7 +1,7 @@
-import React, { FC } from 'react';
-import styled, { css } from 'styled-components';
-import { ifProp } from 'styled-tools';
-import { Flex } from 'reflexbox/styled-components';
+import React, { FC } from "react";
+import styled, { css } from "styled-components";
+import { ifProp } from "styled-tools";
+import { Flex } from "reflexbox/styled-components";
 
 
 interface Props {
 interface Props {
   changePeriod: any; // TODO: types
   changePeriod: any; // TODO: types
@@ -10,11 +10,11 @@ interface Props {
 }
 }
 
 
 const Wrapper = styled(Flex).attrs({
 const Wrapper = styled(Flex).attrs({
-  flex: '1 1 auto',
-  justifyContent: 'center',
-  alignItems: 'center',
+  flex: "1 1 auto",
+  justifyContent: "center",
+  alignItems: "center",
   py: [16, 16, 25],
   py: [16, 16, 25],
-  px: 32,
+  px: 32
 })`
 })`
   background-color: #f1f1f1;
   background-color: #f1f1f1;
   border-top-left-radius: 12px;
   border-top-left-radius: 12px;
@@ -89,10 +89,10 @@ const StatsHead: FC<Props> = ({ changePeriod, period, total }) => {
         Total clicks: <span>{total}</span>
         Total clicks: <span>{total}</span>
       </TotalText>
       </TotalText>
       <Flex>
       <Flex>
-        {buttonWithPeriod('allTime', 'All Time')}
-        {buttonWithPeriod('lastMonth', 'Month')}
-        {buttonWithPeriod('lastWeek', 'Week')}
-        {buttonWithPeriod('lastDay', 'Day')}
+        {buttonWithPeriod("allTime", "All Time")}
+        {buttonWithPeriod("lastMonth", "Month")}
+        {buttonWithPeriod("lastWeek", "Week")}
+        {buttonWithPeriod("lastDay", "Day")}
       </Flex>
       </Flex>
     </Wrapper>
     </Wrapper>
   );
   );

+ 0 - 143
client/components/Table.tsx

@@ -1,143 +0,0 @@
-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
-  url: {
-    list: any[]; // TODO: types
-  };
-}
-
-const Title = styled.h2`
-  font-size: 24px;
-  font-weight: 300;
-
-  @media only screen and (max-width: 768px) {
-    font-size: 18px;
-  }
-`;
-
-const TableWrapper = styled.table`
-  display: flex;
-  flex: 1 1 auto;
-  flex-direction: column;
-  background-color: white;
-  border-radius: 12px;
-  box-shadow: 0 6px 30px rgba(50, 50, 50, 0.2);
-
-  tr {
-    display: flex;
-    flex: 1 1 auto;
-    padding: 0 24px;
-    justify-content: space-between;
-    border-bottom: 1px solid #eaeaea;
-  }
-
-  th,
-  td {
-    position: relative;
-    display: flex;
-    padding: 16px 0;
-    align-items: center;
-  }
-
-  @media only screen and (max-width: 768px) {
-    font-size: 13px;
-  }
-
-  @media only screen and (max-width: 510px) {
-    tr {
-      padding: 0 16px;
-    }
-    th,
-    td {
-      padding: 12px 0;
-    }
-  }
-`;
-
-const TFoot = styled.tfoot`
-  background-color: #f1f1f1;
-  border-bottom-right-radius: 12px;
-  border-bottom-left-radius: 12px;
-`;
-
-const defualtModal = {
-  id: "",
-  domain: "",
-  show: false
-};
-
-const Table: FC<Props> = ({ deleteShortUrl, url }) => {
-  const [copiedIndex, setCopiedIndex] = useState(-1);
-  const [modal, setModal] = useState(defualtModal);
-
-  function handleCopy(index) {
-    setCopiedIndex(index);
-    setTimeout(() => {
-      setCopiedIndex(-1);
-    }, 1500);
-  }
-
-  function showModal(url) {
-    return e => {
-      e.preventDefault();
-      setModal({
-        id: url.address,
-        domain: url.domain,
-        show: true
-      });
-    };
-  }
-
-  const closeModal = () => setModal(defualtModal);
-
-  function deleteUrl() {
-    closeModal();
-    deleteShortUrl({ id: modal.id, domain: modal.domain });
-  }
-
-  return (
-    <Flex
-      width={1200}
-      maxWidth="95%"
-      flexDirection="column"
-      margin="40px 0 120px"
-    >
-      <Title>Recent shortened links.</Title>
-      <TableWrapper>
-        <THead />
-        <TBody
-          copiedIndex={copiedIndex}
-          handleCopy={handleCopy}
-          urls={url.list}
-          showModal={showModal}
-        />
-        <TFoot>
-          <TableOptions nosearch />
-        </TFoot>
-      </TableWrapper>
-      <Modal show={modal.show}>
-        Are you sure do you want to delete the short URL and its stats?
-      </Modal>
-    </Flex>
-  );
-};
-
-const mapStateToProps = ({ url }) => ({ url });
-
-const mapDispatchToProps = dispatch => ({
-  deleteShortUrl: bindActionCreators(deleteShortUrl, dispatch),
-  getUrlsList: bindActionCreators(getUrlsList, dispatch)
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(Table);

+ 15 - 15
client/components/Table/THead/THead.tsx

@@ -1,13 +1,13 @@
-import React, { FC } from 'react';
-import styled, { css } from 'styled-components';
-import { Flex } from 'reflexbox';
+import React, { FC } from "react";
+import styled, { css } from "styled-components";
+import { Flex } from "reflexbox";
 
 
-import TableOptions from '../../TableOptions';
+import TableOptions from "../../TableOptions";
 
 
 const THead = styled(Flex).attrs({
 const THead = styled(Flex).attrs({
-  as: 'thead',
-  flexDirection: 'column',
-  flex: '1 1 auto',
+  as: "thead",
+  flexDirection: "column",
+  flex: "1 1 auto"
 })`
 })`
   background-color: #f1f1f1;
   background-color: #f1f1f1;
   border-top-right-radius: 12px;
   border-top-right-radius: 12px;
@@ -19,10 +19,10 @@ const THead = styled(Flex).attrs({
 `;
 `;
 
 
 const Th = styled(Flex).attrs({
 const Th = styled(Flex).attrs({
-  as: 'th',
-  justifyContent: 'start',
-  alignItems: 'center',
-  flexBasis: 0,
+  as: "th",
+  justifyContent: "start",
+  alignItems: "center",
+  flexBasis: 0
 })`
 })`
   @media only screen and (max-width: 768px) {
   @media only screen and (max-width: 768px) {
     flex: 1;
     flex: 1;
@@ -42,10 +42,10 @@ const TableHead: FC = () => (
   <THead>
   <THead>
     <TableOptions />
     <TableOptions />
     <tr>
     <tr>
-      <Th flex="2 2 0">Original URL</Th>
-      <Th flex="1 1 0">Created</Th>
-      <Th flex="1 1 0">Short URL</Th>
-      <Th flex="1 1 0">Clicks</Th>
+      <Th>Original URL</Th>
+      <Th>Created</Th>
+      <Th>Short URL</Th>
+      <Th>Clicks</Th>
     </tr>
     </tr>
   </THead>
   </THead>
 );
 );

+ 2 - 1
client/components/Text.tsx

@@ -24,7 +24,8 @@ const Text = styled(Box)<Props>`
 
 
 Text.defaultProps = {
 Text.defaultProps = {
   as: "p",
   as: "p",
-  fontWeight: 400
+  fontWeight: 400,
+  color: "hsl(200, 35%, 25%)"
 };
 };
 
 
 export default Text;
 export default Text;

+ 11 - 68
client/components/TextInput.tsx

@@ -1,10 +1,10 @@
-import styled, { css } from "styled-components";
-import { ifProp, withProp } from "styled-tools";
-import { Flex } from "reflexbox/styled-components";
+import styled from "styled-components";
+import { withProp, prop } from "styled-tools";
+import { Flex, BoxProps } from "reflexbox/styled-components";
 
 
 import { fadeIn } from "../helpers/animations";
 import { fadeIn } from "../helpers/animations";
 
 
-interface Props {
+interface Props extends BoxProps {
   autoFocus?: boolean;
   autoFocus?: boolean;
   name?: string;
   name?: string;
   id?: string;
   id?: string;
@@ -15,6 +15,8 @@ interface Props {
   onChange?: any;
   onChange?: any;
   tiny?: boolean;
   tiny?: boolean;
   placeholderSize?: number[];
   placeholderSize?: number[];
+  br?: string;
+  bbw?: string;
 }
 }
 
 
 const TextInput = styled(Flex).attrs({
 const TextInput = styled(Flex).attrs({
@@ -25,16 +27,17 @@ const TextInput = styled(Flex).attrs({
   letter-spacing: 0.05em;
   letter-spacing: 0.05em;
   color: #444;
   color: #444;
   background-color: white;
   background-color: white;
-  box-shadow: 0 10px 35px rgba(50, 50, 50, 0.1);
-  border-radius: 100px;
+  box-shadow: 0 10px 35px hsla(200, 15%, 70%, 0.2);
   border: none;
   border: none;
+  border-radius: ${prop("br", "100px")};
   border-bottom: 5px solid #f5f5f5;
   border-bottom: 5px solid #f5f5f5;
+  border-bottom-width: ${prop("bbw", "5px")};
   animation: ${fadeIn} 0.5s ease-out;
   animation: ${fadeIn} 0.5s ease-out;
   transition: all 0.5s ease-out;
   transition: all 0.5s ease-out;
 
 
   :focus {
   :focus {
     outline: none;
     outline: none;
-    box-shadow: 0 20px 35px rgba(50, 50, 50, 0.2);
+    box-shadow: 0 20px 35px hsla(200, 15%, 70%, 0.4);
   }
   }
 
 
   ::placeholder {
   ::placeholder {
@@ -51,7 +54,7 @@ const TextInput = styled(Flex).attrs({
 
 
   @media screen and (min-width: 52em) {
   @media screen and (min-width: 52em) {
     letter-spacing: 0.1em;
     letter-spacing: 0.1em;
-    border-bottom-width: 6px;
+    border-bottom-width: ${prop("bbw", "6px")};
     ::placeholder {
     ::placeholder {
       font-size: ${withProp("placeholderSize", s => s[2] || 15)}px;
       font-size: ${withProp("placeholderSize", s => s[2] || 15)}px;
     }
     }
@@ -62,66 +65,6 @@ const TextInput = styled(Flex).attrs({
       font-size: ${withProp("placeholderSize", s => s[1] || 15)}px;
       font-size: ${withProp("placeholderSize", s => s[1] || 15)}px;
     }
     }
   }
   }
-
-  /* ${ifProp(
-    "small",
-    css`
-      width: 240px;
-      height: 54px;
-      margin-right: 32px;
-      padding: 0 24px 2px;
-      font-size: 18px;
-      border-bottom: 4px solid #f5f5f5;
-      ::placeholder {
-        font-size: 13px;
-      }
-
-      @media only screen and (max-width: 448px) {
-        width: 200px;
-        height: 40px;
-        padding: 0 16px 2px;
-        font-size: 13px;
-        border-bottom-width: 3px;
-      }
-    `
-  )}
-
-  ${ifProp(
-    "tiny",
-    css`
-      flex: 0 0 auto;
-      width: 280px;
-      height: 32px;
-      margin: 0;
-      padding: 0 16px 1px;
-      font-size: 13px;
-      border-bottom-width: 1px;
-      border-radius: 4px;
-      box-shadow: 0 4px 10px rgba(100, 100, 100, 0.1);
-
-      :focus {
-        box-shadow: 0 10px 25px rgba(50, 50, 50, 0.1);
-      }
-
-      ::placeholder {
-        font-size: 12px;
-        letter-spacing: 0;
-      }
-
-      @media only screen and (max-width: 768px) {
-        width: 240px;
-        height: 28px;
-      }
-
-      @media only screen and (max-width: 510px) {
-        width: 180px;
-        height: 24px;
-        padding: 0 8px 1px;
-        font-size: 12px;
-        border-bottom-width: 3px;
-      }
-    `
-  )} */
 `;
 `;
 
 
 TextInput.defaultProps = {
 TextInput.defaultProps = {

+ 2 - 2
client/components/Tooltip.tsx

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

+ 4 - 1
client/consts/consts.ts

@@ -7,5 +7,8 @@ export enum API {
   BAN_LINK = "/api/url/admin/ban",
   BAN_LINK = "/api/url/admin/ban",
   CUSTOM_DOMAIN = "/api/url/customdomain",
   CUSTOM_DOMAIN = "/api/url/customdomain",
   GENERATE_APIKEY = "/api/auth/generateapikey",
   GENERATE_APIKEY = "/api/auth/generateapikey",
-  SETTINGS = "/api/auth/usersettings"
+  SETTINGS = "/api/auth/usersettings",
+  SUBMIT = "/api/url/submit",
+  GET_LINKS = "/api/url/geturls",
+  DELETE_LINK = "/api/url/deleteurl"
 }
 }

+ 0 - 19
client/helpers/animations.js

@@ -1,19 +0,0 @@
-import { keyframes } from 'styled-components';
-
-export const fadeIn = keyframes`
-  from {
-    opacity: 0;
-  }
-  to {
-    opacity: 1;
-  }
-`;
-
-export const spin = keyframes`
-  from {
-    transform: rotate(0);
-  }
-  to {
-    transform: rotate(-360deg);
-  }
-`;

+ 30 - 0
client/helpers/animations.ts

@@ -0,0 +1,30 @@
+import { keyframes } from "styled-components";
+
+export const fadeInVertical = vertical => keyframes`
+  from {
+    opacity: 0;
+    transform: translateY(${vertical});
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+`;
+
+export const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+export const spin = keyframes`
+  from {
+    transform: rotate(0);
+  }
+  to {
+    transform: rotate(-360deg);
+  }
+`;

+ 4 - 2
client/hooks.ts

@@ -1,13 +1,15 @@
 import { useState } from "react";
 import { useState } from "react";
 
 
+const initialMessage = { color: "red", text: "" };
+
 export const useMessage = (timeout?: number) => {
 export const useMessage = (timeout?: number) => {
-  const [message, set] = useState({ color: "red", text: "" });
+  const [message, set] = useState(initialMessage);
 
 
   const setMessage = (text = "", color = "red") => {
   const setMessage = (text = "", color = "red") => {
     set({ text, color });
     set({ text, color });
 
 
     if (timeout) {
     if (timeout) {
-      setTimeout(setMessage, timeout);
+      setTimeout(() => set(initialMessage), timeout);
     }
     }
   };
   };
 
 

+ 10 - 0
client/next.config.js

@@ -0,0 +1,10 @@
+const { parsed: localEnv } = require("dotenv").config();
+const webpack = require("webpack");
+
+module.exports = {
+  webpack(config) {
+    config.plugins.push(new webpack.EnvironmentPlugin(localEnv));
+
+    return config;
+  }
+};

+ 7 - 7
client/pages/_document.tsx

@@ -1,6 +1,6 @@
-import React from 'react';
-import Document, { Head, Main, NextScript } from 'next/document';
-import { ServerStyleSheet } from 'styled-components';
+import React from "react";
+import Document, { Head, Main, NextScript } from "next/document";
+import { ServerStyleSheet } from "styled-components";
 
 
 interface Props {
 interface Props {
   styleTags: any;
   styleTags: any;
@@ -65,7 +65,7 @@ class AppDocument extends Document<Props> {
 
 
           <script
           <script
             dangerouslySetInnerHTML={{
             dangerouslySetInnerHTML={{
-              __html: `window.recaptchaCallback = function() { window.isCaptchaReady = true; }`,
+              __html: `window.recaptchaCallback = function() { window.isCaptchaReady = true; }`
             }}
             }}
           />
           />
 
 
@@ -79,10 +79,10 @@ class AppDocument extends Document<Props> {
         <body
         <body
           style={{
           style={{
             margin: 0,
             margin: 0,
-            backgroundColor: '#f3f3f3',
+            backgroundColor: "hsl(206, 12%, 95%)",
             font: '16px/1.45 "Nunito", sans-serif',
             font: '16px/1.45 "Nunito", sans-serif',
-            overflowX: 'hidden',
-            color: 'black',
+            overflowX: "hidden",
+            color: "hsl(200, 35%, 25%)"
           }}
           }}
         >
         >
           <Main />
           <Main />

+ 0 - 62
client/pages/index.js

@@ -1,62 +0,0 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import { bindActionCreators } from 'redux';
-import BodyWrapper from '../components/BodyWrapper';
-import Shortener from '../components/Shortener';
-import Features from '../components/Features';
-import Extensions from '../components/Extensions';
-import Table from '../components/Table';
-import NeedToLogin from '../components/NeedToLogin';
-import Footer from '../components/Footer';
-import { authUser, getUrlsList } from '../actions';
-
-class Homepage extends Component {
-  static getInitialProps({ req, reduxStore }) {
-    const token = req && req.cookies && req.cookies.token;
-    if (token && reduxStore) reduxStore.dispatch(authUser(token));
-    return {};
-  }
-
-  componentDidMount() {
-    if (this.props.isAuthenticated) this.props.getUrlsList();
-  }
-
-  shouldComponentUpdate(nextProps) {
-    return this.props.isAuthenticated !== nextProps.isAuthenticated;
-  }
-
-  render() {
-    const { isAuthenticated } = this.props;
-    const needToLogin = !isAuthenticated && <NeedToLogin />;
-    const table = isAuthenticated && <Table />;
-    return (
-      <BodyWrapper>
-        <Shortener />
-        {needToLogin}
-        {table}
-        <Features />
-        <Extensions />
-        <Footer />
-      </BodyWrapper>
-    );
-  }
-}
-
-Homepage.propTypes = {
-  isAuthenticated: PropTypes.bool.isRequired,
-  getUrlsList: PropTypes.func.isRequired,
-};
-
-const mapStateToProps = ({ auth: { isAuthenticated } }) => ({
-  isAuthenticated,
-});
-
-const mapDispatchToProps = dispatch => ({
-  getUrlsList: bindActionCreators(getUrlsList, dispatch),
-});
-
-export default connect(
-  mapStateToProps,
-  mapDispatchToProps
-)(Homepage);

+ 27 - 0
client/pages/index.tsx

@@ -0,0 +1,27 @@
+import React from "react";
+
+import NeedToLogin from "../components/NeedToLogin";
+import BodyWrapper from "../components/BodyWrapper";
+import Extensions from "../components/Extensions";
+import LinksTable from "../components/LinksTable";
+import Shortener from "../components/Shortener";
+import Features from "../components/Features";
+import Footer from "../components/Footer";
+import { useStoreState } from "../store";
+
+const Homepage = () => {
+  const isAuthenticated = useStoreState(s => s.auth.isAuthenticated);
+
+  return (
+    <BodyWrapper>
+      <Shortener />
+      {!isAuthenticated && <NeedToLogin />}
+      {isAuthenticated && <LinksTable />}
+      <Features />
+      <Extensions />
+      <Footer />
+    </BodyWrapper>
+  );
+};
+
+export default Homepage;

+ 13 - 20
client/pages/login.tsx

@@ -6,18 +6,16 @@ import styled from "styled-components";
 import emailValidator from "email-validator";
 import emailValidator from "email-validator";
 import { useFormState } from "react-use-form-state";
 import { useFormState } from "react-use-form-state";
 import { Flex } from "reflexbox/styled-components";
 import { Flex } from "reflexbox/styled-components";
-import cookie from "js-cookie";
-import decode from "jwt-decode";
 
 
 import { useStoreState, useStoreActions } from "../store";
 import { useStoreState, useStoreActions } from "../store";
 import BodyWrapper from "../components/BodyWrapper";
 import BodyWrapper from "../components/BodyWrapper";
 import { fadeIn } from "../helpers/animations";
 import { fadeIn } from "../helpers/animations";
 import { API } from "../consts";
 import { API } from "../consts";
 import TextInput from "../components/TextInput";
 import TextInput from "../components/TextInput";
-import Button from "../components/Button";
+import { Button } from "../components/Button";
 import Text from "../components/Text";
 import Text from "../components/Text";
-import { TokenPayload } from "../types";
 import ALink from "../components/ALink";
 import ALink from "../components/ALink";
+import { ColCenterV } from "../components/Layout";
 
 
 const LoginForm = styled(Flex).attrs({
 const LoginForm = styled(Flex).attrs({
   as: "form",
   as: "form",
@@ -38,10 +36,10 @@ const LoginPage = () => {
   const [error, setError] = useState("");
   const [error, setError] = useState("");
   const [verifying, setVerifying] = useState(false);
   const [verifying, setVerifying] = useState(false);
   const [loading, setLoading] = useState({ login: false, signup: false });
   const [loading, setLoading] = useState({ login: false, signup: false });
-  const [formState, { email, password }] = useFormState<{
+  const [formState, { email, password, label }] = useFormState<{
     email: string;
     email: string;
     password: string;
     password: string;
-  }>();
+  }>(null, { withIds: true });
 
 
   useEffect(() => {
   useEffect(() => {
     if (isAuthenticated) Router.push("/");
     if (isAuthenticated) Router.push("/");
@@ -72,6 +70,7 @@ const LoginPage = () => {
         setLoading(s => ({ ...s, login: true }));
         setLoading(s => ({ ...s, login: true }));
         try {
         try {
           await login(formState.values);
           await login(formState.values);
+          Router.push("/");
         } catch (error) {
         } catch (error) {
           setError(error.response.data.error);
           setError(error.response.data.error);
         }
         }
@@ -97,13 +96,7 @@ const LoginPage = () => {
 
 
   return (
   return (
     <BodyWrapper>
     <BodyWrapper>
-      <Flex
-        flexDirection="column"
-        flex="0 0 auto"
-        alignItems="center"
-        mt={24}
-        mb={64}
-      >
+      <ColCenterV flex="0 0 auto" mt={24} mb={64}>
         {verifying ? (
         {verifying ? (
           <Text fontWeight={300} as="h2" textAlign="center">
           <Text fontWeight={300} as="h2" textAlign="center">
             A verification email has been sent to{" "}
             A verification email has been sent to{" "}
@@ -111,18 +104,18 @@ const LoginPage = () => {
           </Text>
           </Text>
         ) : (
         ) : (
           <LoginForm id="login-form" onSubmit={onSubmit("login")}>
           <LoginForm id="login-form" onSubmit={onSubmit("login")}>
-            <Flex mb={[2, 2, 2]}>
-              <label htmlFor="email">Email address</label>
-            </Flex>
+            <Text {...label("email")} as="label" fontWeight={700} mb={2}>
+              Email address:
+            </Text>
             <TextInput
             <TextInput
               {...email("email")}
               {...email("email")}
               height={[56, 64, 72]}
               height={[56, 64, 72]}
               mb={[24, 32, 36]}
               mb={[24, 32, 36]}
               autoFocus
               autoFocus
             />
             />
-            <Flex mb={[2, 2, 2]}>
-              <label htmlFor="password">Password (min chars: 8)</label>
-            </Flex>
+            <Text {...label("password")} as="label" fontWeight={700} mb={2}>
+              Password (min chars: 8):
+            </Text>
             <TextInput
             <TextInput
               {...password("password")}
               {...password("password")}
               height={[56, 64, 72]}
               height={[56, 64, 72]}
@@ -165,7 +158,7 @@ const LoginPage = () => {
             </Text>
             </Text>
           </LoginForm>
           </LoginForm>
         )}
         )}
-      </Flex>
+      </ColCenterV>
     </BodyWrapper>
     </BodyWrapper>
   );
   );
 };
 };

+ 7 - 1
client/pages/logout.tsx

@@ -1,11 +1,17 @@
 import React, { FC, useEffect } from "react";
 import React, { FC, useEffect } from "react";
+import Router from "next/router";
 
 
 import { useStoreActions } from "../store";
 import { useStoreActions } from "../store";
 
 
 const LogoutPage: FC = () => {
 const LogoutPage: FC = () => {
   const logout = useStoreActions(s => s.auth.logout);
   const logout = useStoreActions(s => s.auth.logout);
+  const reset = useStoreActions(s => s.reset);
 
 
-  useEffect(() => logout(), []);
+  useEffect(() => {
+    logout();
+    reset();
+    Router.push("/");
+  }, []);
 
 
   return <div />;
   return <div />;
 };
 };

+ 1 - 1
client/pages/report.tsx

@@ -5,7 +5,7 @@ import { Flex } from "reflexbox/styled-components";
 
 
 import BodyWrapper from "../components/BodyWrapper";
 import BodyWrapper from "../components/BodyWrapper";
 import TextInput from "../components/TextInput";
 import TextInput from "../components/TextInput";
-import Button from "../components/Button";
+import { Button } from "../components/Button";
 import Text from "../components/Text";
 import Text from "../components/Text";
 import { API } from "../consts";
 import { API } from "../consts";
 import { useMessage } from "../hooks";
 import { useMessage } from "../hooks";

+ 3 - 2
client/pages/reset-password.tsx

@@ -10,7 +10,7 @@ import axios from "axios";
 import { useStoreState, useStoreActions } from "../store";
 import { useStoreState, useStoreActions } from "../store";
 import BodyWrapper from "../components/BodyWrapper";
 import BodyWrapper from "../components/BodyWrapper";
 import TextInput from "../components/TextInput";
 import TextInput from "../components/TextInput";
-import Button from "../components/Button";
+import { Button } from "../components/Button";
 import { TokenPayload } from "../types";
 import { TokenPayload } from "../types";
 import Text from "../components/Text";
 import Text from "../components/Text";
 import { useMessage } from "../hooks";
 import { useMessage } from "../hooks";
@@ -67,7 +67,8 @@ const ResetPassword: NextPage<Props> = ({ token }) => {
         <Text as="p" mb={4}>
         <Text as="p" mb={4}>
           If you forgot you password you can use the form below to get reset
           If you forgot you password you can use the form below to get reset
           password link.
           password link.
-        </Text>w
+        </Text>
+        w
         <Flex
         <Flex
           as="form"
           as="form"
           flexDirection={["column", "row"]}
           flexDirection={["column", "row"]}

+ 2 - 2
client/pages/settings.tsx

@@ -1,6 +1,6 @@
 import { Flex } from "reflexbox/styled-components";
 import { Flex } from "reflexbox/styled-components";
 import { NextPage } from "next";
 import { NextPage } from "next";
-import React, { useState, useEffect } from "react";
+import React, { useEffect } from "react";
 
 
 import SettingsPassword from "../components/Settings/SettingsPassword";
 import SettingsPassword from "../components/Settings/SettingsPassword";
 import SettingsDomain from "../components/Settings/SettingsDomain";
 import SettingsDomain from "../components/Settings/SettingsDomain";
@@ -13,7 +13,7 @@ import { useStoreState, useStoreActions } from "../store";
 import Text from "../components/Text";
 import Text from "../components/Text";
 
 
 const SettingsPage: NextPage = () => {
 const SettingsPage: NextPage = () => {
-  const { isAuthenticated, email, isAdmin } = useStoreState(s => s.auth);
+  const { email, isAdmin } = useStoreState(s => s.auth);
   const getSettings = useStoreActions(s => s.settings.getSettings);
   const getSettings = useStoreActions(s => s.settings.getSettings);
 
 
   useEffect(() => {
   useEffect(() => {

+ 1 - 1
client/pages/url-password.tsx

@@ -7,7 +7,7 @@ import { useFormState } from "react-use-form-state";
 
 
 import BodyWrapper from "../components/BodyWrapper";
 import BodyWrapper from "../components/BodyWrapper";
 import TextInput from "../components/TextInput";
 import TextInput from "../components/TextInput";
-import Button from "../components/Button";
+import { Button } from "../components/Button";
 import Text from "../components/Text";
 import Text from "../components/Text";
 
 
 interface Props {
 interface Props {

+ 1 - 1
client/pages/verify.tsx

@@ -6,7 +6,7 @@ import { Flex } from "reflexbox/styled-components";
 import decode from "jwt-decode";
 import decode from "jwt-decode";
 
 
 import BodyWrapper from "../components/BodyWrapper";
 import BodyWrapper from "../components/BodyWrapper";
-import Button from "../components/Button";
+import { Button } from "../components/Button";
 import { NextPage } from "next";
 import { NextPage } from "next";
 import { TokenPayload } from "../types";
 import { TokenPayload } from "../types";
 import { useStoreActions } from "../store";
 import { useStoreActions } from "../store";

+ 0 - 3
client/store/auth.ts

@@ -1,5 +1,4 @@
 import { action, Action, thunk, Thunk, computed, Computed } from "easy-peasy";
 import { action, Action, thunk, Thunk, computed, Computed } from "easy-peasy";
-import Router from "next/router";
 import decode from "jwt-decode";
 import decode from "jwt-decode";
 import cookie from "js-cookie";
 import cookie from "js-cookie";
 import axios from "axios";
 import axios from "axios";
@@ -32,7 +31,6 @@ export const auth: Auth = {
     state.domain = null;
     state.domain = null;
     state.email = null;
     state.email = null;
     state.isAdmin = false;
     state.isAdmin = false;
-    Router.push("/");
   }),
   }),
   login: thunk(async (actions, payload) => {
   login: thunk(async (actions, payload) => {
     const res = await axios.post(API.LOGIN, payload);
     const res = await axios.post(API.LOGIN, payload);
@@ -40,6 +38,5 @@ export const auth: Auth = {
     cookie.set("token", token, { expires: 7 });
     cookie.set("token", token, { expires: 7 });
     const tokenPayload: TokenPayload = decode(token);
     const tokenPayload: TokenPayload = decode(token);
     actions.add(tokenPayload);
     actions.add(tokenPayload);
-    Router.push("/");
   })
   })
 };
 };

+ 89 - 0
client/store/links.ts

@@ -0,0 +1,89 @@
+import { action, Action, thunk, Thunk } from "easy-peasy";
+import axios from "axios";
+import query from "query-string";
+
+import { getAxiosConfig } from "../utils";
+import { API } from "../consts";
+
+export interface Link {
+  id: number;
+  address: string;
+  banned: boolean;
+  banned_by_id?: number;
+  created_at: string;
+  shortLink: string;
+  domain?: string;
+  domain_id?: number;
+  password?: string;
+  target: string;
+  updated_at: string;
+  user_id?: number;
+  visit_count: number;
+}
+
+export interface NewLink {
+  target: string;
+  customurl?: string;
+  password?: string;
+  reuse?: boolean;
+}
+
+export interface LinksQuery {
+  count?: string;
+  page?: string;
+  search?: string;
+}
+
+export interface LinksListRes {
+  list: Link[];
+  countAll: number;
+}
+
+export interface Links {
+  link?: Link;
+  items: Link[];
+  total: number;
+  loading: boolean;
+  submit: Thunk<Links, NewLink>;
+  get: Thunk<Links, LinksQuery>;
+  add: Action<Links, Link>;
+  set: Action<Links, LinksListRes>;
+  deleteOne: Thunk<Links, { id: string; domain?: string }>;
+  setLoading: Action<Links, boolean>;
+}
+
+export const links: Links = {
+  link: null,
+  items: [],
+  total: 0,
+  loading: true,
+  submit: thunk(async (actions, payload) => {
+    const res = await axios.post(API.SUBMIT, payload, getAxiosConfig());
+    actions.add(res.data);
+    return res.data;
+  }),
+  get: thunk(async (actions, payload) => {
+    actions.setLoading(true);
+    const res = await axios.get(
+      `${API.GET_LINKS}?${query.stringify(payload)}`,
+      getAxiosConfig()
+    );
+    actions.set(res.data);
+    actions.setLoading(false);
+    return res.data;
+  }),
+  deleteOne: thunk(async (actions, payload) => {
+    await axios.post(API.DELETE_LINK, payload, getAxiosConfig());
+  }),
+  add: action((state, payload) => {
+    state.items.pop();
+    state.items.unshift(payload);
+  }),
+  set: action((state, payload) => {
+    state.items = payload.list;
+    state.total = payload.countAll;
+  }),
+  setLoading: action((state, payload) => {
+    state.loading = payload;
+  })
+};

+ 15 - 6
client/store/store.ts

@@ -1,19 +1,26 @@
-import { createStore, createTypedHooks } from "easy-peasy";
+import { Action, createStore, createTypedHooks, action } from "easy-peasy";
 
 
-import { auth, Auth } from "./auth";
-import { loading, Loading } from "./loading";
 import { settings, Settings } from "./settings";
 import { settings, Settings } from "./settings";
+import { loading, Loading } from "./loading";
+import { links, Links } from "./links";
+import { auth, Auth } from "./auth";
 
 
 export interface StoreModel {
 export interface StoreModel {
   auth: Auth;
   auth: Auth;
+  links: Links;
   loading: Loading;
   loading: Loading;
   settings: Settings;
   settings: Settings;
+  reset: Action;
 }
 }
 
 
+let initState: any = {};
+
 export const store: StoreModel = {
 export const store: StoreModel = {
   auth,
   auth,
+  links,
   loading,
   loading,
-  settings
+  settings,
+  reset: action(() => initState)
 };
 };
 
 
 const typedHooks = createTypedHooks<StoreModel>();
 const typedHooks = createTypedHooks<StoreModel>();
@@ -22,5 +29,7 @@ export const useStoreActions = typedHooks.useStoreActions;
 export const useStoreDispatch = typedHooks.useStoreDispatch;
 export const useStoreDispatch = typedHooks.useStoreDispatch;
 export const useStoreState = typedHooks.useStoreState;
 export const useStoreState = typedHooks.useStoreState;
 
 
-export const initializeStore = (initialState?: StoreModel) =>
-  createStore(store, { initialState });
+export const initializeStore = (initialState?: StoreModel) => {
+  initState = initialState;
+  return createStore(store, { initialState });
+};

+ 6 - 0
client/utils.ts

@@ -1,6 +1,12 @@
 import cookie from "js-cookie";
 import cookie from "js-cookie";
 import { AxiosRequestConfig } from "axios";
 import { AxiosRequestConfig } from "axios";
 
 
+export const removeProtocol = (link: string) =>
+  link.replace(/^https?:\/\//, "");
+
+export const withComma = (num: number) =>
+  num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
+
 export const getAxiosConfig = (
 export const getAxiosConfig = (
   options: AxiosRequestConfig = {}
   options: AxiosRequestConfig = {}
 ): AxiosRequestConfig => ({
 ): AxiosRequestConfig => ({


+ 39 - 8
package-lock.json

@@ -1880,6 +1880,15 @@
       "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==",
       "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==",
       "dev": true
       "dev": true
     },
     },
+    "@types/qrcode.react": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@types/qrcode.react/-/qrcode.react-1.0.0.tgz",
+      "integrity": "sha512-CyH8QizAyp/G2RTz+2W0qIp/qbJMNC6a8aossG+zN42Rmx3loQlgBCWFatjKJ3NeJaX99e22SJ75yU2EVHlu3g==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*"
+      }
+    },
     "@types/range-parser": {
     "@types/range-parser": {
       "version": "1.2.3",
       "version": "1.2.3",
       "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz",
       "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz",
@@ -10350,6 +10359,22 @@
         "prepend-http": "^1.0.0",
         "prepend-http": "^1.0.0",
         "query-string": "^4.1.0",
         "query-string": "^4.1.0",
         "sort-keys": "^1.0.0"
         "sort-keys": "^1.0.0"
+      },
+      "dependencies": {
+        "query-string": {
+          "version": "4.3.4",
+          "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz",
+          "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=",
+          "requires": {
+            "object-assign": "^4.1.0",
+            "strict-uri-encode": "^1.0.0"
+          }
+        },
+        "strict-uri-encode": {
+          "version": "1.1.0",
+          "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
+          "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM="
+        }
       }
       }
     },
     },
     "npm-run-path": {
     "npm-run-path": {
@@ -11946,12 +11971,13 @@
       "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
       "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
     },
     },
     "query-string": {
     "query-string": {
-      "version": "4.3.4",
-      "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz",
-      "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=",
+      "version": "6.9.0",
+      "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.9.0.tgz",
+      "integrity": "sha512-KG4bhCFYapExLsUHrFt+kQVEegF2agm4cpF/VNc6pZVthIfCc/GK8t8VyNIE3nyXG9DK3Tf2EGkxjR6/uRdYsA==",
       "requires": {
       "requires": {
-        "object-assign": "^4.1.0",
-        "strict-uri-encode": "^1.0.0"
+        "decode-uri-component": "^0.2.0",
+        "split-on-first": "^1.0.0",
+        "strict-uri-encode": "^2.0.0"
       }
       }
     },
     },
     "querystring": {
     "querystring": {
@@ -13110,6 +13136,11 @@
         "through": "2"
         "through": "2"
       }
       }
     },
     },
+    "split-on-first": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
+      "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="
+    },
     "split-string": {
     "split-string": {
       "version": "3.1.0",
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
       "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
@@ -13224,9 +13255,9 @@
       "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI="
       "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI="
     },
     },
     "strict-uri-encode": {
     "strict-uri-encode": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
-      "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM="
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
+      "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY="
     },
     },
     "string-hash": {
     "string-hash": {
       "version": "1.1.3",
       "version": "1.1.3",

+ 2 - 0
package.json

@@ -69,6 +69,7 @@
     "pg-query-stream": "^2.0.0",
     "pg-query-stream": "^2.0.0",
     "prop-types": "^15.7.2",
     "prop-types": "^15.7.2",
     "qrcode.react": "^0.8.0",
     "qrcode.react": "^0.8.0",
+    "query-string": "^6.9.0",
     "raven": "^2.6.4",
     "raven": "^2.6.4",
     "react": "^16.8.1",
     "react": "^16.8.1",
     "react-copy-to-clipboard": "^5.0.1",
     "react-copy-to-clipboard": "^5.0.1",
@@ -119,6 +120,7 @@
     "@types/nodemailer": "^6.2.1",
     "@types/nodemailer": "^6.2.1",
     "@types/pg": "^7.11.0",
     "@types/pg": "^7.11.0",
     "@types/pg-query-stream": "^1.0.3",
     "@types/pg-query-stream": "^1.0.3",
+    "@types/qrcode.react": "^1.0.0",
     "@types/react": "^16.9.16",
     "@types/react": "^16.9.16",
     "@types/react-dom": "^16.9.4",
     "@types/react-dom": "^16.9.4",
     "@types/react-tooltip": "^3.11.0",
     "@types/react-tooltip": "^3.11.0",