poeti8 %!s(int64=6) %!d(string=hai) anos
pai
achega
2c243edf9e
Modificáronse 66 ficheiros con 1585 adicións e 1206 borrados
  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=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 {
   ADD_URL,
   LIST_URLS,
@@ -7,8 +7,8 @@ import {
   DELETE_URL,
   SHORTENER_LOADING,
   TABLE_LOADING,
-  SHORTENER_ERROR,
-} from './actionTypes';
+  SHORTENER_ERROR
+} from "./actionTypes";
 
 const addUrl = payload => ({ type: ADD_URL, payload });
 const listUrls = payload => ({ type: LIST_URLS, payload });
@@ -18,15 +18,15 @@ const showTableLoading = () => ({ type: TABLE_LOADING });
 
 export const setShortenerFormError = payload => ({
   type: SHORTENER_ERROR,
-  payload,
+  payload
 });
 
 export const showShortenerLoading = () => ({ type: SHORTENER_LOADING });
 
 export const createShortUrl = params => async dispatch => {
   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));
   } catch ({ response }) {
@@ -45,12 +45,12 @@ export const getUrlsList = params => async (dispatch, getState) => {
   const { list, ...queryParams } = url;
   const query = Object.keys(queryParams).reduce(
     (string, item) => `${string + item}=${queryParams[item]}&`,
-    '?'
+    "?"
   );
 
   try {
     const { data } = await axios.get(`/api/url/geturls${query}`, {
-      headers: { Authorization: cookie.get('token') },
+      headers: { Authorization: cookie.get("token") }
     });
     dispatch(listUrls(data));
   } catch (error) {
@@ -61,8 +61,8 @@ export const getUrlsList = params => async (dispatch, getState) => {
 export const deleteShortUrl = params => async dispatch => {
   dispatch(showTableLoading());
   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));
   } catch ({ response }) {

+ 2 - 0
client/components/ALink.tsx

@@ -5,6 +5,7 @@ interface Props extends BoxProps {
   href?: string;
   title?: string;
   target?: string;
+  rel?: string;
 }
 const ALink = styled(Box).attrs({
   as: "a"
@@ -13,6 +14,7 @@ const ALink = styled(Box).attrs({
   color: #2196f3;
   border-bottom: 1px dotted transparent;
   text-decoration: none;
+  transition: all 0.2s ease-out;
 
   :hover {
     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 ? (
     <Icon
       icon={props.icon}
@@ -139,4 +139,49 @@ Button.defaultProps = {
   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} />
       <Box checked={checked} width={width} height={height} />
-      <Text as="span" ml={2}>
+      <Text as="span" ml={12} color="#555">
         {label}
       </Text>
     </Flex>

+ 38 - 16
client/components/CustomTable.ts

@@ -1,16 +1,13 @@
-import styled from "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;
   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;
+  overflow: scroll;
 
   tr,
   th,
@@ -19,36 +16,61 @@ const Table = styled(Flex).attrs({
   thead,
   tfoot {
     display: flex;
-    flex: 1 1 auto;
+    overflow: hidden;
+  }
+
+  tbody,
+  thead,
+  tfoot {
+    flex-direction: column;
   }
 
   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-left-radius: 12px;
+    overflow: hidden;
   }
-  tbody tr:last-child + tfoot {
+  tbody + tfoot {
     border: none;
   }
   tbody tr:hover {
-    background-color: #f8f8f8;
+    background-color: hsl(200, 14%, 98%);
   }
   thead {
-    background-color: #f1f1f1;
+    background-color: hsl(200, 14%, 96%);
     border-top-right-radius: 12px;
     border-top-left-radius: 12px;
     font-weight: bold;
     tr {
-      border-bottom: 1px solid #dedede;
+      border-bottom: 1px solid hsl(200, 14%, 90%);
     }
   }
   tfoot {
-    background-color: #f1f1f1;
+    background-color: hsl(200, 14%, 96%);
     border-bottom-right-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;

+ 149 - 24
client/components/Header.tsx

@@ -1,32 +1,157 @@
+import { Flex } from "reflexbox/styled-components";
 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 { 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
-      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>
-    <HeaderRightMenu />
-  </Flex>
-);
+  );
+};
 
 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 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 QRCode from "./QRCode";
+import Trash from "./Trash";
+import Check from "./Check";
 import Plus from "./Plus";
 import Lock from "./Lock";
-import Refresh from "./Refresh";
+import Copy from "./Copy";
+import Send from "./Send";
+import Key from "./Key";
 import Zap from "./Zap";
 
 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;
+  Lock: JSX.Element;
+  copy: JSX.Element;
+  refresh: JSX.Element;
+  check: JSX.Element;
+  send: JSX.Element;
   spinner: JSX.Element;
   trash: JSX.Element;
+  zap: JSX.Element;
+  qrcode: JSX.Element;
 }
 
 const icons = {
+  clipboard: Clipboard,
+  chevronRight: ChevronRight,
+  chevronLeft: ChevronLeft,
+  pieChart: PieChart,
+  key: Key,
   lock: Lock,
-  refresh: Refresh,
-  zap: Zap,
+  check: Check,
   plus: Plus,
+  copy: Copy,
+  refresh: Refresh,
+  send: Send,
   spinner: Spinner,
   trash: Trash,
+  zap: Zap,
+  qrcode: QRCode
 };
 
 interface Props extends React.ComponentProps<typeof Flex> {
   name: keyof typeof icons;
+  stroke?: string;
+  fill?: string;
+  hoverFill?: string;
+  hoverStroke?: string;
+  strokeWidth?: string;
 }
 
 const CustomIcon: FC<React.ComponentProps<typeof Flex>> = styled(Flex)`
   position: relative;
-  fill: ${prop("color")};
 
   svg {
+    transition: all 0.2s ease-out;
     width: 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(
     { as: "button" },
     css`
       border: none;
       outline: none;
       transition: transform 0.4s ease-out;
-      background-color: transparent;
+      border-radius: 100%;
+      background-color: none !important;
       cursor: pointer;
       box-sizing: content-box;
 
@@ -69,8 +145,7 @@ const Icon: FC<Props> = ({ name, ...rest }) => (
 Icon.defaultProps = {
   size: 16,
   alignItems: "center",
-  justifyContent: "center",
-  color: "#888"
+  justifyContent: "center"
 };
 
 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 (
     <svg
       xmlns="http://www.w3.org/2000/svg"
-      width="24"
-      height="24"
+      width="48"
+      height="48"
       fill="none"
-      stroke="currentColor"
+      stroke="#000"
       strokeLinecap="round"
       strokeLinejoin="round"
-      strokeWidth="2"
-      className="feather feather-lock"
+      strokeWidth="3"
       viewBox="0 0 24 24"
     >
       <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 styled from "styled-components";
+import React, { FC } from "react";
+
+import Animation from "./Animation";
 
-interface Props {
+interface Props extends React.ComponentProps<typeof Flex> {
   show: boolean;
   id?: string;
   closeHandler?: () => unknown;
@@ -21,7 +23,7 @@ const Wrapper = styled.div`
   z-index: 1000;
 `;
 
-const Modal: FC<Props> = ({ children, id, show, closeHandler }) => {
+const Modal: FC<Props> = ({ children, id, show, closeHandler, ...rest }) => {
   if (!show) return null;
 
   const onClickOutside = e => {
@@ -30,16 +32,19 @@ const Modal: FC<Props> = ({ children, id, show, closeHandler }) => {
 
   return (
     <Wrapper id={id} onClick={onClickOutside}>
-      <Flex
+      <Animation
+        offset="-20px"
+        duration="0.2s"
         minWidth={[400, 450]}
         maxWidth={0.9}
         py={[32, 32, 48]}
         px={[24, 24, 32]}
         style={{ borderRadius: 8, backgroundColor: "white" }}
         flexDirection="column"
+        {...rest}
       >
         {children}
-      </Flex>
+      </Animation>
     </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({
   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;
   box-sizing: border-box;
@@ -58,14 +58,14 @@ const NeedToLogin = () => (
   <Wrapper>
     <Flex
       flexDirection="column"
-      alignItems={['center', 'center', 'flex-start']}
+      alignItems={["center", "center", "flex-start"]}
       mt={-32}
       mb={[32, 32, 0]}
     >
       <Title>
         Manage links, set custom <b>domains</b> and view <b>stats</b>.
       </Title>
-      <Link href="/login" prefetch>
+      <Link href="/login">
         <a href="/login" title="login / signup">
           <Button>Login / Signup</Button>
         </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 = () => (
   <Flex
@@ -19,7 +11,7 @@ const pageLoading = () => (
     justifyContent="center"
     margin="0 0 48px"
   >
-    <Icon src="/images/loader.svg" />
+    <Icon name="spinner" size={24} stroke="#888" />
   </Flex>
 );
 

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

@@ -4,7 +4,7 @@ import React, { FC, useState } from "react";
 import styled from "styled-components";
 
 import { useStoreState, useStoreActions } from "../../store";
-import Button from "../Button";
+import { Button } from "../Button";
 import ALink from "../ALink";
 import Icon from "../Icon";
 import Text from "../Text";
@@ -85,7 +85,7 @@ const SettingsApi: FC = () => {
         </Flex>
       )}
       <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
       </Button>
     </Flex>

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

@@ -8,7 +8,7 @@ import { useMessage } from "../../hooks";
 import TextInput from "../TextInput";
 import Checkbox from "../Checkbox";
 import { API } from "../../consts";
-import Button from "../Button";
+import { Button } from "../Button";
 import Icon from "../Icon";
 import Text from "../Text";
 
@@ -64,9 +64,14 @@ const SettingsBan: FC = () => {
             pl={24}
             pr={24}
             width={[1, 3 / 5]}
+            required
           />
           <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"}
           </Button>
         </Flex>

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

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

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

@@ -7,7 +7,7 @@ import { getAxiosConfig } from "../../utils";
 import { useMessage } from "../../hooks";
 import TextInput from "../TextInput";
 import { API } from "../../consts";
-import Button from "../Button";
+import { Button } from "../Button";
 import Icon from "../Icon";
 import Text from "../Text";
 
@@ -64,7 +64,7 @@ const SettingsPassword: FC = () => {
           required
         />
         <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"}
         </Button>
       </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 StatsCharts from "./StatsCharts";
 import PageLoading from "../PageLoading";
-import Button from "../Button";
+import { Button } from "../Button";
 
 interface Props {
   isAuthenticated: boolean;
@@ -143,7 +143,4 @@ const mapStateToProps = ({ auth: { 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 {
   changePeriod: any; // TODO: types
@@ -10,11 +10,11 @@ interface Props {
 }
 
 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],
-  px: 32,
+  px: 32
 })`
   background-color: #f1f1f1;
   border-top-left-radius: 12px;
@@ -89,10 +89,10 @@ const StatsHead: FC<Props> = ({ changePeriod, period, total }) => {
         Total clicks: <span>{total}</span>
       </TotalText>
       <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>
     </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({
-  as: 'thead',
-  flexDirection: 'column',
-  flex: '1 1 auto',
+  as: "thead",
+  flexDirection: "column",
+  flex: "1 1 auto"
 })`
   background-color: #f1f1f1;
   border-top-right-radius: 12px;
@@ -19,10 +19,10 @@ const THead = 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) {
     flex: 1;
@@ -42,10 +42,10 @@ const TableHead: FC = () => (
   <THead>
     <TableOptions />
     <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>
   </THead>
 );

+ 2 - 1
client/components/Text.tsx

@@ -24,7 +24,8 @@ const Text = styled(Box)<Props>`
 
 Text.defaultProps = {
   as: "p",
-  fontWeight: 400
+  fontWeight: 400,
+  color: "hsl(200, 35%, 25%)"
 };
 
 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";
 
-interface Props {
+interface Props extends BoxProps {
   autoFocus?: boolean;
   name?: string;
   id?: string;
@@ -15,6 +15,8 @@ interface Props {
   onChange?: any;
   tiny?: boolean;
   placeholderSize?: number[];
+  br?: string;
+  bbw?: string;
 }
 
 const TextInput = styled(Flex).attrs({
@@ -25,16 +27,17 @@ const TextInput = styled(Flex).attrs({
   letter-spacing: 0.05em;
   color: #444;
   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-radius: ${prop("br", "100px")};
   border-bottom: 5px solid #f5f5f5;
+  border-bottom-width: ${prop("bbw", "5px")};
   animation: ${fadeIn} 0.5s ease-out;
   transition: all 0.5s ease-out;
 
   :focus {
     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 {
@@ -51,7 +54,7 @@ const TextInput = styled(Flex).attrs({
 
   @media screen and (min-width: 52em) {
     letter-spacing: 0.1em;
-    border-bottom-width: 6px;
+    border-bottom-width: ${prop("bbw", "6px")};
     ::placeholder {
       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;
     }
   }
-
-  /* ${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 = {

+ 2 - 2
client/components/Tooltip.tsx

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

+ 4 - 1
client/consts/consts.ts

@@ -7,5 +7,8 @@ export enum API {
   BAN_LINK = "/api/url/admin/ban",
   CUSTOM_DOMAIN = "/api/url/customdomain",
   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";
 
+const initialMessage = { color: "red", text: "" };
+
 export const useMessage = (timeout?: number) => {
-  const [message, set] = useState({ color: "red", text: "" });
+  const [message, set] = useState(initialMessage);
 
   const setMessage = (text = "", color = "red") => {
     set({ text, color });
 
     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 {
   styleTags: any;
@@ -65,7 +65,7 @@ class AppDocument extends Document<Props> {
 
           <script
             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
           style={{
             margin: 0,
-            backgroundColor: '#f3f3f3',
+            backgroundColor: "hsl(206, 12%, 95%)",
             font: '16px/1.45 "Nunito", sans-serif',
-            overflowX: 'hidden',
-            color: 'black',
+            overflowX: "hidden",
+            color: "hsl(200, 35%, 25%)"
           }}
         >
           <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 { useFormState } from "react-use-form-state";
 import { Flex } from "reflexbox/styled-components";
-import cookie from "js-cookie";
-import decode from "jwt-decode";
 
 import { useStoreState, useStoreActions } from "../store";
 import BodyWrapper from "../components/BodyWrapper";
 import { fadeIn } from "../helpers/animations";
 import { API } from "../consts";
 import TextInput from "../components/TextInput";
-import Button from "../components/Button";
+import { Button } from "../components/Button";
 import Text from "../components/Text";
-import { TokenPayload } from "../types";
 import ALink from "../components/ALink";
+import { ColCenterV } from "../components/Layout";
 
 const LoginForm = styled(Flex).attrs({
   as: "form",
@@ -38,10 +36,10 @@ const LoginPage = () => {
   const [error, setError] = useState("");
   const [verifying, setVerifying] = useState(false);
   const [loading, setLoading] = useState({ login: false, signup: false });
-  const [formState, { email, password }] = useFormState<{
+  const [formState, { email, password, label }] = useFormState<{
     email: string;
     password: string;
-  }>();
+  }>(null, { withIds: true });
 
   useEffect(() => {
     if (isAuthenticated) Router.push("/");
@@ -72,6 +70,7 @@ const LoginPage = () => {
         setLoading(s => ({ ...s, login: true }));
         try {
           await login(formState.values);
+          Router.push("/");
         } catch (error) {
           setError(error.response.data.error);
         }
@@ -97,13 +96,7 @@ const LoginPage = () => {
 
   return (
     <BodyWrapper>
-      <Flex
-        flexDirection="column"
-        flex="0 0 auto"
-        alignItems="center"
-        mt={24}
-        mb={64}
-      >
+      <ColCenterV flex="0 0 auto" mt={24} mb={64}>
         {verifying ? (
           <Text fontWeight={300} as="h2" textAlign="center">
             A verification email has been sent to{" "}
@@ -111,18 +104,18 @@ const LoginPage = () => {
           </Text>
         ) : (
           <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
               {...email("email")}
               height={[56, 64, 72]}
               mb={[24, 32, 36]}
               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
               {...password("password")}
               height={[56, 64, 72]}
@@ -165,7 +158,7 @@ const LoginPage = () => {
             </Text>
           </LoginForm>
         )}
-      </Flex>
+      </ColCenterV>
     </BodyWrapper>
   );
 };

+ 7 - 1
client/pages/logout.tsx

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

+ 1 - 1
client/pages/report.tsx

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

+ 2 - 2
client/pages/settings.tsx

@@ -1,6 +1,6 @@
 import { Flex } from "reflexbox/styled-components";
 import { NextPage } from "next";
-import React, { useState, useEffect } from "react";
+import React, { useEffect } from "react";
 
 import SettingsPassword from "../components/Settings/SettingsPassword";
 import SettingsDomain from "../components/Settings/SettingsDomain";
@@ -13,7 +13,7 @@ import { useStoreState, useStoreActions } from "../store";
 import Text from "../components/Text";
 
 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);
 
   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 TextInput from "../components/TextInput";
-import Button from "../components/Button";
+import { Button } from "../components/Button";
 import Text from "../components/Text";
 
 interface Props {

+ 1 - 1
client/pages/verify.tsx

@@ -6,7 +6,7 @@ import { Flex } from "reflexbox/styled-components";
 import decode from "jwt-decode";
 
 import BodyWrapper from "../components/BodyWrapper";
-import Button from "../components/Button";
+import { Button } from "../components/Button";
 import { NextPage } from "next";
 import { TokenPayload } from "../types";
 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 Router from "next/router";
 import decode from "jwt-decode";
 import cookie from "js-cookie";
 import axios from "axios";
@@ -32,7 +31,6 @@ export const auth: Auth = {
     state.domain = null;
     state.email = null;
     state.isAdmin = false;
-    Router.push("/");
   }),
   login: thunk(async (actions, payload) => {
     const res = await axios.post(API.LOGIN, payload);
@@ -40,6 +38,5 @@ export const auth: Auth = {
     cookie.set("token", token, { expires: 7 });
     const tokenPayload: TokenPayload = decode(token);
     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 { loading, Loading } from "./loading";
+import { links, Links } from "./links";
+import { auth, Auth } from "./auth";
 
 export interface StoreModel {
   auth: Auth;
+  links: Links;
   loading: Loading;
   settings: Settings;
+  reset: Action;
 }
 
+let initState: any = {};
+
 export const store: StoreModel = {
   auth,
+  links,
   loading,
-  settings
+  settings,
+  reset: action(() => initState)
 };
 
 const typedHooks = createTypedHooks<StoreModel>();
@@ -22,5 +29,7 @@ export const useStoreActions = typedHooks.useStoreActions;
 export const useStoreDispatch = typedHooks.useStoreDispatch;
 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 { 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 = (
   options: AxiosRequestConfig = {}
 ): AxiosRequestConfig => ({

BIN=BIN
dump.rdb


+ 39 - 8
package-lock.json

@@ -1880,6 +1880,15 @@
       "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==",
       "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": {
       "version": "1.2.3",
       "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz",
@@ -10350,6 +10359,22 @@
         "prepend-http": "^1.0.0",
         "query-string": "^4.1.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": {
@@ -11946,12 +11971,13 @@
       "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
     },
     "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": {
-        "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": {
@@ -13110,6 +13136,11 @@
         "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": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
@@ -13224,9 +13255,9 @@
       "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI="
     },
     "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": {
       "version": "1.1.3",

+ 2 - 0
package.json

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