poeti8 6 лет назад
Родитель
Сommit
ec96da0652
100 измененных файлов с 2852 добавлено и 3337 удалено
  1. 8 1
      .babelrc
  2. 3 3
      .eslintrc
  3. 1 1
      .example.env
  4. 8 0
      .prettierrc
  5. 21 21
      client/actions/auth.js
  6. 26 0
      client/components/ALink.tsx
  7. 87 0
      client/components/BodyWrapper.tsx
  8. 0 97
      client/components/BodyWrapper/BodyWrapper.js
  9. 0 1
      client/components/BodyWrapper/index.js
  10. 138 0
      client/components/Button.tsx
  11. 0 155
      client/components/Button/Button.js
  12. 0 1
      client/components/Button/index.js
  13. 97 0
      client/components/Checkbox.tsx
  14. 0 108
      client/components/Checkbox/Checkbox.js
  15. 0 1
      client/components/Checkbox/index.js
  16. 17 22
      client/components/Error.tsx
  17. 0 1
      client/components/Error/index.js
  18. 20 31
      client/components/Extensions.tsx
  19. 0 1
      client/components/Extensions/index.js
  20. 21 29
      client/components/Features.tsx
  21. 0 1
      client/components/Features/index.js
  22. 24 40
      client/components/FeaturesItem.tsx
  23. 87 0
      client/components/Footer.tsx
  24. 0 84
      client/components/Footer/Footer.js
  25. 0 1
      client/components/Footer/index.js
  26. 32 0
      client/components/Header.tsx
  27. 0 58
      client/components/Header/Header.js
  28. 0 89
      client/components/Header/HeaderRightMenu.js
  29. 0 1
      client/components/Header/index.js
  30. 10 23
      client/components/HeaderLeftMenu.tsx
  31. 5 11
      client/components/HeaderLogo.tsx
  32. 4 8
      client/components/HeaderMenuItem.tsx
  33. 83 0
      client/components/HeaderRightMenu.tsx
  34. 0 174
      client/components/Login/Login.js
  35. 0 31
      client/components/Login/LoginBox.js
  36. 0 20
      client/components/Login/LoginInputLabel.js
  37. 0 1
      client/components/Login/index.js
  38. 15 15
      client/components/Modal.tsx
  39. 0 1
      client/components/Modal/index.js
  40. 18 29
      client/components/NeedToLogin.tsx
  41. 0 1
      client/components/NeedToLogin/index.js
  42. 10 12
      client/components/PageLoading.tsx
  43. 0 1
      client/components/PageLoading/index.js
  44. 3 6
      client/components/ReCaptcha.tsx
  45. 0 334
      client/components/Settings/Settings.js
  46. 208 0
      client/components/Settings/Settings.tsx
  47. 0 117
      client/components/Settings/SettingsApi.js
  48. 96 0
      client/components/Settings/SettingsApi.tsx
  49. 0 98
      client/components/Settings/SettingsBan.js
  50. 77 0
      client/components/Settings/SettingsBan.tsx
  51. 0 174
      client/components/Settings/SettingsDomain.js
  52. 173 0
      client/components/Settings/SettingsDomain.tsx
  53. 0 59
      client/components/Settings/SettingsPassword.js
  54. 76 0
      client/components/Settings/SettingsPassword.tsx
  55. 6 7
      client/components/Settings/SettingsWelcome.tsx
  56. 0 1
      client/components/Settings/index.js
  57. 1 0
      client/components/Settings/index.tsx
  58. 0 173
      client/components/Shortener/Shortener.js
  59. 176 0
      client/components/Shortener/Shortener.tsx
  60. 22 11
      client/components/Shortener/ShortenerInput.tsx
  61. 0 134
      client/components/Shortener/ShortenerOptions.js
  62. 128 0
      client/components/Shortener/ShortenerOptions.tsx
  63. 0 127
      client/components/Shortener/ShortenerResult.js
  64. 120 0
      client/components/Shortener/ShortenerResult.tsx
  65. 0 0
      client/components/Shortener/ShortenerTitle.tsx
  66. 0 0
      client/components/Shortener/index.tsx
  67. 0 172
      client/components/Stats/Stats.js
  68. 149 0
      client/components/Stats/Stats.tsx
  69. 16 10
      client/components/Stats/StatsCharts/Area.tsx
  70. 20 9
      client/components/Stats/StatsCharts/Bar.tsx
  71. 10 8
      client/components/Stats/StatsCharts/Pie.tsx
  72. 33 16
      client/components/Stats/StatsCharts/StatsCharts.tsx
  73. 0 0
      client/components/Stats/StatsCharts/index.tsx
  74. 0 71
      client/components/Stats/StatsCharts/withTitle.js
  75. 75 0
      client/components/Stats/StatsCharts/withTitle.tsx
  76. 8 14
      client/components/Stats/StatsError.tsx
  77. 29 30
      client/components/Stats/StatsHead.tsx
  78. 0 0
      client/components/Stats/index.tsx
  79. 146 0
      client/components/Table.tsx
  80. 67 55
      client/components/Table/TBody/TBody.tsx
  81. 8 16
      client/components/Table/TBody/TBodyButton.tsx
  82. 0 112
      client/components/Table/TBody/TBodyCount.js
  83. 80 0
      client/components/Table/TBody/TBodyCount.tsx
  84. 19 19
      client/components/Table/TBody/TBodyShortUrl.tsx
  85. 0 0
      client/components/Table/TBody/index.tsx
  86. 0 55
      client/components/Table/THead/THead.js
  87. 53 0
      client/components/Table/THead/THead.tsx
  88. 0 0
      client/components/Table/THead/index.tsx
  89. 0 164
      client/components/Table/Table.js
  90. 0 204
      client/components/Table/TableOptions.js
  91. 0 1
      client/components/Table/index.js
  92. 23 25
      client/components/TableNav.tsx
  93. 168 0
      client/components/TableOptions.tsx
  94. 30 0
      client/components/Text.tsx
  95. 57 40
      client/components/TextInput.tsx
  96. 0 1
      client/components/TextInput/index.js
  97. 10 0
      client/consts/consts.ts
  98. 1 0
      client/consts/index.ts
  99. 15 0
      client/hooks.ts
  100. 14 0
      client/module.d.ts

+ 8 - 1
.babelrc

@@ -1,4 +1,11 @@
 {
   "presets": ["next/babel", "@zeit/next-typescript/babel"],
-  "plugins": [["styled-components", { "ssr": true, "displayName": true, "preprocess": false }]]
+  "plugins": [
+    [
+      "styled-components",
+      { "ssr": true, "displayName": true, "preprocess": false }
+    ],
+    "@babel/plugin-proposal-optional-chaining",
+    "@babel/plugin-proposal-nullish-coalescing-operator"
+  ]
 }

+ 3 - 3
.eslintrc

@@ -6,7 +6,7 @@
   ],
   "parser": "@typescript-eslint/parser",
   "parserOptions": {
-    "project": "./tsconfig.server.json",
+    "project": ["./tsconfig.server.json", "./client/tsconfig.json"]
   },
   "plugins": ["@typescript-eslint"],
   "rules": {
@@ -39,5 +39,5 @@
     "react": {
       "version": "detect"
     }
-  },
-}
+  }
+}

+ 1 - 1
.example.env

@@ -70,7 +70,7 @@ MAIL_FROM=
 MAIL_PASSWORD=
 
 # The email address that will receive submitted reports.
-REPORT_MAIL=
+REPORT_EMAIL=
 
 # Support email to show on the app
 CONTACT_EMAIL=

+ 8 - 0
.prettierrc

@@ -0,0 +1,8 @@
+{
+  "useTabs": false,
+  "tabWidth": 2,
+  "trailingComma": "none",
+  "singleQuote": false,
+  "printWidth": 80,
+  "endOfLine": "lf"
+}

+ 21 - 21
client/actions/auth.js

@@ -1,7 +1,7 @@
-import Router from 'next/router';
-import axios from 'axios';
-import cookie from 'js-cookie';
-import decodeJwt from 'jwt-decode';
+import Router from "next/router";
+import axios from "axios";
+import cookie from "js-cookie";
+import decodeJwt from "jwt-decode";
 import {
   SET_DOMAIN,
   SHOW_PAGE_LOADING,
@@ -12,8 +12,8 @@ import {
   AUTH_ERROR,
   LOGIN_LOADING,
   SIGNUP_LOADING,
-  AUTH_RENEW,
-} from './actionTypes';
+  AUTH_RENEW
+} from "./actionTypes";
 
 const setDomain = payload => ({ type: SET_DOMAIN, payload });
 
@@ -23,7 +23,7 @@ export const authUser = payload => ({ type: AUTH_USER, payload });
 export const unauthUser = () => ({ type: UNAUTH_USER });
 export const sentVerification = payload => ({
   type: SENT_VERIFICATION,
-  payload,
+  payload
 });
 export const showAuthError = payload => ({ type: AUTH_ERROR, payload });
 export const showLoginLoading = () => ({ type: LOGIN_LOADING });
@@ -34,8 +34,8 @@ export const signupUser = payload => async dispatch => {
   dispatch(showSignupLoading());
   try {
     const {
-      data: { email },
-    } = await axios.post('/api/auth/signup', payload);
+      data: { email }
+    } = await axios.post("/api/auth/signup", payload);
     dispatch(sentVerification(email));
   } catch ({ response }) {
     dispatch(showAuthError(response.data.error));
@@ -46,14 +46,14 @@ export const loginUser = payload => async dispatch => {
   dispatch(showLoginLoading());
   try {
     const {
-      data: { token },
-    } = await axios.post('/api/auth/login', payload);
-    cookie.set('token', token, { expires: 7 });
+      data: { token }
+    } = await axios.post("/api/auth/login", payload);
+    cookie.set("token", token, { expires: 7 });
     dispatch(authRenew());
     dispatch(authUser(decodeJwt(token)));
     dispatch(setDomain({ customDomain: decodeJwt(token).domain }));
     dispatch(showPageLoading());
-    Router.push('/');
+    Router.push("/");
   } catch ({ response }) {
     dispatch(showAuthError(response.data.error));
   }
@@ -61,9 +61,9 @@ export const loginUser = payload => async dispatch => {
 
 export const logoutUser = () => dispatch => {
   dispatch(showPageLoading());
-  cookie.remove('token');
+  cookie.remove("token");
   dispatch(unauthUser());
-  Router.push('/login');
+  Router.push("/login");
 };
 
 export const renewAuthUser = () => async (dispatch, getState) => {
@@ -72,21 +72,21 @@ export const renewAuthUser = () => async (dispatch, getState) => {
   }
 
   const options = {
-    method: 'POST',
-    headers: { Authorization: cookie.get('token') },
-    url: '/api/auth/renew',
+    method: "POST",
+    headers: { Authorization: cookie.get("token") },
+    url: "/api/auth/renew"
   };
 
   try {
     const {
-      data: { token },
+      data: { token }
     } = await axios(options);
-    cookie.set('token', token, { expires: 7 });
+    cookie.set("token", token, { expires: 7 });
     dispatch(authRenew());
     dispatch(authUser(decodeJwt(token)));
     dispatch(setDomain({ customDomain: decodeJwt(token).domain }));
   } catch (error) {
-    cookie.remove('token');
+    cookie.remove("token");
     dispatch(unauthUser());
   }
 };

+ 26 - 0
client/components/ALink.tsx

@@ -0,0 +1,26 @@
+import styled from "styled-components";
+import { Box, BoxProps } from "reflexbox/styled-components";
+
+interface Props extends BoxProps {
+  href?: string;
+  title?: string;
+  target?: string;
+}
+const ALink = styled(Box).attrs({
+  as: "a"
+})<Props>`
+  cursor: pointer;
+  color: #2196f3;
+  border-bottom: 1px dotted transparent;
+  text-decoration: none;
+
+  :hover {
+    border-bottom-color: #2196f3;
+  }
+`;
+
+ALink.defaultProps = {
+  pb: "1px"
+};
+
+export default ALink;

+ 87 - 0
client/components/BodyWrapper.tsx

@@ -0,0 +1,87 @@
+import React, { FC, useEffect } from "react";
+import { bindActionCreators } from "redux";
+import { connect } from "react-redux";
+import styled from "styled-components";
+import cookie from "js-cookie";
+import { Flex } from "reflexbox/styled-components";
+
+import Header from "./Header";
+import PageLoading from "./PageLoading";
+import { renewAuthUser } from "../actions";
+import { initGA, logPageView } from "../helpers/analytics";
+import { useStoreState } from "../store";
+
+interface Props {
+  norenew?: boolean;
+  pageLoading: boolean;
+  renewAuthUser: any; // TODO: better typing;
+}
+
+const Wrapper = styled.div`
+  position: relative;
+  min-height: 100vh;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  box-sizing: border-box;
+
+  * {
+    box-sizing: border-box;
+  }
+
+  *::-moz-focus-inner {
+    border: none;
+  }
+
+  @media only screen and (max-width: 448px) {
+    font-size: 14px;
+  }
+`;
+
+const BodyWrapper: FC<Props> = ({ children, norenew, renewAuthUser }) => {
+  const loading = useStoreState(s => s.loading.loading);
+
+  useEffect(() => {
+    // FIXME: types bro
+    if (process.env.GOOGLE_ANALYTICS) {
+      if (!(window as any).GA_INITIALIZED) {
+        initGA();
+        (window as any).GA_INITIALIZED = true;
+      }
+      logPageView();
+    }
+
+    const token = cookie.get("token");
+    if (!token || norenew) return undefined;
+    renewAuthUser(token);
+  }, []);
+
+  const content = loading ? <PageLoading /> : children;
+  return (
+    <Wrapper>
+      <Flex
+        minHeight="100vh"
+        width={1}
+        flex="0 0 auto"
+        alignItems="center"
+        flexDirection="column"
+      >
+        <Header />
+        {content}
+      </Flex>
+    </Wrapper>
+  );
+};
+
+BodyWrapper.defaultProps = {
+  norenew: false
+};
+
+const mapDispatchToProps = dispatch => ({
+  renewAuthUser: bindActionCreators(renewAuthUser, dispatch)
+});
+
+export default connect(
+  null,
+  mapDispatchToProps
+)(BodyWrapper);

+ 0 - 97
client/components/BodyWrapper/BodyWrapper.js

@@ -1,97 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { bindActionCreators } from 'redux';
-import { connect } from 'react-redux';
-import styled from 'styled-components';
-import cookie from 'js-cookie';
-import Header from '../Header';
-import PageLoading from '../PageLoading';
-import { renewAuthUser, hidePageLoading } from '../../actions';
-import { initGA, logPageView } from '../../helpers/analytics';
-
-const Wrapper = styled.div`
-  position: relative;
-  min-height: 100vh;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  box-sizing: border-box;
-
-  * {
-    box-sizing: border-box;
-  }
-
-  *::-moz-focus-inner {
-    border: none;
-  }
-
-  @media only screen and (max-width: 448px) {
-    font-size: 14px;
-  }
-`;
-
-const ContentWrapper = styled.div`
-  min-height: 100vh;
-  width: 100%;
-  flex: 0 0 auto;
-  display: flex;
-  align-items: center;
-  flex-direction: column;
-  box-sizing: border-box;
-`;
-
-class BodyWrapper extends React.Component {
-  componentDidMount() {
-    if (process.env.GOOGLE_ANALYTICS) {
-      if (!window.GA_INITIALIZED) {
-        initGA();
-        window.GA_INITIALIZED = true;
-      }
-      logPageView();
-    }
-
-    const token = cookie.get('token');
-    this.props.hidePageLoading();
-    if (!token || this.props.norenew) return null;
-    return this.props.renewAuthUser(token);
-  }
-
-  render() {
-    const { children, pageLoading } = this.props;
-
-    const content = pageLoading ? <PageLoading /> : children;
-
-    return (
-      <Wrapper>
-        <ContentWrapper>
-          <Header />
-          {content}
-        </ContentWrapper>
-      </Wrapper>
-    );
-  }
-}
-
-BodyWrapper.propTypes = {
-  children: PropTypes.node.isRequired,
-  hidePageLoading: PropTypes.func.isRequired,
-  norenew: PropTypes.bool,
-  pageLoading: PropTypes.bool.isRequired,
-  renewAuthUser: PropTypes.func.isRequired,
-};
-
-BodyWrapper.defaultProps = {
-  norenew: false,
-};
-
-const mapStateToProps = ({ loading: { page: pageLoading } }) => ({ pageLoading });
-
-const mapDispatchToProps = dispatch => ({
-  hidePageLoading: bindActionCreators(hidePageLoading, dispatch),
-  renewAuthUser: bindActionCreators(renewAuthUser, dispatch),
-});
-
-export default connect(
-  mapStateToProps,
-  mapDispatchToProps
-)(BodyWrapper);

+ 0 - 1
client/components/BodyWrapper/index.js

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

+ 138 - 0
client/components/Button.tsx

@@ -0,0 +1,138 @@
+import React, { FC } from "react";
+import styled, { css } from "styled-components";
+import { switchProp, prop, ifProp } from "styled-tools";
+import { Flex, BoxProps } from "reflexbox/styled-components";
+
+// TODO: another solution for inline SVG
+import SVG from "react-inlinesvg";
+
+import { spin } from "../helpers/animations";
+
+interface Props extends BoxProps {
+  color?: "purple" | "gray" | "blue";
+  disabled?: boolean;
+  icon?: string; // TODO: better typing
+  isRound?: boolean;
+  onClick?: any; // TODO: better typing
+  type?: "button" | "submit" | "reset";
+}
+
+const StyledButton = styled(Flex)<Props>`
+  position: relative;
+  align-items: center;
+  justify-content: center;
+  font-weight: normal;
+  text-align: center;
+  line-height: 1;
+  word-break: keep-all;
+  color: white;
+  background: ${switchProp(prop("color", "blue"), {
+    blue: "linear-gradient(to right, #42a5f5, #2979ff)",
+    purple: "linear-gradient(to right, #7e57c2, #6200ea)",
+    gray: "linear-gradient(to right, #e0e0e0, #bdbdbd)"
+  })};
+  box-shadow: ${switchProp(prop("color", "blue"), {
+    blue: "0 5px 6px rgba(66, 165, 245, 0.5)",
+    purple: "0 5px 6px rgba(81, 45, 168, 0.5)",
+    gray: "0 5px 6px rgba(160, 160, 160, 0.5)"
+  })};
+  border: none;
+  border-radius: 100px;
+  transition: all 0.4s ease-out;
+  cursor: pointer;
+  overflow: hidden;
+
+  :hover,
+  :focus {
+    outline: none;
+    box-shadow: ${switchProp(prop("color", "blue"), {
+      blue: "0 6px 15px rgba(66, 165, 245, 0.5)",
+      purple: "0 6px 15px rgba(81, 45, 168, 0.5)",
+      gray: "0 6px 15px rgba(160, 160, 160, 0.5)"
+    })};
+    transform: translateY(-2px) scale(1.02, 1.02);
+  }
+
+  a & {
+    text-decoration: none;
+    border: none;
+  }
+
+  ${ifProp(
+    { size: "big" },
+    css`
+      height: 56px;
+      @media only screen and (max-width: 448px) {
+        height: 40px;
+      }
+    `
+  )}
+`;
+
+const Icon = styled(SVG)`
+  svg {
+    width: 16px;
+    height: 16px;
+    margin-right: 12px;
+    stroke: ${ifProp({ color: "gray" }, "#444", "#fff")};
+
+    ${ifProp(
+      { icon: "loader" },
+      css`
+        width: 20px;
+        height: 20px;
+        margin: 0;
+        animation: ${spin} 1s linear infinite;
+      `
+    )}
+
+    ${ifProp(
+      "isRound",
+      css`
+        width: 15px;
+        height: 15px;
+        margin: 0;
+      `
+    )}
+
+    @media only screen and (max-width: 768px) {
+      width: 12px;
+      height: 12px;
+      margin-right: 6px;
+    }
+  }
+`;
+
+const Button: FC<Props> = props => {
+  const SVGIcon = props.icon ? (
+    <Icon
+      icon={props.icon}
+      isRound={props.isRound}
+      color={props.color}
+      src={`/images/${props.icon}.svg`}
+    />
+  ) : (
+    ""
+  );
+  return (
+    <StyledButton {...props}>
+      {SVGIcon}
+      {props.icon !== "loader" && props.children}
+    </StyledButton>
+  );
+};
+
+Button.defaultProps = {
+  as: "button",
+  width: "auto",
+  flex: "0 0 auto",
+  height: [32, 40],
+  py: 0,
+  px: [24, 32],
+  fontSize: [12, 13],
+  color: "blue",
+  icon: "",
+  isRound: false
+};
+
+export default Button;

+ 0 - 155
client/components/Button/Button.js

@@ -1,155 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import styled, { css } from 'styled-components';
-import SVG from 'react-inlinesvg';
-import { spin } from '../../helpers/animations';
-
-const StyledButton = styled.button`
-  position: relative;
-  height: 40px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  padding: 0px 32px;
-  font-size: 13px;
-  font-weight: normal;
-  text-align: center;
-  line-height: 1;
-  word-break: keep-all;
-  color: white;
-  background: linear-gradient(to right, #42a5f5, #2979ff);
-  box-shadow: 0 5px 6px rgba(66, 165, 245, 0.5);
-  border: none;
-  border-radius: 100px;
-  transition: all 0.4s ease-out;
-  cursor: pointer;
-  overflow: hidden;
-
-  :hover,
-  :focus {
-    outline: none;
-    box-shadow: 0 6px 15px rgba(66, 165, 245, 0.5);
-    transform: translateY(-2px) scale(1.02, 1.02);
-  }
-
-  a & {
-    text-decoration: none;
-    border: none;
-  }
-
-  @media only screen and (max-width: 448px) {
-    height: 32px;
-    padding: 0 24px;
-    font-size: 12px;
-  }
-
-  ${({ color }) => {
-    if (color === 'purple') {
-      return css`
-        background: linear-gradient(to right, #7e57c2, #6200ea);
-        box-shadow: 0 5px 6px rgba(81, 45, 168, 0.5);
-
-        :focus,
-        :hover {
-          box-shadow: 0 6px 15px rgba(81, 45, 168, 0.5);
-        }
-      `;
-    }
-    if (color === 'gray') {
-      return css`
-        color: black;
-        background: linear-gradient(to right, #e0e0e0, #bdbdbd);
-        box-shadow: 0 5px 6px rgba(160, 160, 160, 0.5);
-
-        :focus,
-        :hover {
-          box-shadow: 0 6px 15px rgba(160, 160, 160, 0.5);
-        }
-      `;
-    }
-    return null;
-  }};
-
-  ${({ big }) =>
-    big &&
-    css`
-      height: 56px;
-      @media only screen and (max-width: 448px) {
-        height: 40px;
-      }
-    `};
-`;
-
-const Icon = styled(SVG)`
-  svg {
-    width: 16px;
-    height: 16px;
-    margin-right: 12px;
-    stroke: #fff;
-
-    ${({ type }) =>
-      type === 'loader' &&
-      css`
-        width: 20px;
-        height: 20px;
-        margin: 0;
-        animation: ${spin} 1s linear infinite;
-      `};
-
-    ${({ round }) =>
-      round &&
-      css`
-        width: 15px;
-        height: 15px;
-        margin: 0;
-      `};
-
-    ${({ color }) =>
-      color === 'gray' &&
-      css`
-        stroke: #444;
-      `};
-
-    @media only screen and (max-width: 768px) {
-      width: 12px;
-      height: 12px;
-      margin-right: 6px;
-    }
-  }
-`;
-
-const Button = props => {
-  const SVGIcon = props.icon ? (
-    <Icon
-      type={props.icon}
-      round={props.round}
-      color={props.color}
-      src={`/images/${props.icon}.svg`}
-    />
-  ) : (
-    ''
-  );
-  return (
-    <StyledButton {...props}>
-      {SVGIcon}
-      {props.icon !== 'loader' && props.children}
-    </StyledButton>
-  );
-};
-
-Button.propTypes = {
-  children: PropTypes.node.isRequired,
-  color: PropTypes.string,
-  icon: PropTypes.string,
-  round: PropTypes.bool,
-  type: PropTypes.string,
-};
-
-Button.defaultProps = {
-  color: 'blue',
-  icon: '',
-  type: '',
-  round: false,
-};
-
-export default Button;

+ 0 - 1
client/components/Button/index.js

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

+ 97 - 0
client/components/Checkbox.tsx

@@ -0,0 +1,97 @@
+import React, { FC } from "react";
+import styled, { css } from "styled-components";
+import { ifProp } from "styled-tools";
+import { Flex, BoxProps } from "reflexbox/styled-components";
+
+import Text from "./Text";
+
+interface InputProps {
+  checked: boolean;
+  id?: string;
+  name: string;
+}
+
+const Input = styled(Flex).attrs({
+  as: "input",
+  type: "checkbox",
+  m: 0,
+  p: 0,
+  width: 0,
+  height: 0,
+  opacity: 0
+})<InputProps>`
+  position: relative;
+  opacity: 0;
+`;
+
+const Box = styled(Flex).attrs({
+  alignItems: "center",
+  justifyContent: "center"
+})<{ checked: boolean }>`
+  position: relative;
+  transition: color 0.3s ease-out;
+  border-radius: 4px;
+  background-color: white;
+  box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2);
+  cursor: pointer;
+
+  input:focus + & {
+    outline: 3px solid rgba(65, 164, 245, 0.5);
+  }
+
+  ${ifProp(
+    "checked",
+    css`
+      box-shadow: 0 3px 5px rgba(50, 50, 50, 0.4);
+
+      :after {
+        content: "";
+        position: absolute;
+        width: 80%;
+        height: 80%;
+        display: block;
+        border-radius: 2px;
+        background-color: #9575cd;
+        box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2);
+        cursor: pointer;
+      }
+    `
+  )}
+`;
+
+interface Props extends InputProps, BoxProps {
+  label: string;
+}
+
+const Checkbox: FC<Props> = ({
+  checked,
+  height,
+  id,
+  label,
+  name,
+  width,
+  ...rest
+}) => {
+  return (
+    <Flex
+      flex="0 0 auto"
+      as="label"
+      alignItems="center"
+      style={{ cursor: "pointer" }}
+      {...(rest as any)}
+    >
+      <Input name={name} id={id} checked={checked} />
+      <Box checked={checked} width={width} height={height} />
+      <Text as="span" ml={2}>
+        {label}
+      </Text>
+    </Flex>
+  );
+};
+
+Checkbox.defaultProps = {
+  width: [18],
+  height: [18]
+};
+
+export default Checkbox;

+ 0 - 108
client/components/Checkbox/Checkbox.js

@@ -1,108 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import styled, { css } from 'styled-components';
-
-const Wrapper = styled.div`
-  display: flex;
-  justify-content: flex-start;
-  align-items: center;
-  margin: 16px 0 16px;
-
-  ${({ withMargin }) =>
-    withMargin &&
-    css`
-      margin: 24px 16px 24px;
-    `};
-
-  :first-child {
-    margin-left: 0;
-  }
-
-  :last-child {
-    margin-right: 0;
-  }
-`;
-
-const Box = styled.span`
-  position: relative;
-  display: flex;
-  align-items: center;
-  font-weight: normal;
-  color: #666;
-  transition: color 0.3s ease-out;
-  cursor: pointer;
-
-  :hover {
-    color: black;
-  }
-  :before {
-    content: '';
-    display: block;
-    width: 18px;
-    height: 18px;
-    margin-right: 10px;
-    border-radius: 4px;
-    background-color: white;
-    box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2);
-    cursor: pointer;
-
-    @media only screen and (max-width: 768px) {
-      width: 14px;
-      height: 14px;
-      margin-right: 8px;
-    }
-  }
-
-  ${({ checked }) =>
-    checked &&
-    css`
-      :before {
-        box-shadow: 0 3px 5px rgba(50, 50, 50, 0.4);
-      }
-      :after {
-        content: '';
-        position: absolute;
-        left: 2px;
-        top: 4px;
-        width: 14px;
-        height: 14px;
-        display: block;
-        margin-right: 10px;
-        border-radius: 2px;
-        background-color: #9575cd;
-        box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2);
-        cursor: pointer;
-
-        @media only screen and (max-width: 768px) {
-          left: 2px;
-          top: 5px;
-          width: 10px;
-          height: 10px;
-        }
-      }
-    `};
-`;
-
-const Checkbox = ({ checked, label, id, withMargin, onClick }) => (
-  <Wrapper withMargin={withMargin}>
-    <Box checked={checked} id={id} onClick={onClick}>
-      {label}
-    </Box>
-  </Wrapper>
-);
-
-Checkbox.propTypes = {
-  checked: PropTypes.bool,
-  withMargin: PropTypes.bool,
-  label: PropTypes.string.isRequired,
-  id: PropTypes.string.isRequired,
-  onClick: PropTypes.func,
-};
-
-Checkbox.defaultProps = {
-  withMargin: true,
-  checked: false,
-  onClick: f => f,
-};
-
-export default Checkbox;

+ 0 - 1
client/components/Checkbox/index.js

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

+ 17 - 22
client/components/Error/Error.js → client/components/Error.tsx

@@ -1,14 +1,25 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 import { connect } from 'react-redux';
 import styled, { css } from 'styled-components';
-import { fadeIn } from '../../helpers/animations';
+import { prop } from 'styled-tools';
 
-const ErrorMessage = styled.p`
+import { fadeIn } from '../helpers/animations';
+
+interface Message {
+  bottom?: number;
+  left?: number;
+}
+
+interface Props extends Message {
+  error: any;
+  type: string;
+}
+
+const ErrorMessage = styled.p<Message>`
   content: '';
   position: absolute;
   right: 36px;
-  bottom: -64px;
+  bottom: ${prop('bottom', -64)}px;
   display: block;
   font-size: 14px;
   color: red;
@@ -26,15 +37,9 @@ const ErrorMessage = styled.p`
       right: auto;
       left: ${left}px;
     `};
-
-  ${({ bottom }) =>
-    bottom &&
-    css`
-      bottom: ${bottom}px;
-    `};
 `;
 
-const Error = ({ bottom, error, left, type }) => {
+const Error: FC<Props> = ({ bottom, error, left, type }) => {
   const message = error[type] && (
     <ErrorMessage left={left} bottom={bottom}>
       {error[type]}
@@ -43,16 +48,6 @@ const Error = ({ bottom, error, left, type }) => {
   return <div>{message}</div>;
 };
 
-Error.propTypes = {
-  bottom: PropTypes.number,
-  error: PropTypes.shape({
-    auth: PropTypes.string.isRequired,
-    shortener: PropTypes.string.isRequired,
-  }).isRequired,
-  type: PropTypes.string.isRequired,
-  left: PropTypes.number,
-};
-
 Error.defaultProps = {
   bottom: -64,
   left: -1,

+ 0 - 1
client/components/Error/index.js

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

+ 20 - 31
client/components/Extensions/Extensions.js → client/components/Extensions.tsx

@@ -1,35 +1,18 @@
 import React from 'react';
 import styled from 'styled-components';
-import SVG from 'react-inlinesvg';
-
-const Section = styled.div`
-  position: relative;
-  width: 100%;
-  flex: 0 0 auto;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  margin: 0;
-  padding: 90px 0 100px;
+import { Flex } from 'reflexbox/styled-components';
+import SVG from 'react-inlinesvg'; // TODO: another solution
+
+const Section = styled(Flex).attrs({
+  width: 1,
+  flex: '0 0 auto',
+  flexWrap: ['wrap', 'wrap', 'nowrap'],
+  flexDirection: 'column',
+  alignItems: 'center',
+  m: 0,
+  p: ['48px 0 16px', '48px 0 16px', '90px 0 100px'],
+})`
   background-color: #282828;
-
-  @media only screen and (max-width: 768px) {
-    margin: 0;
-    padding: 48px 0 16px;
-    flex-wrap: wrap;
-  }
-`;
-
-const Wrapper = styled.div`
-  width: 1200px;
-  max-width: 100%;
-  flex: 1 1 auto;
-  display: flex;
-  justify-content: center;
-
-  @media only screen and (max-width: 1200px) {
-    flex-wrap: wrap;
-  }
 `;
 
 const Title = styled.h3`
@@ -119,7 +102,13 @@ const Icon = styled(SVG)`
 const Extensions = () => (
   <Section>
     <Title>Browser extensions.</Title>
-    <Wrapper>
+    <Flex
+      width={1200}
+      maxWidth="100%"
+      flex="1 1 auto"
+      justifyContent="center"
+      flexWrap={['wrap', 'wrap', 'nowrap']}
+    >
       <Link
         href="https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd"
         target="_blank"
@@ -140,7 +129,7 @@ const Extensions = () => (
           <span>Download for Firefox</span>
         </FirefoxButton>
       </Link>
-    </Wrapper>
+    </Flex>
   </Section>
 );
 

+ 0 - 1
client/components/Extensions/index.js

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

+ 21 - 29
client/components/Features/Features.js → client/components/Features.tsx

@@ -1,35 +1,20 @@
 import React from 'react';
 import styled from 'styled-components';
+import { Flex } from 'reflexbox/styled-components';
+
 import FeaturesItem from './FeaturesItem';
 
-const Section = styled.div`
+const Section = styled(Flex).attrs({
+  width: 1,
+  flex: '0 0 auto',
+  flexDirection: 'column',
+  alignItems: 'center',
+  m: 0,
+  p: ['64px 0 16px', '64px 0 16px', '64px 0 16px', '102px 0 110px'],
+  flexWrap: ['wrap', 'wrap', 'wrap', 'nowrap'],
+})`
   position: relative;
-  width: 100%;
-  flex: 0 0 auto;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  margin: 0;
-  padding: 102px 0 110px;
   background-color: #eaeaea;
-
-  @media only screen and (max-width: 768px) {
-    margin: 0;
-    padding: 64px 0 16px;
-    flex-wrap: wrap;
-  }
-`;
-
-const Wrapper = styled.div`
-  width: 1200px;
-  max-width: 100%;
-  flex: 1 1 auto;
-  display: flex;
-  justify-content: center;
-
-  @media only screen and (max-width: 1200px) {
-    flex-wrap: wrap;
-  }
 `;
 
 const Title = styled.h3`
@@ -51,9 +36,16 @@ const Title = styled.h3`
 const Features = () => (
   <Section>
     <Title>Kutting edge features.</Title>
-    <Wrapper>
+    <Flex
+      width={1200}
+      maxWidth="100%"
+      flex="1 1 auto"
+      justifyContent="center"
+      flexWrap={['wrap', 'wrap', 'wrap', 'nowrap']}
+    >
       <FeaturesItem title="Managing links" icon="edit">
-        Create, protect and delete your links and monitor them with detailed statistics.
+        Create, protect and delete your links and monitor them with detailed
+        statistics.
       </FeaturesItem>
       <FeaturesItem title="Custom domain" icon="navigation">
         Use custom domains for your links. Add or remove them for free.
@@ -64,7 +56,7 @@ const Features = () => (
       <FeaturesItem title="Free &amp; open source" icon="heart">
         Completely open source and free. You can host it on your own server.
       </FeaturesItem>
-    </Wrapper>
+    </Flex>
   </Section>
 );
 

+ 0 - 1
client/components/Features/index.js

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

+ 24 - 40
client/components/Features/FeaturesItem.js → client/components/FeaturesItem.tsx

@@ -1,47 +1,37 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 import styled from 'styled-components';
-import { fadeIn } from '../../helpers/animations';
-
-const Block = styled.div`
-  max-width: 25%;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  padding: 0 24px;
+import { Flex } from 'reflexbox/styled-components';
+
+import { fadeIn } from '../helpers/animations';
+
+interface Props {
+  title: string;
+  icon: string; // TODO: better typing
+}
+
+const Block = styled(Flex).attrs({
+  maxWidth: ['100%', '100%', '50%', '25%'],
+  flexDirection: 'column',
+  alignItems: 'center',
+  p: '0 24px',
+  mb: [48, 48, 48, 0],
+})`
   animation: ${fadeIn} 0.8s ease-out;
 
   :last-child {
     margin-right: 0;
   }
-
-  @media only screen and (max-width: 1200px) {
-    margin-bottom: 48px;
-  }
-
-  @media only screen and (max-width: 980px) {
-    max-width: 50%;
-  }
-
-  @media only screen and (max-width: 760px) {
-    max-width: 100%;
-  }
 `;
 
-const IconBox = styled.div`
-  width: 48px;
-  height: 48px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
+const IconBox = styled(Flex).attrs({
+  width: [40, 40, 48],
+  height: [40, 40, 48],
+  alignItems: 'center',
+  justifyContent: 'center',
+})`
   border-radius: 100%;
   box-sizing: border-box;
   background-color: #2196f3;
-
-  @media only screen and (max-width: 448px) {
-    width: 40px;
-    height: 40px;
-  }
 `;
 
 const Icon = styled.img`
@@ -78,7 +68,7 @@ const Description = styled.p`
   }
 `;
 
-const FeaturesItem = ({ children, icon, title }) => (
+const FeaturesItem: FC<Props> = ({ children, icon, title }) => (
   <Block>
     <IconBox>
       <Icon src={`/images/${icon}.svg`} />
@@ -88,10 +78,4 @@ const FeaturesItem = ({ children, icon, title }) => (
   </Block>
 );
 
-FeaturesItem.propTypes = {
-  children: PropTypes.node.isRequired,
-  icon: PropTypes.string.isRequired,
-  title: PropTypes.string.isRequired,
-};
-
 export default FeaturesItem;

+ 87 - 0
client/components/Footer.tsx

@@ -0,0 +1,87 @@
+import React, { FC, useEffect } from 'react';
+import { connect } from 'react-redux';
+import styled from 'styled-components';
+import { Flex } from 'reflexbox/styled-components';
+
+import ReCaptcha from './ReCaptcha';
+import showRecaptcha from '../helpers/recaptcha';
+import { ifProp } from 'styled-tools';
+
+interface Props {
+  isAuthenticated: boolean;
+}
+
+const Wrapper = styled(Flex).attrs({
+  as: 'footer',
+  width: 1,
+  flexDirection: 'column',
+  justifyContent: 'center',
+  alignItems: 'center',
+})<Props>`
+  padding: 4px 0 ${ifProp('isAuthenticated', '8px', '24px')};
+  background-color: white;
+
+  a {
+    text-decoration: none;
+    color: #2196f3;
+  }
+`;
+
+const Text = styled.p`
+  font-size: 13px;
+  font-weight: 300;
+  color: #666;
+
+  @media only screen and (max-width: 768px) {
+    font-size: 11px;
+  }
+`;
+
+const Footer: FC<Props> = ({ isAuthenticated }) => {
+  useEffect(() => {
+    showRecaptcha();
+  }, []);
+
+  return (
+    <Wrapper isAuthenticated={isAuthenticated}>
+      {!isAuthenticated && <ReCaptcha />}
+      <Text>
+        Made with love by{' '}
+        <a href="//thedevs.network/" title="The Devs">
+          The Devs
+        </a>
+        .{' | '}
+        <a
+          href="https://github.com/thedevs-network/kutt"
+          title="GitHub"
+          target="_blank"
+        >
+          GitHub
+        </a>
+        {' | '}
+        <a href="/terms" title="Terms of Service">
+          Terms of Service
+        </a>
+        {' | '}
+        <a href="/report" title="Report abuse">
+          Report Abuse
+        </a>
+        {process.env.CONTACT_EMAIL && (
+          <>
+            {' | '}
+            <a href={`mailto:${process.env.CONTACT_EMAIL}`} title="Contact us">
+              Contact us
+            </a>
+          </>
+        )}
+        .
+      </Text>
+    </Wrapper>
+  );
+};
+
+const mapStateToProps = ({ auth: { isAuthenticated } }) => ({
+  isAuthenticated,
+});
+
+export default connect(mapStateToProps)(Footer);

+ 0 - 84
client/components/Footer/Footer.js

@@ -1,84 +0,0 @@
-import React, { Component, Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import styled from 'styled-components';
-import ReCaptcha from './ReCaptcha';
-import showRecaptcha from '../../helpers/recaptcha';
-
-const Wrapper = styled.footer`
-  width: 100%;
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  align-items: center;
-  padding: 4px 0 ${({ isAuthenticated }) => (isAuthenticated ? '8px' : '24px')};
-  background-color: white;
-
-  a {
-    text-decoration: none;
-    color: #2196f3;
-  }
-`;
-
-const Text = styled.p`
-  font-size: 13px;
-  font-weight: 300;
-  color: #666;
-
-  @media only screen and (max-width: 768px) {
-    font-size: 11px;
-  }
-`;
-
-class Footer extends Component {
-  componentDidMount() {
-    showRecaptcha();
-  }
-
-  render() {
-    return (
-      <Wrapper isAuthenticated={this.props.isAuthenticated}>
-        {!this.props.isAuthenticated && <ReCaptcha />}
-        <Text>
-          Made with love by{' '}
-          <a href="//thedevs.network/" title="The Devs">
-            The Devs
-          </a>
-          .{' | '}
-          <a
-            href="https://github.com/thedevs-network/kutt"
-            title="GitHub"
-            target="_blank"
-          >
-            GitHub
-          </a>
-          {' | '}
-          <a href="/terms" title="Terms of Service">
-            Terms of Service
-          </a>
-          {' | '}
-          <a href="/report" title="Report abuse">
-            Report Abuse
-          </a>
-          {process.env.CONTACT_EMAIL && (
-            <Fragment>
-              {' | '}
-              <a href={`mailto:${process.env.CONTACT_EMAIL}`} title="Contact us">
-                Contact us
-              </a>
-            </Fragment>
-          )}
-          .
-        </Text>
-      </Wrapper>
-    );
-  }
-}
-
-Footer.propTypes = {
-  isAuthenticated: PropTypes.bool.isRequired,
-};
-
-const mapStateToProps = ({ auth: { isAuthenticated } }) => ({ isAuthenticated });
-
-export default connect(mapStateToProps)(Footer);

+ 0 - 1
client/components/Footer/index.js

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

+ 32 - 0
client/components/Header.tsx

@@ -0,0 +1,32 @@
+import React, { FC } from "react";
+import { bindActionCreators } from "redux";
+import { connect } from "react-redux";
+import styled from "styled-components";
+import { Flex } from "reflexbox/styled-components";
+
+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"]}
+  >
+    <Flex
+      flexDirection={["column", "column", "row"]}
+      alignItems={["flex-start", "flex-start", "stretch"]}
+    >
+      <HeaderLogo />
+      <HeaderLeftMenu />
+    </Flex>
+    <HeaderRightMenu />
+  </Flex>
+);
+
+export default Header;

+ 0 - 58
client/components/Header/Header.js

@@ -1,58 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { bindActionCreators } from 'redux';
-import { connect } from 'react-redux';
-import styled from 'styled-components';
-import HeaderLogo from './HeaderLogo';
-import HeaderLeftMenu from './HeaderLeftMenu';
-import HeaderRightMenu from './HeaderRightMenu';
-import { showPageLoading } from '../../actions';
-
-const Wrapper = styled.header`
-  display: flex;
-  width: 1232px;
-  max-width: 100%;
-  padding: 0 32px;
-  height: 102px;
-  justify-content: space-between;
-  align-items: center;
-
-  @media only screen and (max-width: 768px) {
-    height: auto;
-    align-items: flex-start;
-    padding: 16px;
-    margin-bottom: 32px;
-  }
-`;
-
-const LeftMenuWrapper = styled.div`
-  display: flex;
-
-  @media only screen and (max-width: 488px) {
-    flex-direction: column;
-    align-items: flex-start;
-  }
-`;
-
-const Header = props => (
-  <Wrapper>
-    <LeftMenuWrapper>
-      <HeaderLogo showPageLoading={props.showPageLoading} />
-      <HeaderLeftMenu />
-    </LeftMenuWrapper>
-    <HeaderRightMenu showPageLoading={props.showPageLoading} />
-  </Wrapper>
-);
-
-Header.propTypes = {
-  showPageLoading: PropTypes.func.isRequired,
-};
-
-const mapDispatchToProps = dispatch => ({
-  showPageLoading: bindActionCreators(showPageLoading, dispatch),
-});
-
-export default connect(
-  null,
-  mapDispatchToProps
-)(Header);

+ 0 - 89
client/components/Header/HeaderRightMenu.js

@@ -1,89 +0,0 @@
-import React from 'react';
-import { bindActionCreators } from 'redux';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import Router from 'next/router';
-import styled from 'styled-components';
-import HeaderMenuItem from './HeaderMenuItem';
-import { logoutUser, showPageLoading } from '../../actions';
-import Button from '../Button';
-
-const List = styled.ul`
-  display: flex;
-  float: right;
-  justify-content: flex-end;
-  align-items: center;
-  margin: 0;
-  padding: 0;
-  list-style: none;
-`;
-
-const ReportLink = styled.a`
-  display: none;
-  @media only screen and (max-width: 488px) {
-    display: block;
-  }
-`;
-
-const HeaderMenu = props => {
-  const goTo = e => {
-    e.preventDefault();
-    const path = e.currentTarget.getAttribute('href');
-    if (!path || window.location.pathname === path) return;
-    props.showPageLoading();
-    Router.push(path);
-  };
-
-  const login = !props.auth.isAuthenticated && (
-    <HeaderMenuItem>
-      <a href="/login" title="login / signup" onClick={goTo}>
-        <Button>Login / Sign up</Button>
-      </a>
-    </HeaderMenuItem>
-  );
-  const logout = props.auth.isAuthenticated && (
-    <HeaderMenuItem>
-      <a href="/logout" title="logout" onClick={goTo}>
-        Log out
-      </a>
-    </HeaderMenuItem>
-  );
-  const settings = props.auth.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>
-  );
-};
-
-HeaderMenu.propTypes = {
-  auth: PropTypes.shape({
-    isAuthenticated: PropTypes.bool.isRequired,
-  }).isRequired,
-  showPageLoading: PropTypes.func.isRequired,
-};
-
-const mapStateToProps = ({ auth }) => ({ auth });
-
-const mapDispatchToProps = dispatch => ({
-  logoutUser: bindActionCreators(logoutUser, dispatch),
-  showPageLoading: bindActionCreators(showPageLoading, dispatch),
-});
-
-export default connect(
-  mapStateToProps,
-  mapDispatchToProps
-)(HeaderMenu);

+ 0 - 1
client/components/Header/index.js

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

+ 10 - 23
client/components/Header/HeaderLeftMenu.js → client/components/HeaderLeftMenu.tsx

@@ -1,11 +1,10 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { bindActionCreators } from 'redux';
-import { connect } from 'react-redux';
-import styled from 'styled-components';
-import Router from 'next/router';
-import HeaderMenuItem from './HeaderMenuItem';
-import { showPageLoading } from '../../actions';
+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;
@@ -19,12 +18,11 @@ const List = styled.ul`
   }
 `;
 
-const HeaderLeftMenu = props => {
+const HeaderLeftMenu: FC = () => {
   const goTo = e => {
     e.preventDefault();
-    const path = e.currentTarget.getAttribute('href');
+    const path = e.currentTarget.getAttribute("href");
     if (!path || window.location.pathname === path) return;
-    props.showPageLoading();
     Router.push(path);
   };
   return (
@@ -48,15 +46,4 @@ const HeaderLeftMenu = props => {
   );
 };
 
-HeaderLeftMenu.propTypes = {
-  showPageLoading: PropTypes.func.isRequired,
-};
-
-const mapDispatchToProps = dispatch => ({
-  showPageLoading: bindActionCreators(showPageLoading, dispatch),
-});
-
-export default connect(
-  null,
-  mapDispatchToProps
-)(HeaderLeftMenu);
+export default HeaderLeftMenu;

+ 5 - 11
client/components/Header/HeaderLogo.js → client/components/HeaderLogo.tsx

@@ -1,7 +1,6 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Router from 'next/router';
-import styled from 'styled-components';
+import React, { FC } from "react";
+import Router from "next/router";
+import styled from "styled-components";
 
 const LogoImage = styled.div`
   & > a {
@@ -28,12 +27,11 @@ const LogoImage = styled.div`
   }
 `;
 
-const HeaderLogo = props => {
+const HeaderLogo: FC = () => {
   const goTo = e => {
     e.preventDefault();
-    const path = e.target.getAttribute('href');
+    const path = e.target.getAttribute("href");
     if (window.location.pathname === path) return;
-    props.showPageLoading();
     Router.push(path);
   };
 
@@ -47,8 +45,4 @@ const HeaderLogo = props => {
   );
 };
 
-HeaderLogo.propTypes = {
-  showPageLoading: PropTypes.func.isRequired,
-};
-
 export default HeaderLogo;

+ 4 - 8
client/components/Header/HeaderMenuItem.js → client/components/HeaderMenuItem.tsx

@@ -1,7 +1,7 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 import styled from 'styled-components';
-import { fadeIn } from '../../helpers/animations';
+
+import { fadeIn } from '../helpers/animations';
 
 const ListItem = styled.li`
   margin-left: 32px;
@@ -25,14 +25,10 @@ const ListLink = styled.div`
   }
 `;
 
-const HeaderMenuItem = ({ children }) => (
+const HeaderMenuItem: FC = ({ children }) => (
   <ListItem>
     <ListLink>{children}</ListLink>
   </ListItem>
 );
 
-HeaderMenuItem.propTypes = {
-  children: PropTypes.node.isRequired,
-};
-
 export default HeaderMenuItem;

+ 83 - 0
client/components/HeaderRightMenu.tsx

@@ -0,0 +1,83 @@
+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);

+ 0 - 174
client/components/Login/Login.js

@@ -1,174 +0,0 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import Router from 'next/router';
-import { bindActionCreators } from 'redux';
-import { connect } from 'react-redux';
-import styled from 'styled-components';
-import emailValidator from 'email-validator';
-import LoginBox from './LoginBox';
-import LoginInputLabel from './LoginInputLabel';
-import TextInput from '../TextInput';
-import Button from '../Button';
-import Error from '../Error';
-import { loginUser, showAuthError, signupUser, showPageLoading } from '../../actions';
-
-const Wrapper = styled.div`
-  flex: 0 0 auto;
-  display: flex;
-  align-items: center;
-  margin: 24px 0 64px;
-`;
-
-const ButtonWrapper = styled.div`
-  display: flex;
-  justify-content: space-between;
-  & > * {
-    flex: 1 1 0;
-  }
-  & > *:last-child {
-    margin-left: 32px;
-  }
-  @media only screen and (max-width: 768px) {
-    & > *:last-child {
-      margin-left: 16px;
-    }
-  }
-`;
-
-const VerificationMsg = styled.p`
-  font-size: 24px;
-  font-weight: 300;
-`;
-
-const User = styled.span`
-  font-weight: normal;
-  color: #512da8;
-  border-bottom: 1px dotted #999;
-`;
-
-const ForgetPassLink = styled.a`
-  align-self: flex-start;
-  margin: -24px 0 32px;
-  font-size: 14px;
-  text-decoration: none;
-  color: #2196f3;
-  border-bottom: 1px dotted transparent;
-
-  :hover {
-    border-bottom-color: #2196f3;
-  }
-`;
-
-class Login extends Component {
-  constructor() {
-    super();
-    this.authHandler = this.authHandler.bind(this);
-    this.loginHandler = this.loginHandler.bind(this);
-    this.signupHandler = this.signupHandler.bind(this);
-    this.goTo = this.goTo.bind(this);
-  }
-
-  goTo(e) {
-    e.preventDefault();
-    const path = e.currentTarget.getAttribute('href');
-    this.props.showPageLoading();
-    Router.push(path);
-  }
-
-  authHandler(type) {
-    const { loading, showError } = this.props;
-    if (loading.login || loading.signup) return null;
-    const form = document.getElementById('login-form');
-    const { value: email } = form.elements.email;
-    const { value: password } = form.elements.password;
-    if (!email) return showError('Email address must not be empty.');
-    if (!emailValidator.validate(email)) return showError('Email address is not valid.');
-    if (password.trim().length < 8) {
-      return showError('Password must be at least 8 chars long.');
-    }
-    return type === 'login'
-      ? this.props.login({ email, password })
-      : this.props.signup({ email, password });
-  }
-
-  loginHandler(e) {
-    e.preventDefault();
-    this.authHandler('login');
-  }
-
-  signupHandler(e) {
-    e.preventDefault();
-    this.authHandler('signup');
-  }
-
-  render() {
-    return (
-      <Wrapper>
-        {this.props.auth.sentVerification ? (
-          <VerificationMsg>
-            A verification email has been sent to <User>{this.props.auth.user}</User>.
-          </VerificationMsg>
-        ) : (
-          <LoginBox id="login-form" onSubmit={this.loginHandler}>
-            <LoginInputLabel htmlFor="email" test="test">
-              Email address
-            </LoginInputLabel>
-            <TextInput type="email" name="email" id="email" autoFocus />
-            <LoginInputLabel htmlFor="password">Password (min chars: 8)</LoginInputLabel>
-            <TextInput type="password" name="password" id="password" />
-            <ForgetPassLink href="/reset-password" title="Forget password" onClick={this.goTo}>
-              Forgot your password?
-            </ForgetPassLink>
-            <ButtonWrapper>
-              <Button
-                icon={this.props.loading.login ? 'loader' : 'login'}
-                onClick={this.loginHandler}
-                big
-              >
-                Log in
-              </Button>
-              <Button
-                icon={this.props.loading.signup ? 'loader' : 'signup'}
-                color="purple"
-                onClick={this.signupHandler}
-                big
-              >
-                Sign up
-              </Button>
-            </ButtonWrapper>
-            <Error type="auth" left={0} />
-          </LoginBox>
-        )}
-      </Wrapper>
-    );
-  }
-}
-
-Login.propTypes = {
-  auth: PropTypes.shape({
-    sentVerification: PropTypes.bool.isRequired,
-    user: PropTypes.string.isRequired,
-  }).isRequired,
-  loading: PropTypes.shape({
-    login: PropTypes.bool.isRequired,
-    signup: PropTypes.bool.isRequired,
-  }).isRequired,
-  login: PropTypes.func.isRequired,
-  signup: PropTypes.func.isRequired,
-  showError: PropTypes.func.isRequired,
-  showPageLoading: PropTypes.func.isRequired,
-};
-
-const mapStateToProps = ({ auth, loading }) => ({ auth, loading });
-
-const mapDispatchToProps = dispatch => ({
-  login: bindActionCreators(loginUser, dispatch),
-  signup: bindActionCreators(signupUser, dispatch),
-  showError: bindActionCreators(showAuthError, dispatch),
-  showPageLoading: bindActionCreators(showPageLoading, dispatch),
-});
-
-export default connect(
-  mapStateToProps,
-  mapDispatchToProps
-)(Login);

+ 0 - 31
client/components/Login/LoginBox.js

@@ -1,31 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import styled from 'styled-components';
-import { fadeIn } from '../../helpers/animations';
-
-const Box = styled.form`
-  position: relative;
-  flex-basis: 400px;
-  display: flex;
-  flex-direction: column;
-  justify-content: space-between;
-  align-items: stretch;
-  animation: ${fadeIn} 0.8s ease-out;
-
-  input {
-    margin-bottom: 48px;
-  }
-  @media only screen and (max-width: 768px) {
-    input {
-      margin-bottom: 32px;
-    }
-  }
-`;
-
-const LoginBox = ({ children, ...props }) => <Box {...props}>{children}</Box>;
-
-LoginBox.propTypes = {
-  children: PropTypes.node.isRequired,
-};
-
-export default LoginBox;

+ 0 - 20
client/components/Login/LoginInputLabel.js

@@ -1,20 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import styled from 'styled-components';
-
-const Label = styled.div`
-  margin-bottom: 8px;
-`;
-
-const LoginInputLabel = ({ children, htmlFor }) => (
-  <Label>
-    <label htmlFor={htmlFor}>{children}</label>
-  </Label>
-);
-
-LoginInputLabel.propTypes = {
-  children: PropTypes.node.isRequired,
-  htmlFor: PropTypes.string.isRequired,
-};
-
-export default LoginInputLabel;

+ 0 - 1
client/components/Login/index.js

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

+ 15 - 15
client/components/Modal/Modal.js → client/components/Modal.tsx

@@ -1,7 +1,14 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 import styled from 'styled-components';
-import Button from '../Button';
+import { Flex } from 'reflexbox/styled-components';
+
+import Button from './Button';
+
+interface Props {
+  close: any; // TODO: typing
+  handler?: any; // TODO: typing
+  show?: boolean;
+}
 
 const Wrapper = styled.div`
   position: fixed;
@@ -28,16 +35,16 @@ const Content = styled.div`
   }
 `;
 
-const ButtonsWrapper = styled.div`
-  display: flex;
-  justify-content: center;
-  margin-top: 40px;
+const ButtonsWrapper = styled(Flex).attrs({
+  justifyContent: 'center',
+  mt: 40,
+})`
   button {
     margin: 0 16px;
   }
 `;
 
-const Modal = ({ children, handler, show, close }) =>
+const Modal: FC<Props> = ({ children, handler, show, close }) =>
   show ? (
     <Wrapper>
       <Content>
@@ -52,13 +59,6 @@ const Modal = ({ children, handler, show, close }) =>
     </Wrapper>
   ) : null;
 
-Modal.propTypes = {
-  children: PropTypes.node.isRequired,
-  close: PropTypes.func.isRequired,
-  handler: PropTypes.func,
-  show: PropTypes.bool,
-};
-
 Modal.defaultProps = {
   show: false,
   handler: null,

+ 0 - 1
client/components/Modal/index.js

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

+ 18 - 29
client/components/NeedToLogin/NeedToLogin.js → client/components/NeedToLogin.tsx

@@ -1,40 +1,24 @@
 import React from 'react';
 import Link from 'next/link';
 import styled from 'styled-components';
-import Button from '../Button';
-import { fadeIn } from '../../helpers/animations';
+import { Flex } from 'reflexbox/styled-components';
 
-const Wrapper = styled.div`
-  position: relative;
-  width: 1200px;
-  max-width: 98%;
-  display: flex;
-  align-items: center;
-  margin: 16px 0 0;
+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'],
+})`
   animation: ${fadeIn} 0.8s ease-out;
   box-sizing: border-box;
 
   a {
     text-decoration: none;
   }
-
-  @media only screen and (max-width: 768px) {
-    flex-direction: column;
-    align-items: center;
-  }
-`;
-
-const TitleWrapper = styled.div`
-  display: flex;
-  flex-direction: column;
-  align-items: flex-start;
-  margin-top: -32px;
-
-  @media only screen and (max-width: 768px) {
-    flex-direction: column;
-    align-items: center;
-    margin-bottom: 32px;
-  }
 `;
 
 const Title = styled.h2`
@@ -72,7 +56,12 @@ const Image = styled.img`
 
 const NeedToLogin = () => (
   <Wrapper>
-    <TitleWrapper>
+    <Flex
+      flexDirection="column"
+      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>
@@ -81,7 +70,7 @@ const NeedToLogin = () => (
           <Button>Login / Signup</Button>
         </a>
       </Link>
-    </TitleWrapper>
+    </Flex>
     <Image src="/images/callout.png" />
   </Wrapper>
 );

+ 0 - 1
client/components/NeedToLogin/index.js

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

+ 10 - 12
client/components/PageLoading/PageLoading.js → client/components/PageLoading.tsx

@@ -1,16 +1,8 @@
 import React from 'react';
 import styled from 'styled-components';
-import { spin } from '../../helpers/animations';
+import { Flex } from 'reflexbox/styled-components';
 
-const Loading = styled.div`
-  margin: 0 0 48px;
-  flex: 1 1 auto;
-  flex-basis: 250px;
-  display: flex;
-  align-self: center;
-  align-items: center;
-  justify-content: center;
-`;
+import { spin } from '../helpers/animations';
 
 const Icon = styled.img`
   display: block;
@@ -20,9 +12,15 @@ const Icon = styled.img`
 `;
 
 const pageLoading = () => (
-  <Loading>
+  <Flex
+    flex="1 1 250px"
+    alignItems="center"
+    alignSelf="center"
+    justifyContent="center"
+    margin="0 0 48px"
+  >
     <Icon src="/images/loader.svg" />
-  </Loading>
+  </Flex>
 );
 
 export default pageLoading;

+ 0 - 1
client/components/PageLoading/index.js

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

+ 3 - 6
client/components/Footer/ReCaptcha.js → client/components/ReCaptcha.tsx

@@ -1,10 +1,6 @@
 import React from 'react';
 import styled from 'styled-components';
-
-const Recaptcha = styled.div`
-  display: flex;
-  margin: 54px 0 16px;
-`;
+import { Flex } from 'reflexbox/styled-components';
 
 const ReCaptcha = () => {
   if (process.env.NODE_ENV !== 'production') {
@@ -12,7 +8,8 @@ const ReCaptcha = () => {
   }
 
   return (
-    <Recaptcha
+    <Flex
+      margin="54px 0 16px"
       id="g-recaptcha"
       className="g-recaptcha"
       data-sitekey={process.env.RECAPTCHA_SITE_KEY}

+ 0 - 334
client/components/Settings/Settings.js

@@ -1,334 +0,0 @@
-import React, { Component, Fragment } from 'react';
-import PropTypes from 'prop-types';
-import Router from 'next/router';
-import { bindActionCreators } from 'redux';
-import { connect } from 'react-redux';
-import styled from 'styled-components';
-import cookie from 'js-cookie';
-import axios from 'axios';
-import SettingsWelcome from './SettingsWelcome';
-import SettingsDomain from './SettingsDomain';
-import SettingsPassword from './SettingsPassword';
-import SettingsBan from './SettingsBan';
-import SettingsApi from './SettingsApi';
-import Modal from '../Modal';
-import { fadeIn } from '../../helpers/animations';
-import {
-  deleteCustomDomain,
-  generateApiKey,
-  getUserSettings,
-  setCustomDomain,
-  showDomainInput,
-  banUrl,
-} from '../../actions';
-
-const Wrapper = styled.div`
-  poistion: relative;
-  width: 600px;
-  max-width: 90%;
-  display: flex;
-  flex-direction: column;
-  align-items: flex-start;
-  padding: 0 0 80px;
-  animation: ${fadeIn} 0.8s ease;
-
-  > * {
-    max-width: 100%;
-  }
-
-  hr {
-    width: 100%;
-    height: 1px;
-    outline: none;
-    border: none;
-    background-color: #e3e3e3;
-    margin: 24px 0;
-
-    @media only screen and (max-width: 768px) {
-      margin: 12px 0;
-    }
-  }
-  h3 {
-    font-size: 24px;
-    margin: 32px 0 16px;
-
-    @media only screen and (max-width: 768px) {
-      font-size: 18px;
-    }
-  }
-  p {
-    margin: 24px 0;
-  }
-  a {
-    margin: 32px 0 0;
-    color: #2196f3;
-    text-decoration: none;
-
-    :hover {
-      color: #2196f3;
-      border-bottom: 1px dotted #2196f3;
-    }
-  }
-`;
-
-class Settings extends Component {
-  constructor() {
-    super();
-    this.state = {
-      showModal: false,
-      passwordMessage: '',
-      passwordError: '',
-      isCopied: false,
-      ban: {
-        domain: false,
-        error: '',
-        host: false,
-        loading: false,
-        message: '',
-        user: false,
-      },
-    };
-    this.onSubmitBan = this.onSubmitBan.bind(this);
-    this.onChangeBanCheckboxes = this.onChangeBanCheckboxes.bind(this);
-    this.handleCustomDomain = this.handleCustomDomain.bind(this);
-    this.handleCheckbox = this.handleCheckbox.bind(this);
-    this.deleteDomain = this.deleteDomain.bind(this);
-    this.showModal = this.showModal.bind(this);
-    this.onCopy = this.onCopy.bind(this);
-    this.closeModal = this.closeModal.bind(this);
-    this.changePassword = this.changePassword.bind(this);
-  }
-
-  componentDidMount() {
-    if (!this.props.auth.isAuthenticated) Router.push('/login');
-    this.props.getUserSettings();
-  }
-
-  async onSubmitBan(e) {
-    e.preventDefault();
-    const {
-      ban: { domain, host, user },
-    } = this.state;
-    this.setState(state => ({
-      ban: {
-        ...state.ban,
-        loading: true,
-      },
-    }));
-    const id = e.currentTarget.elements.id.value;
-    let message;
-    let error;
-    try {
-      message = await this.props.banUrl({
-        id,
-        domain,
-        host,
-        user,
-      });
-    } catch (err) {
-      error = err;
-    }
-    this.setState(
-      state => ({
-        ban: {
-          ...state.ban,
-          loading: false,
-          message,
-          error,
-        },
-      }),
-      () => {
-        setTimeout(() => {
-          this.setState(state => ({
-            ban: {
-              ...state.ban,
-              loading: false,
-              message: '',
-              error: '',
-            },
-          }));
-        }, 2000);
-      }
-    );
-  }
-
-  onCopy() {
-    this.setState({ isCopied: true });
-    setTimeout(() => {
-      this.setState({ isCopied: false });
-    }, 1500);
-  }
-
-  onChangeBanCheckboxes(type) {
-    return e => {
-      const { checked } = e.target;
-      this.setState(state => ({
-        ban: {
-          ...state.ban,
-          [type]: !checked,
-        },
-      }));
-    };
-  }
-
-  handleCustomDomain(e) {
-    e.preventDefault();
-    if (this.props.domainLoading) return null;
-    const customDomain = e.currentTarget.elements.customdomain.value;
-    const homepage = e.currentTarget.elements.homepage.value;
-    return this.props.setCustomDomain({ customDomain, homepage });
-  }
-
-  handleCheckbox({ target: { id, checked } }) {
-    this.setState({ [id]: !checked });
-  }
-
-  deleteDomain() {
-    this.closeModal();
-    this.props.deleteCustomDomain();
-  }
-
-  showModal() {
-    this.setState({ showModal: true });
-  }
-
-  closeModal() {
-    this.setState({ showModal: false });
-  }
-
-  changePassword(e) {
-    e.preventDefault();
-    const form = e.target;
-    const password = form.elements.password.value;
-    if (password.length < 8) {
-      return this.setState({ passwordError: 'Password must be at least 8 chars long.' }, () => {
-        setTimeout(() => {
-          this.setState({
-            passwordError: '',
-          });
-        }, 1500);
-      });
-    }
-    return axios
-      .post(
-        '/api/auth/changepassword',
-        { password },
-        { headers: { Authorization: cookie.get('token') } }
-      )
-      .then(res =>
-        this.setState({ passwordMessage: res.data.message }, () => {
-          setTimeout(() => {
-            this.setState({ passwordMessage: '' });
-          }, 1500);
-          form.reset();
-        })
-      )
-      .catch(err =>
-        this.setState({ passwordError: err.response.data.error }, () => {
-          setTimeout(() => {
-            this.setState({
-              passwordError: '',
-            });
-          }, 1500);
-        })
-      );
-  }
-
-  render() {
-    const {
-      auth: { user, admin },
-    } = this.props;
-    return (
-      <Wrapper>
-        <SettingsWelcome user={user} />
-        <hr />
-        {admin && (
-          <Fragment>
-            <SettingsBan
-              {...this.state.ban}
-              onSubmitBan={this.onSubmitBan}
-              onChangeBanCheckboxes={this.onChangeBanCheckboxes}
-            />
-            <hr />
-          </Fragment>
-        )}
-        <SettingsDomain
-          handleCustomDomain={this.handleCustomDomain}
-          handleCheckbox={this.handleCheckbox}
-          loading={this.props.domainLoading}
-          settings={this.props.settings}
-          showDomainInput={this.props.showDomainInput}
-          showModal={this.showModal}
-        />
-        <hr />
-        <SettingsPassword
-          message={this.state.passwordMessage}
-          error={this.state.passwordError}
-          changePassword={this.changePassword}
-        />
-        <hr />
-        <SettingsApi
-          loader={this.props.apiLoading}
-          generateKey={this.props.generateApiKey}
-          apikey={this.props.settings.apikey}
-          isCopied={this.state.isCopied}
-          onCopy={this.onCopy}
-        />
-        <Modal show={this.state.showModal} close={this.closeModal} handler={this.deleteDomain}>
-          Are you sure do you want to delete the domain?
-        </Modal>
-      </Wrapper>
-    );
-  }
-}
-
-Settings.propTypes = {
-  auth: PropTypes.shape({
-    admin: PropTypes.bool.isRequired,
-    isAuthenticated: PropTypes.bool.isRequired,
-    user: PropTypes.string.isRequired,
-  }).isRequired,
-  apiLoading: PropTypes.bool,
-  deleteCustomDomain: PropTypes.func.isRequired,
-  domainLoading: PropTypes.bool,
-  banUrl: PropTypes.func.isRequired,
-  setCustomDomain: PropTypes.func.isRequired,
-  generateApiKey: PropTypes.func.isRequired,
-  getUserSettings: PropTypes.func.isRequired,
-  settings: PropTypes.shape({
-    apikey: PropTypes.string.isRequired,
-    customDomain: PropTypes.string.isRequired,
-    domainInput: PropTypes.bool.isRequired,
-  }).isRequired,
-  showDomainInput: PropTypes.func.isRequired,
-};
-
-Settings.defaultProps = {
-  apiLoading: false,
-  domainLoading: false,
-};
-
-const mapStateToProps = ({
-  auth,
-  loading: { api: apiLoading, domain: domainLoading },
-  settings,
-}) => ({
-  auth,
-  apiLoading,
-  domainLoading,
-  settings,
-});
-
-const mapDispatchToProps = dispatch => ({
-  banUrl: bindActionCreators(banUrl, dispatch),
-  deleteCustomDomain: bindActionCreators(deleteCustomDomain, dispatch),
-  setCustomDomain: bindActionCreators(setCustomDomain, dispatch),
-  generateApiKey: bindActionCreators(generateApiKey, dispatch),
-  getUserSettings: bindActionCreators(getUserSettings, dispatch),
-  showDomainInput: bindActionCreators(showDomainInput, dispatch),
-});
-
-export default connect(
-  mapStateToProps,
-  mapDispatchToProps
-)(Settings);

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

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

+ 0 - 117
client/components/Settings/SettingsApi.js

@@ -1,117 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import styled, { css } from 'styled-components';
-import { CopyToClipboard } from 'react-copy-to-clipboard';
-import Button from '../Button';
-import { fadeIn } from '../../helpers/animations';
-
-const Wrapper = styled.div`
-  display: flex;
-  flex-direction: column;
-  align-items: flex-start;
-`;
-
-const ApiKeyWrapper = styled.div`
-  position: relative;
-  display: flex;
-  align-items: center;
-  margin: 16px 0;
-
-  button {
-    margin-right: 16px;
-  }
-
-  ${({ apikey }) =>
-    apikey &&
-    css`
-      flex-direction: column;
-      align-items: flex-start;
-      > span {
-        margin-bottom: 32px;
-      }
-    `};
-
-  @media only screen and (max-width: 768px) {
-    width: 100%;
-    overflow-wrap: break-word;
-  }
-`;
-
-const KeyWrapper = styled.div`
-  max-width: 100%;
-  display: flex;
-  flex-wrap: wrap;
-  align-items: center;
-  margin-bottom: 16px;
-`;
-
-const ApiKey = styled.span`
-  max-width: 100%;
-  margin-right: 16px;
-  font-size: 16px;
-  font-weight: bold;
-  border-bottom: 2px dotted #999;
-
-  @media only screen and (max-width: 768px) {
-    font-size: 14px;
-  }
-
-  @media only screen and (max-width: 520px) {
-    margin-bottom: 16px;
-  }
-`;
-
-const Link = styled.a`
-  margin: 16px 0;
-
-  @media only screen and (max-width: 768px) {
-    margin: 8px 0;
-  }
-`;
-
-const CopyMessage = styled.p`
-  position: absolute;
-  top: -42px;
-  left: 0;
-  font-size: 14px;
-  color: #689f38;
-  animation: ${fadeIn} 0.3s ease-out;
-`;
-
-const SettingsApi = ({ apikey, generateKey, loader, isCopied, onCopy }) => (
-  <Wrapper>
-    <h3>API</h3>
-    <p>
-      In additional to this website, you can use the API to create, delete and get shortend URLs. If
-      {" you're"} not familiar with API, {"don't"} generate the key. DO NOT share this key on the
-      client side of your website.
-    </p>
-    <ApiKeyWrapper apikey={apikey}>
-      {isCopied && <CopyMessage>Copied to clipboard.</CopyMessage>}
-      {apikey && (
-        <KeyWrapper>
-          <ApiKey>{apikey}</ApiKey>
-          <CopyToClipboard text={apikey} onCopy={onCopy}>
-            <Button icon="copy">Copy</Button>
-          </CopyToClipboard>
-        </KeyWrapper>
-      )}
-      <Button color="purple" icon={loader ? 'loader' : 'zap'} onClick={generateKey}>
-        {apikey ? 'Regenerate' : 'Generate'} key
-      </Button>
-    </ApiKeyWrapper>
-    <Link href="https://github.com/thedevs-network/kutt#api" title="API Docs" target="_blank">
-      Read API docs
-    </Link>
-  </Wrapper>
-);
-
-SettingsApi.propTypes = {
-  apikey: PropTypes.string.isRequired,
-  loader: PropTypes.bool.isRequired,
-  isCopied: PropTypes.bool.isRequired,
-  generateKey: PropTypes.func.isRequired,
-  onCopy: PropTypes.func.isRequired,
-};
-
-export default SettingsApi;

+ 96 - 0
client/components/Settings/SettingsApi.tsx

@@ -0,0 +1,96 @@
+import React, { FC, useState } from "react";
+import styled from "styled-components";
+import { CopyToClipboard } from "react-copy-to-clipboard";
+import { Flex } from "reflexbox/styled-components";
+
+import Button from "../Button";
+import Text from "../Text";
+import ALink from "../ALink";
+import { useStoreState, useStoreActions } from "../../store";
+
+const ApiKey = styled(Text).attrs({
+  mr: 3,
+  fontSize: [14, 16],
+  fontWeight: 700
+})`
+  max-width: 100%;
+  border-bottom: 2px dotted #999;
+`;
+
+const SettingsApi: FC = () => {
+  const [copied, setCopied] = useState(false);
+  const [loading, setLoading] = useState(false);
+  const apikey = useStoreState(s => s.settings.apikey);
+  const generateApiKey = useStoreActions(s => s.settings.generateApiKey);
+
+  const onCopy = () => {
+    setCopied(true);
+    setTimeout(() => {
+      setCopied(false);
+    }, 1500);
+  };
+
+  const onSubmit = async () => {
+    setLoading(true);
+    await generateApiKey();
+    setLoading(false);
+  };
+
+  return (
+    <Flex flexDirection="column" alignItems="flex-start">
+      <Text as="h2" fontWeight={700} mb={4}>
+        API
+      </Text>
+      <Text as="p" mb={4}>
+        In additional to this website, you can use the API to create, delete and
+        get shortend URLs. If
+        {" you're"} not familiar with API, {"don't"} generate the key. DO NOT
+        share this key on the client side of your website.{" "}
+        <ALink
+          href="https://github.com/thedevs-network/kutt#api"
+          title="API Docs"
+          target="_blank"
+        >
+          Read API docs.
+        </ALink>
+      </Text>
+      {apikey && (
+        <Flex flexDirection="column" style={{ position: "relative" }} my={3}>
+          {copied && (
+            <Text
+              as="p"
+              color="green"
+              fontSize={14}
+              style={{ position: "absolute", top: -24 }}
+            >
+              Copied to clipboard.
+            </Text>
+          )}
+          <Flex
+            maxWidth="100%"
+            flexDirection={["column", "column", "row"]}
+            flexWrap="wrap"
+            alignItems={["flex-start", "flex-start", "center"]}
+            mb={16}
+          >
+            <ApiKey>{apikey}</ApiKey>
+            <CopyToClipboard text={apikey} onCopy={onCopy}>
+              <Button icon="copy" height={36} mt={[3, 3, 0]}>
+                Copy
+              </Button>
+            </CopyToClipboard>
+          </Flex>
+        </Flex>
+      )}
+      <Button
+        color="purple"
+        icon={loading ? "loader" : "zap"}
+        onClick={onSubmit}
+      >
+        {loading ? "Generating..." : apikey ? "Regenerate" : "Generate"} key
+      </Button>
+    </Flex>
+  );
+};
+
+export default SettingsApi;

+ 0 - 98
client/components/Settings/SettingsBan.js

@@ -1,98 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import styled from 'styled-components';
-import TextInput from '../TextInput';
-import Button from '../Button';
-import Checkbox from '../Checkbox';
-
-const Form = styled.form`
-  position: relative;
-  display: flex;
-  flex-direction: column;
-  margin: 32px 0;
-
-  input {
-    flex: 0 0 auto;
-    margin-right: 16px;
-  }
-`;
-
-const InputWrapper = styled.div`
-  display: flex;
-`;
-
-const Message = styled.div`
-  position: absolute;
-  left: 0;
-  bottom: -32px;
-  color: green;
-`;
-
-const Error = styled.div`
-  position: absolute;
-  left: 0;
-  bottom: -32px;
-  color: red;
-`;
-
-const SettingsBan = props => (
-  <div>
-    <h3>Ban link</h3>
-    <Form onSubmit={props.onSubmitBan}>
-      <InputWrapper>
-        <Message>{props.message}</Message>
-        <TextInput
-          id="id"
-          name="id"
-          type="text"
-          placeholder="Link ID (e.g. K7b2A)"
-          height={44}
-          small
-        />
-        <Button type="submit" icon={props.loading ? 'loader' : 'lock'} disabled={props.loading}>
-          {props.loading ? 'Baning...' : 'Ban'}
-        </Button>
-      </InputWrapper>
-      <div>
-        <Checkbox
-          id="user"
-          name="user"
-          label="Ban user (and all of their links)"
-          withMargin={false}
-          checked={props.user}
-          onClick={props.onChangeBanCheckboxes('user')}
-        />
-        <Checkbox
-          id="domain"
-          name="domain"
-          label="Ban domain"
-          withMargin={false}
-          checked={props.domain}
-          onClick={props.onChangeBanCheckboxes('domain')}
-        />
-        <Checkbox
-          id="host"
-          name="host"
-          label="Ban Host/IP"
-          withMargin={false}
-          checked={props.host}
-          onClick={props.onChangeBanCheckboxes('host')}
-        />
-      </div>
-      <Error>{props.error}</Error>
-    </Form>
-  </div>
-);
-
-SettingsBan.propTypes = {
-  domain: PropTypes.bool.isRequired,
-  error: PropTypes.string.isRequired,
-  host: PropTypes.bool.isRequired,
-  loading: PropTypes.bool.isRequired,
-  message: PropTypes.string.isRequired,
-  onChangeBanCheckboxes: PropTypes.func.isRequired,
-  onSubmitBan: PropTypes.func.isRequired,
-  user: PropTypes.bool.isRequired,
-};
-
-export default SettingsBan;

+ 77 - 0
client/components/Settings/SettingsBan.tsx

@@ -0,0 +1,77 @@
+import React, { FC, useState } from "react";
+import { Flex } from "reflexbox/styled-components";
+import { useFormState } from "react-use-form-state";
+import axios from "axios";
+
+import { getAxiosConfig } from "../../utils";
+import TextInput from "../TextInput";
+import Checkbox from "../Checkbox";
+import { API } from '../../consts';
+import Button from "../Button";
+import Text from "../Text";
+import { useMessage } from "../../hooks";
+
+interface BanForm {
+  id: string;
+  user: boolean;
+  domain: boolean;
+  host: boolean;
+}
+
+const SettingsBan: FC = () => {
+  const [submitting, setSubmitting] = useState(false);
+  const [message, setMessage] = useMessage(3000);
+  const [formState, { checkbox, text }] = useFormState<BanForm>();
+
+  const onSubmit = async e => {
+    e.preventDefault();
+    setSubmitting(true);
+    setMessage()
+    try {
+      const { data } = await axios.post(API.BAN_LINK, formState.values, getAxiosConfig());
+      setMessage(data.message, "green");
+      formState.clear();
+    } catch (err) {
+      setMessage(err?.response?.data?.error || "Couldn't ban the link.");
+    }
+    setSubmitting(false);
+  };
+
+  return (
+    <Flex flexDirection="column">
+      <Text as="h2" fontWeight={700} mb={4}>Ban link</Text>
+      <Flex as="form" flexDirection="column" onSubmit={onSubmit} alignItems="flex-start">
+        <Flex mb={24} alignItems="center">
+          <TextInput
+            {...text("id")}
+            placeholder="Link ID (e.g. K7b2A)"
+            height={44}
+            fontSize={[16, 18]}
+            placeholderSize={[14, 15]}
+            mr={3}
+            pl={24}
+            pr={24}
+            width={[1, 3 / 5]}
+          />
+          <Button
+            type="submit"
+            icon={submitting ? "loader" : "lock"}
+            disabled={submitting}
+          >
+            {submitting ? "Banning..." : "Ban"}
+          </Button>
+        </Flex>
+        <Checkbox
+          {...checkbox("user")}
+          label="Ban User (and all of their links)"
+          mb={12}
+        />
+        <Checkbox {...checkbox("domain")} label="Ban Domain" mb={12} />
+        <Checkbox {...checkbox("host")} label="Ban Host/IP" />
+        <Text color={message.color} mt={3}>{message.text}</Text>
+      </Flex>
+    </Flex>
+  );
+};
+
+export default SettingsBan;

+ 0 - 174
client/components/Settings/SettingsDomain.js

@@ -1,174 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import styled from 'styled-components';
-import TextInput from '../TextInput';
-import Checkbox from '../Checkbox';
-import Button from '../Button';
-import Error from '../Error';
-import { fadeIn } from '../../helpers/animations';
-
-const Form = styled.form`
-  position: relative;
-  display: flex;
-  flex-direction: column;
-  align-items: flex-start;
-  justify-content: flex-start;
-  margin: 32px 0;
-  animation: ${fadeIn} 0.8s ease;
-
-  input {
-    flex: 0 0 auto;
-    margin-right: 16px;
-  }
-
-  @media only screen and (max-width: 768px) {
-    margin: 16px 0;
-  }
-`;
-
-const DomainWrapper = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const ButtonWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  margin: 32px 0;
-  animation: ${fadeIn} 0.8s ease;
-
-  button {
-    margin-right: 16px;
-  }
-
-  @media only screen and (max-width: 768px) {
-    flex-direction: column;
-    align-items: flex-start;
-
-    > * {
-      margin: 8px 0;
-    }
-  }
-`;
-
-const Domain = styled.h4`
-  margin: 0 16px 0 0;
-  font-size: 20px;
-  font-weight: bold;
-
-  span {
-    border-bottom: 2px dotted #999;
-  }
-`;
-
-const Homepage = styled.h6`
-  margin: 0 16px 0 0;
-  font-size: 14px;
-  font-weight: 300;
-
-  span {
-    border-bottom: 2px dotted #999;
-  }
-`;
-
-const InputWrapper = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const LabelWrapper = styled.div`
-  display: flex;
-  flex-direction: column;
-
-  span {
-    font-weight: bold;
-    margin-bottom: 8px;
-  }
-`;
-
-const SettingsDomain = ({
-  settings,
-  handleCustomDomain,
-  loading,
-  showDomainInput,
-  showModal,
-  handleCheckbox,
-}) => (
-  <div>
-    <h3>Custom domain</h3>
-    <p>
-      You can set a custom domain for your short URLs, so instead of <b>kutt.it/shorturl</b> you can
-      have <b>example.com/shorturl.</b>
-    </p>
-    <p>
-      Point your domain A record to <b>192.64.116.170</b> then add the domain via form below:
-    </p>
-    {settings.customDomain && !settings.domainInput ? (
-      <div>
-        <DomainWrapper>
-          <Domain>
-            <span>{settings.customDomain}</span>
-          </Domain>
-          <Homepage>
-            (Homepage redirects to <span>{settings.homepage || window.location.hostname}</span>)
-          </Homepage>
-        </DomainWrapper>
-        <ButtonWrapper>
-          <Button icon="edit" onClick={showDomainInput}>
-            Change
-          </Button>
-          <Button color="gray" icon="x" onClick={showModal}>
-            Delete
-          </Button>
-        </ButtonWrapper>
-      </div>
-    ) : (
-      <Form onSubmit={handleCustomDomain}>
-        <Error type="domain" left={0} bottom={-54} />
-        <InputWrapper>
-          <LabelWrapper htmlFor="customdomain">
-            <span>Domain</span>
-            <TextInput
-              id="customdomain"
-              name="customdomain"
-              type="text"
-              placeholder="example.com"
-              defaultValue={settings.customDomain}
-              height={44}
-              small
-            />
-          </LabelWrapper>
-          <LabelWrapper>
-            <span>Homepage (Optional)</span>
-            <TextInput
-              id="homepage"
-              name="homepage"
-              type="text"
-              placeholder="Homepage URL"
-              defaultValue={settings.homepage}
-              height={44}
-              small
-            />
-          </LabelWrapper>
-        </InputWrapper>
-        <Button type="submit" color="purple" icon={loading ? 'loader' : ''}>
-          Set domain
-        </Button>
-      </Form>
-    )}
-  </div>
-);
-
-SettingsDomain.propTypes = {
-  settings: PropTypes.shape({
-    customDomain: PropTypes.string.isRequired,
-    domainInput: PropTypes.bool.isRequired,
-  }).isRequired,
-  handleCustomDomain: PropTypes.func.isRequired,
-  loading: PropTypes.bool.isRequired,
-  showDomainInput: PropTypes.func.isRequired,
-  showModal: PropTypes.func.isRequired,
-  handleCheckbox: PropTypes.func.isRequired,
-};
-
-export default SettingsDomain;

+ 173 - 0
client/components/Settings/SettingsDomain.tsx

@@ -0,0 +1,173 @@
+import React, { FC, useState } from "react";
+import styled from "styled-components";
+import { Flex } from "reflexbox/styled-components";
+
+import { useStoreState, useStoreActions } from "../../store";
+import { useFormState } from "react-use-form-state";
+import { useMessage } from "../../hooks";
+import TextInput from "../TextInput";
+import Button from "../Button";
+import Text from "../Text";
+
+// TODO: types
+interface Props {
+  settings: {
+    customDomain: string;
+    domainInput: boolean;
+    homepage: string;
+  };
+  handleCustomDomain: any;
+  loading: boolean;
+  showDomainInput: any;
+  showModal: any;
+}
+
+const ButtonWrapper = styled(Flex).attrs({
+  justifyContent: ["column", "column", "row"],
+  alignItems: ["flex-start", "flex-start", "center"],
+  my: 32
+})`
+  display: flex;
+
+  button {
+    margin-right: 16px;
+  }
+
+  @media only screen and (max-width: 768px) {
+    > * {
+      margin: 8px 0;
+    }
+  }
+`;
+
+const Domain = styled.h4`
+  margin: 0 16px 0 0;
+  font-size: 20px;
+  font-weight: bold;
+
+  span {
+    border-bottom: 2px dotted #999;
+  }
+`;
+
+const Homepage = styled.h6`
+  margin: 0 16px 0 0;
+  font-size: 14px;
+  font-weight: 300;
+
+  span {
+    border-bottom: 2px dotted #999;
+  }
+`;
+
+const SettingsDomain: FC<Props> = ({ showDomainInput, showModal }) => {
+  const [loading, setLoading] = useState(false);
+  const [message, setMessage] = useMessage(2000);
+  const domains = useStoreState(s => s.settings.domains);
+  const { saveDomain } = useStoreActions(s => s.settings);
+  const [formState, { text }] = useFormState<{
+    customDomain: string;
+    homepage: string;
+  }>();
+
+  const onSubmit = async e => {
+    e.preventDefault();
+    setLoading(true);
+
+    try {
+      await saveDomain(formState.values);
+    } catch (err) {
+      setMessage(err?.response?.data?.error || "Couldn't add domain.");
+    }
+    setLoading(false);
+  };
+
+  return (
+    <Flex alignItems="flex-start" flexDirection="column">
+      <Text as="h2" fontWeight={700} mb={4}>
+        Custom domain
+      </Text>
+      <Text as="p" mb={3}>
+        You can set a custom domain for your short URLs, so instead of{" "}
+        <b>kutt.it/shorturl</b> you can have <b>example.com/shorturl.</b>
+      </Text>
+      <Text mb={4}>
+        Point your domain A record to <b>192.64.116.170</b> then add the domain
+        via form below:
+      </Text>
+      {domains.length ? (
+        domains.map(d => (
+          <Flex key={d.customDomain}>
+            <Flex alignItems="center">
+              <Domain>
+                <span>{d.customDomain}</span>
+              </Domain>
+              <Homepage>
+                (Homepage redirects to{" "}
+                <span>{d.homepage || window.location.hostname}</span>)
+              </Homepage>
+            </Flex>
+            <ButtonWrapper>
+              <Button icon="edit" onClick={showDomainInput}>
+                Change
+              </Button>
+              <Button color="gray" icon="x" onClick={showModal}>
+                Delete
+              </Button>
+            </ButtonWrapper>
+          </Flex>
+        ))
+      ) : (
+        <Flex
+          alignItems="flex-start"
+          flexDirection="column"
+          onSubmit={onSubmit}
+          width={1}
+          as="form"
+          my={4}
+        >
+          <Flex width={1}>
+            <Flex flexDirection="column" mr={2} flex="1 1 auto">
+              <Text as="label" htmlFor="customdomain" fontWeight={700} mb={3}>
+                Domain
+              </Text>
+              <TextInput
+                {...text("customDomain")}
+                placeholder="example.com"
+                height={44}
+                pl={24}
+                pr={24}
+                required
+              />
+            </Flex>
+            <Flex flexDirection="column" ml={2} flex="1 1 auto">
+              <Text as="label" htmlFor="customdomain" fontWeight={700} mb={3}>
+                Homepage (optional)
+              </Text>
+              <TextInput
+                {...text("homepage")}
+                type="text"
+                placeholder="Homepage URL"
+                flex="1 1 auto"
+                height={44}
+                pl={24}
+                pr={24}
+              />
+            </Flex>
+          </Flex>
+          <Button
+            type="submit"
+            color="purple"
+            icon={loading ? "loader" : ""}
+            mt={3}
+          >
+            Set domain
+          </Button>
+        </Flex>
+      )}
+      <Text color={message.color}>{message.text}</Text>
+    </Flex>
+  );
+};
+
+export default SettingsDomain;

+ 0 - 59
client/components/Settings/SettingsPassword.js

@@ -1,59 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import styled from 'styled-components';
-import TextInput from '../TextInput';
-import Button from '../Button';
-
-const Form = styled.form`
-  position: relative;
-  display: flex;
-  margin: 32px 0;
-
-  input {
-    flex: 0 0 auto;
-    margin-right: 16px;
-  }
-`;
-
-const Message = styled.div`
-  position: absolute;
-  left: 0;
-  bottom: -32px;
-  color: green;
-`;
-
-const Error = styled.div`
-  position: absolute;
-  left: 0;
-  bottom: -32px;
-  color: red;
-`;
-
-const SettingsPassword = ({ changePassword, error, message }) => (
-  <div>
-    <h3>Change password</h3>
-    <Form onSubmit={changePassword}>
-      <Message>{message}</Message>
-      <TextInput
-        id="password"
-        name="password"
-        type="password"
-        placeholder="New password"
-        height={44}
-        small
-      />
-      <Button type="submit" icon="refresh">
-        Update
-      </Button>
-      <Error>{error}</Error>
-    </Form>
-  </div>
-);
-
-SettingsPassword.propTypes = {
-  error: PropTypes.string.isRequired,
-  changePassword: PropTypes.func.isRequired,
-  message: PropTypes.string.isRequired,
-};
-
-export default SettingsPassword;

+ 76 - 0
client/components/Settings/SettingsPassword.tsx

@@ -0,0 +1,76 @@
+import { useFormState } from "react-use-form-state";
+import { Flex } from "reflexbox/styled-components";
+import React, { FC, useState } from "react";
+import axios from "axios";
+
+import { getAxiosConfig } from "../../utils";
+import { useMessage } from "../../hooks";
+import TextInput from "../TextInput";
+import { API } from "../../consts";
+import Button from "../Button";
+import Text from "../Text";
+
+const SettingsPassword: FC = () => {
+  const [loading, setLoading] = useState(false);
+  const [message, setMessage] = useMessage();
+  const [formState, { password }] = useFormState<{ password: string }>();
+
+  const onSubmit = async e => {
+    e.preventDefault();
+    if (loading) return;
+    if (!formState.validity.password) {
+      return setMessage(formState.errors.password);
+    }
+    setLoading(true);
+    setMessage();
+    try {
+      const res = await axios.post(
+        API.CHANGE_PASSWORD,
+        formState.values,
+        getAxiosConfig()
+      );
+      formState.clear();
+      setMessage(res.data.message, "green");
+    } catch (err) {
+      setMessage(err?.response?.data?.error || "Couldn't update the password.");
+    }
+    setLoading(false);
+  };
+
+  return (
+    <Flex flexDirection="column" alignItems="flex-start">
+      <Text as="h2" fontWeight={700} mb={4}>
+        Change password
+      </Text>
+      <Text mb={4}>Enter a new password to change your current password.</Text>
+      <Flex as="form" onSubmit={onSubmit}>
+        <TextInput
+          {...password({
+            name: "password",
+            validate: value => {
+              const val = value.trim();
+              if (!val || val.length < 8) {
+                return "Password must be at least 8 chars.";
+              }
+            }
+          })}
+          placeholder="New password"
+          height={44}
+          width={[1, 2 / 3]}
+          pl={24}
+          pr={24}
+          mr={3}
+          required
+        />
+        <Button type="submit" icon={loading ? "loader" : "refresh"}>
+          {loading ? "Updating..." : "Update"}
+        </Button>
+      </Flex>
+      <Text color={message.color} mt={3} fontSize={14}>
+        {message.text}
+      </Text>
+    </Flex>
+  );
+};
+
+export default SettingsPassword;

+ 6 - 7
client/components/Settings/SettingsWelcome.js → client/components/Settings/SettingsWelcome.tsx

@@ -1,7 +1,10 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 import styled from 'styled-components';
 
+interface Props {
+  user: string;
+}
+
 const Title = styled.h2`
   font-size: 28px;
   font-weight: 300;
@@ -16,14 +19,10 @@ const Title = styled.h2`
   }
 `;
 
-const SettingsWelcome = ({ user }) => (
+const SettingsWelcome: FC<Props> = ({ user }) => (
   <Title>
     Welcome, <span>{user}</span>.
   </Title>
 );
 
-SettingsWelcome.propTypes = {
-  user: PropTypes.string.isRequired,
-};
-
 export default SettingsWelcome;

+ 0 - 1
client/components/Settings/index.js

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

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

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

+ 0 - 173
client/components/Shortener/Shortener.js

@@ -1,173 +0,0 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import { bindActionCreators } from 'redux';
-import { connect } from 'react-redux';
-import styled from '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';
-
-const Wrapper = styled.div`
-  position: relative;
-  width: 800px;
-  max-width: 98%;
-  flex: 0 0 auto;
-  display: flex;
-  flex-direction: column;
-  margin: 16px 0 40px;
-  padding-bottom: 125px;
-  animation: ${fadeIn} 0.8s ease-out;
-
-  @media only screen and (max-width: 800px) {
-    padding: 0 8px 96px;
-  }
-`;
-
-const ResultWrapper = styled.div`
-  position: relative;
-  height: 96px;
-  display: flex;
-  justify-content: center;
-  align-items: flex-start;
-  box-sizing: border-box;
-
-  @media only screen and (max-width: 448px) {
-    height: 72px;
-  }
-`;
-
-class Shortener extends Component {
-  constructor() {
-    super();
-    this.state = {
-      isCopied: false,
-    };
-    this.handleSubmit = this.handleSubmit.bind(this);
-    this.copyHandler = this.copyHandler.bind(this);
-  }
-
-  shouldComponentUpdate(nextProps, nextState) {
-    const {
-      isAuthenticated,
-      domain,
-      shortenerError,
-      shortenerLoading,
-      url: { isShortened },
-    } = this.props;
-    return (
-      isAuthenticated !== nextProps.isAuthenticated ||
-      shortenerError !== nextProps.shortenerError ||
-      isShortened !== nextProps.url.isShortened ||
-      shortenerLoading !== nextProps.shortenerLoading ||
-      domain !== nextProps.domain ||
-      this.state.isCopied !== nextState.isCopied
-    );
-  }
-
-  handleSubmit(e) {
-    e.preventDefault();
-    const { isAuthenticated } = this.props;
-    this.props.showShortenerLoading();
-    const shortenerForm = document.getElementById('shortenerform');
-    const {
-      target: originalUrl,
-      customurl: customurlInput,
-      password: pwd,
-    } = shortenerForm.elements;
-    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) {
-      window.grecaptcha.execute(window.captchaId);
-      const getCaptchaToken = () => {
-        setTimeout(() => {
-          if (window.isCaptchaReady) {
-            const reCaptchaToken = window.grecaptcha.getResponse(window.captchaId);
-            window.isCaptchaReady = false;
-            window.grecaptcha.reset(window.captchaId);
-            return this.props.createShortUrl({ target, reCaptchaToken, ...options });
-          }
-          return getCaptchaToken();
-        }, 200);
-      };
-      return getCaptchaToken();
-    }
-    return this.props.createShortUrl({ target, ...options });
-  }
-
-  copyHandler() {
-    this.setState({ isCopied: true });
-    setTimeout(() => {
-      this.setState({ isCopied: false });
-    }, 1500);
-  }
-
-  render() {
-    const { isCopied } = this.state;
-    const { isAuthenticated, shortenerError, shortenerLoading, url } = this.props;
-    return (
-      <Wrapper>
-        <ResultWrapper>
-          {!shortenerError && (url.isShortened || shortenerLoading) ? (
-            <ShortenerResult
-              copyHandler={this.copyHandler}
-              loading={shortenerLoading}
-              url={url}
-              isCopied={isCopied}
-            />
-          ) : (
-            <ShortenerTitle />
-          )}
-        </ResultWrapper>
-        <ShortenerInput
-          isAuthenticated={isAuthenticated}
-          handleSubmit={this.handleSubmit}
-          setShortenerFormError={this.props.setShortenerFormError}
-          domain={this.props.domain}
-        />
-      </Wrapper>
-    );
-  }
-}
-
-Shortener.propTypes = {
-  isAuthenticated: PropTypes.bool.isRequired,
-  domain: PropTypes.string.isRequired,
-  createShortUrl: PropTypes.func.isRequired,
-  shortenerError: PropTypes.string.isRequired,
-  shortenerLoading: PropTypes.bool.isRequired,
-  setShortenerFormError: PropTypes.func.isRequired,
-  showShortenerLoading: PropTypes.func.isRequired,
-  url: PropTypes.shape({
-    isShortened: PropTypes.bool.isRequired,
-  }).isRequired,
-};
-
-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);

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

@@ -0,0 +1,176 @@
+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);

+ 22 - 11
client/components/Shortener/ShortenerInput.js → client/components/Shortener/ShortenerInput.tsx

@@ -1,11 +1,19 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+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;
@@ -52,9 +60,19 @@ const Icon = styled(SVG)`
   }
 `;
 
-const ShortenerInput = ({ isAuthenticated, domain, handleSubmit, setShortenerFormError }) => (
+const ShortenerInput: FC<Props> = ({
+  isAuthenticated,
+  domain,
+  handleSubmit,
+  setShortenerFormError,
+}) => (
   <ShortenerForm id="shortenerform" onSubmit={handleSubmit}>
-    <TextInput id="target" name="target" placeholder="Paste your long URL" autoFocus />
+    <TextInput
+      id="target"
+      name="target"
+      placeholder="Paste your long URL"
+      autoFocus
+    />
     <Submit onClick={handleSubmit}>
       <Icon src="/images/send.svg" />
     </Submit>
@@ -67,11 +85,4 @@ const ShortenerInput = ({ isAuthenticated, domain, handleSubmit, setShortenerFor
   </ShortenerForm>
 );
 
-ShortenerInput.propTypes = {
-  handleSubmit: PropTypes.func.isRequired,
-  isAuthenticated: PropTypes.bool.isRequired,
-  domain: PropTypes.string.isRequired,
-  setShortenerFormError: PropTypes.func.isRequired,
-};
-
 export default ShortenerInput;

+ 0 - 134
client/components/Shortener/ShortenerOptions.js

@@ -1,134 +0,0 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import styled from 'styled-components';
-import Checkbox from '../Checkbox';
-import TextInput from '../TextInput';
-import { fadeIn } from '../../helpers/animations';
-
-const Wrapper = styled.div`
-  position: absolute;
-  top: 74px;
-  left: 0;
-  display: flex;
-  flex-direction: column;
-  align-self: flex-start;
-  justify-content: flex-start;
-  z-index: 2;
-
-  @media only screen and (max-width: 448px) {
-    width: 100%;
-    top: 56px;
-  }
-`;
-
-const CheckboxWrapper = styled.div`
-  display: flex;
-
-  @media only screen and (max-width: 448px) {
-    justify-content: center;
-  }
-`;
-
-const InputWrapper = styled.div`
-  display: flex;
-  align-items: center;
-
-  @media only screen and (max-width: 448px) {
-    flex-direction: column;
-    align-items: flex-start;
-
-    > * {
-      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;
-  }
-`;
-
-class ShortenerOptions extends Component {
-  constructor() {
-    super();
-    this.state = {
-      customurlCheckbox: false,
-      passwordCheckbox: false,
-    };
-    this.handleCheckbox = this.handleCheckbox.bind(this);
-  }
-
-  shouldComponentUpdate(nextProps, nextState) {
-    const { customurlCheckbox, passwordCheckbox } = this.state;
-    return (
-      this.props.isAuthenticated !== nextProps.isAuthenticated ||
-      customurlCheckbox !== nextState.customurlCheckbox ||
-      this.props.domain !== nextProps.domain ||
-      passwordCheckbox !== nextState.passwordCheckbox
-    );
-  }
-
-  handleCheckbox(e) {
-    e.preventDefault();
-    if (!this.props.isAuthenticated) {
-      return this.props.setShortenerFormError('Please login or sign up to use this feature.');
-    }
-    const type = e.target.id;
-    return this.setState({ [type]: !this.state[type] });
-  }
-
-  render() {
-    const { customurlCheckbox, passwordCheckbox } = this.state;
-    const { isAuthenticated, domain } = this.props;
-    const customUrlInput = customurlCheckbox && (
-      <div>
-        <Label htmlFor="customurl">{domain || window.location.hostname}/</Label>
-        <TextInput id="customurl" type="text" placeholder="custom name" small />
-      </div>
-    );
-    const passwordInput = passwordCheckbox && (
-      <div>
-        <Label htmlFor="customurl">password:</Label>
-        <TextInput id="password" type="password" placeholder="password" small />
-      </div>
-    );
-    return (
-      <Wrapper isAuthenticated={isAuthenticated}>
-        <CheckboxWrapper>
-          <Checkbox
-            id="customurlCheckbox"
-            name="customurlCheckbox"
-            label="Set custom URL"
-            checked={this.state.customurlCheckbox}
-            onClick={this.handleCheckbox}
-          />
-          <Checkbox
-            id="passwordCheckbox"
-            name="passwordCheckbox"
-            label="Set password"
-            checked={this.state.passwordCheckbox}
-            onClick={this.handleCheckbox}
-          />
-        </CheckboxWrapper>
-        <InputWrapper>
-          {customUrlInput}
-          {passwordInput}
-        </InputWrapper>
-      </Wrapper>
-    );
-  }
-}
-
-ShortenerOptions.propTypes = {
-  isAuthenticated: PropTypes.bool.isRequired,
-  domain: PropTypes.string.isRequired,
-  setShortenerFormError: PropTypes.func.isRequired,
-};
-
-export default ShortenerOptions;

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

@@ -0,0 +1,128 @@
+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 - 127
client/components/Shortener/ShortenerResult.js

@@ -1,127 +0,0 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import styled from 'styled-components';
-import { CopyToClipboard } from 'react-copy-to-clipboard';
-import QRCode from 'qrcode.react';
-import Button from '../Button';
-import Loading from '../PageLoading';
-import { fadeIn } from '../../helpers/animations';
-import TBodyButton from '../Table/TBody/TBodyButton';
-import Modal from '../Modal';
-
-const Wrapper = styled.div`
-  position: relative;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-
-  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;
-`;
-
-class ShortenerResult extends Component {
-  constructor() {
-    super();
-    this.state = {
-      showQrCodeModal: false,
-    };
-    this.toggleQrCodeModal = this.toggleQrCodeModal.bind(this);
-  }
-
-  toggleQrCodeModal() {
-    this.setState(prevState => ({
-      showQrCodeModal: !prevState.showQrCodeModal,
-    }));
-  }
-
-  render() {
-    const { copyHandler, isCopied, loading, url } = this.props;
-    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={this.toggleQrCodeModal}>
-            <Icon src="/images/qrcode.svg" />
-          </QRButton>
-        )}
-        <Modal show={this.state.showQrCodeModal} close={this.toggleQrCodeModal}>
-          <QRCode value={url.list[0].shortLink} size={196} />
-        </Modal>
-      </Wrapper>
-    );
-  }
-}
-
-ShortenerResult.propTypes = {
-  copyHandler: PropTypes.func.isRequired,
-  isCopied: PropTypes.bool.isRequired,
-  loading: PropTypes.bool.isRequired,
-  url: PropTypes.shape({
-    list: PropTypes.array.isRequired,
-  }).isRequired,
-};
-
-export default ShortenerResult;

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

@@ -0,0 +1,120 @@
+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} close={toggleQrCodeModal}>
+        <QRCode value={url.list[0].shortLink} size={196} />
+      </Modal>
+    </Wrapper>
+  );
+};
+
+export default ShortenerResult;

+ 0 - 0
client/components/Shortener/ShortenerTitle.js → client/components/Shortener/ShortenerTitle.tsx


+ 0 - 0
client/components/Shortener/index.js → client/components/Shortener/index.tsx


+ 0 - 172
client/components/Stats/Stats.js

@@ -1,172 +0,0 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import { bindActionCreators } from 'redux';
-import { connect } from 'react-redux';
-import Router from 'next/router';
-import styled from 'styled-components';
-import axios from 'axios';
-import cookie from 'js-cookie';
-import StatsError from './StatsError';
-import StatsHead from './StatsHead';
-import StatsCharts from './StatsCharts';
-import PageLoading from '../PageLoading';
-import Button from '../Button';
-import { showPageLoading } from '../../actions';
-
-const Wrapper = styled.div`
-  width: 1200px;
-  max-width: 95%;
-  display: flex;
-  flex-direction: column;
-  align-items: stretch;
-  margin: 40px 0;
-`;
-
-const TitleWrapper = styled.div`
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-`;
-
-const Title = styled.h2`
-  font-size: 24px;
-  font-weight: 300;
-
-  a {
-    color: #2196f3;
-    text-decoration: none;
-    border-bottom: 1px dotted transparent;
-
-    :hover {
-      border-bottom-color: #2196f3;
-    }
-  }
-
-  @media only screen and (max-width: 768px) {
-    font-size: 18px;
-  }
-`;
-
-const TitleTarget = styled.p`
-  font-size: 14px;
-  text-align: right;
-  color: #333;
-
-  @media only screen and (max-width: 768px) {
-    font-size: 11px;
-  }
-`;
-
-const Content = styled.div`
-  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);
-`;
-
-const ButtonWrapper = styled.div`
-  align-self: center;
-  margin: 64px 0;
-`;
-
-class Stats extends Component {
-  constructor() {
-    super();
-    this.state = {
-      error: false,
-      loading: true,
-      period: 'lastDay',
-      stats: null,
-    };
-    this.changePeriod = this.changePeriod.bind(this);
-    this.goToHomepage = this.goToHomepage.bind(this);
-  }
-
-  componentDidMount() {
-    const { domain, id } = this.props;
-    if (!id) return null;
-    return axios
-      .get(`/api/url/stats?id=${id}&domain=${domain}`, { headers: { Authorization: cookie.get('token') } })
-      .then(({ data }) =>
-        this.setState({
-          stats: data,
-          loading: false,
-          error: !data,
-        })
-      )
-      .catch(() => this.setState({ error: true, loading: false }));
-  }
-
-  changePeriod(e) {
-    e.preventDefault();
-    const { period } = e.currentTarget.dataset;
-    this.setState({ period });
-  }
-
-  goToHomepage(e) {
-    e.preventDefault();
-    this.props.showPageLoading();
-    Router.push('/');
-  }
-
-  render() {
-    const { error, loading, period, stats } = this.state;
-    const { isAuthenticated, id } = this.props;
-
-    if (!isAuthenticated) return <StatsError text="You need to login to view stats." />;
-
-    if (!id || error) return <StatsError />;
-
-    if (loading) return <PageLoading />;
-
-    return (
-      <Wrapper>
-        <TitleWrapper>
-          <Title>
-            Stats for:{' '}
-            <a href={stats.shortLink} title="Short link">
-              {stats.shortLink.replace(/https?:\/\//, '')}
-            </a>
-          </Title>
-          <TitleTarget>
-            {stats.target.length > 80
-              ? `${stats.target
-                  .split('')
-                  .slice(0, 80)
-                  .join('')}...`
-              : stats.target}
-          </TitleTarget>
-        </TitleWrapper>
-        <Content>
-          <StatsHead total={stats.total} period={period} changePeriod={this.changePeriod} />
-          <StatsCharts stats={stats[period]} updatedAt={stats.updatedAt} period={period} />
-        </Content>
-        <ButtonWrapper>
-          <Button icon="arrow-left" onClick={this.goToHomepage}>
-            Back to homepage
-          </Button>
-        </ButtonWrapper>
-      </Wrapper>
-    );
-  }
-}
-
-Stats.propTypes = {
-  isAuthenticated: PropTypes.bool.isRequired,
-  domain: PropTypes.string.isRequired,
-  id: PropTypes.string.isRequired,
-  showPageLoading: PropTypes.func.isRequired,
-};
-
-const mapStateToProps = ({ auth: { isAuthenticated } }) => ({ isAuthenticated });
-
-const mapDispatchToProps = dispatch => ({
-  showPageLoading: bindActionCreators(showPageLoading, dispatch),
-});
-
-export default connect(
-  mapStateToProps,
-  mapDispatchToProps
-)(Stats);

+ 149 - 0
client/components/Stats/Stats.tsx

@@ -0,0 +1,149 @@
+import React, { Component, FC, useState, useEffect } from "react";
+import { bindActionCreators } from "redux";
+import { connect } from "react-redux";
+import Router from "next/router";
+import styled from "styled-components";
+import axios from "axios";
+import cookie from "js-cookie";
+import { Box, Flex } from "reflexbox/styled-components";
+
+import StatsError from "./StatsError";
+import StatsHead from "./StatsHead";
+import StatsCharts from "./StatsCharts";
+import PageLoading from "../PageLoading";
+import Button from "../Button";
+
+interface Props {
+  isAuthenticated: boolean;
+  domain: string;
+  id: string;
+}
+
+const Title = styled.h2`
+  font-size: 24px;
+  font-weight: 300;
+
+  a {
+    color: #2196f3;
+    text-decoration: none;
+    border-bottom: 1px dotted transparent;
+
+    :hover {
+      border-bottom-color: #2196f3;
+    }
+  }
+
+  @media only screen and (max-width: 768px) {
+    font-size: 18px;
+  }
+`;
+
+const TitleTarget = styled.p`
+  font-size: 14px;
+  text-align: right;
+  color: #333;
+
+  @media only screen and (max-width: 768px) {
+    font-size: 11px;
+  }
+`;
+
+const Content = styled(Flex).attrs({
+  flex: "1 1 auto",
+  flexDirection: "column"
+})`
+  background-color: white;
+  border-radius: 12px;
+  box-shadow: 0 6px 30px rgba(50, 50, 50, 0.2);
+`;
+
+const Stats: FC<Props> = ({ domain, id, isAuthenticated }) => {
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState(false);
+  const [stats, setStats] = useState();
+  const [period, setPeriod] = useState();
+
+  useEffect(() => {
+    if (id) return null;
+    axios
+      .get(`/api/url/stats?id=${id}&domain=${domain}`, {
+        headers: { Authorization: cookie.get("token") }
+      })
+      .then(({ data }) => {
+        setLoading(false);
+        setError(!data);
+        setStats(data);
+      })
+      .catch(() => {
+        setLoading(false);
+        setError(true);
+      });
+  }, []);
+
+  const changePeriod = e => setPeriod(e.currentTarget.dataset.period);
+
+  function goToHomepage(e) {
+    e.preventDefault();
+    Router.push("/");
+  }
+
+  if (!isAuthenticated)
+    return <StatsError text="You need to login to view stats." />;
+
+  if (!id || error) return <StatsError />;
+
+  if (loading) return <PageLoading />;
+
+  return (
+    <Flex
+      width={1200}
+      maxWidth="95%"
+      flexDirection="column"
+      alignItems="stretch"
+      m="40px 0"
+    >
+      <Flex justifyContent="space-between" alignItems="center">
+        <Title>
+          Stats for:{" "}
+          <a href={stats.shortLink} title="Short link">
+            {stats.shortLink.replace(/https?:\/\//, "")}
+          </a>
+        </Title>
+        <TitleTarget>
+          {stats.target.length > 80
+            ? `${stats.target
+                .split("")
+                .slice(0, 80)
+                .join("")}...`
+            : stats.target}
+        </TitleTarget>
+      </Flex>
+      <Content>
+        <StatsHead
+          total={stats.total}
+          period={period}
+          changePeriod={changePeriod}
+        />
+        <StatsCharts
+          stats={stats[period]}
+          updatedAt={stats.updatedAt}
+          period={period}
+        />
+      </Content>
+      <Box alignSelf="center" my={64}>
+        <Button icon="arrow-left" onClick={goToHomepage}>
+          Back to homepage
+        </Button>
+      </Box>
+    </Flex>
+  );
+};
+
+const mapStateToProps = ({ auth: { isAuthenticated } }) => ({
+  isAuthenticated
+});
+
+export default connect(
+  mapStateToProps,
+  null
+)(Stats);

+ 16 - 10
client/components/Stats/StatsCharts/Area.js → client/components/Stats/StatsCharts/Area.tsx

@@ -1,5 +1,4 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 import subHours from 'date-fns/subHours';
 import subDays from 'date-fns/subDays';
 import subMonths from 'date-fns/subMonths';
@@ -13,14 +12,23 @@ import {
   ResponsiveContainer,
   Tooltip,
 } from 'recharts';
+
 import withTitle from './withTitle';
 
-const ChartArea = ({ data: rawData, period }) => {
+interface Props {
+  data: number[];
+  period: string;
+}
+
+const ChartArea: FC<Props> = ({ data: rawData, period }) => {
   const now = new Date();
   const getDate = index => {
     switch (period) {
       case 'allTime':
-        return formatDate(subMonths(now, rawData.length - index - 1), 'MMM yyy');
+        return formatDate(
+          subMonths(now, rawData.length - index - 1),
+          'MMM yyy'
+        );
       case 'lastDay':
         return formatDate(subHours(now, rawData.length - index - 1), 'HH:00');
       case 'lastMonth':
@@ -35,7 +43,10 @@ const ChartArea = ({ data: rawData, period }) => {
   }));
 
   return (
-    <ResponsiveContainer width="100%" height={window.innerWidth < 468 ? 240 : 320}>
+    <ResponsiveContainer
+      width="100%"
+      height={window.innerWidth < 468 ? 240 : 320}
+    >
       <AreaChart
         data={data}
         margin={{
@@ -68,9 +79,4 @@ const ChartArea = ({ data: rawData, period }) => {
   );
 };
 
-ChartArea.propTypes = {
-  data: PropTypes.arrayOf(PropTypes.number.isRequired).isRequired,
-  period: PropTypes.string.isRequired,
-};
-
 export default withTitle(ChartArea);

+ 20 - 9
client/components/Stats/StatsCharts/Bar.js → client/components/Stats/StatsCharts/Bar.tsx

@@ -1,10 +1,25 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
+import React, { FC } from 'react';
+import {
+  BarChart,
+  Bar,
+  XAxis,
+  YAxis,
+  CartesianGrid,
+  Tooltip,
+  ResponsiveContainer,
+} from 'recharts';
+
 import withTitle from './withTitle';
 
-const ChartBar = ({ data }) => (
-  <ResponsiveContainer width="100%" height={window.innerWidth < 468 ? 240 : 320}>
+interface Props {
+  data: any[]; // TODO: types
+}
+
+const ChartBar: FC<Props> = ({ data }) => (
+  <ResponsiveContainer
+    width="100%"
+    height={window.innerWidth < 468 ? 240 : 320}
+  >
     <BarChart
       data={data}
       layout="vertical"
@@ -24,8 +39,4 @@ const ChartBar = ({ data }) => (
   </ResponsiveContainer>
 );
 
-ChartBar.propTypes = {
-  data: PropTypes.arrayOf(PropTypes.object.isRequired).isRequired,
-};
-
 export default withTitle(ChartBar);

+ 10 - 8
client/components/Stats/StatsCharts/Pie.js → client/components/Stats/StatsCharts/Pie.tsx

@@ -1,12 +1,18 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 import { PieChart, Pie, Tooltip, ResponsiveContainer } from 'recharts';
 import withTitle from './withTitle';
 
+interface Props {
+  data: any[]; // TODO: types
+}
+
 const renderCustomLabel = ({ name }) => name;
 
-const ChartPie = ({ data }) => (
-  <ResponsiveContainer width="100%" height={window.innerWidth < 468 ? 240 : 320}>
+const ChartPie: FC<Props> = ({ data }) => (
+  <ResponsiveContainer
+    width="100%"
+    height={window.innerWidth < 468 ? 240 : 320}
+  >
     <PieChart
       margin={{
         top: window.innerWidth < 468 ? 56 : 0,
@@ -27,8 +33,4 @@ const ChartPie = ({ data }) => (
   </ResponsiveContainer>
 );
 
-ChartPie.propTypes = {
-  data: PropTypes.arrayOf(PropTypes.object.isRequired).isRequired,
-};
-
 export default withTitle(ChartPie);

+ 33 - 16
client/components/Stats/StatsCharts/StatsCharts.js → client/components/Stats/StatsCharts/StatsCharts.tsx

@@ -1,10 +1,19 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 import styled from 'styled-components';
+
 import Area from './Area';
 import Pie from './Pie';
 import Bar from './Bar';
 
+interface Props {
+  updatedAt: string;
+  period: string;
+  stats: {
+    stats: any; // TODO: types
+    views: number[];
+  };
+}
+
 const ChartsWrapper = styled.div`
   display: flex;
   flex-direction: column;
@@ -41,7 +50,7 @@ const Row = styled.div`
   }
 `;
 
-const StatsCharts = ({ stats, period, updatedAt }) => {
+const StatsCharts: FC<Props> = ({ stats, period, updatedAt }) => {
   const periodText = period.includes('last')
     ? `the last ${period.replace('last', '').toLocaleLowerCase()}`
     : 'all time';
@@ -49,16 +58,33 @@ const StatsCharts = ({ stats, period, updatedAt }) => {
   return (
     <ChartsWrapper>
       <Row>
-        <Area data={stats.views} period={period} updatedAt={updatedAt} periodText={periodText} />
+        <Area
+          data={stats.views}
+          period={period}
+          updatedAt={updatedAt}
+          periodText={periodText}
+        />
       </Row>
       {hasView
         ? [
             <Row key="second-row">
-              <Pie data={stats.stats.referrer} updatedAt={updatedAt} title="Referrals" />
-              <Bar data={stats.stats.browser} updatedAt={updatedAt} title="Browsers" />
+              <Pie
+                data={stats.stats.referrer}
+                updatedAt={updatedAt}
+                title="Referrals"
+              />
+              <Bar
+                data={stats.stats.browser}
+                updatedAt={updatedAt}
+                title="Browsers"
+              />
             </Row>,
             <Row key="third-row">
-              <Pie data={stats.stats.country} updatedAt={updatedAt} title="Country" />
+              <Pie
+                data={stats.stats.country}
+                updatedAt={updatedAt}
+                title="Country"
+              />
               <Bar
                 data={stats.stats.os.map(o => ({
                   ...o,
@@ -74,13 +100,4 @@ const StatsCharts = ({ stats, period, updatedAt }) => {
   );
 };
 
-StatsCharts.propTypes = {
-  updatedAt: PropTypes.string.isRequired,
-  period: PropTypes.string.isRequired,
-  stats: PropTypes.shape({
-    stats: PropTypes.object.isRequired,
-    views: PropTypes.array.isRequired,
-  }).isRequired,
-};
-
 export default StatsCharts;

+ 0 - 0
client/components/Stats/StatsCharts/index.js → client/components/Stats/StatsCharts/index.tsx


+ 0 - 71
client/components/Stats/StatsCharts/withTitle.js

@@ -1,71 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import styled from 'styled-components';
-import formatDate from 'date-fns/format';
-
-const Wrapper = styled.div`
-  flex: 1 1 50%;
-  display: flex;
-  flex-direction: column;
-  align-items: stretch;
-
-  @media only screen and (max-width: 768px) {
-    flex-basis: 100%;
-  }
-`;
-
-const Title = styled.h3`
-  margin-bottom: 12px;
-  font-size: 24px;
-  font-weight: 300;
-
-  @media only screen and (max-width: 768px) {
-    font-size: 18px;
-  }
-`;
-
-const SubTitle = styled.span`
-  margin-bottom: 32px;
-  font-size: 13px;
-  font-weight: 300;
-  color: #aaa;
-
-  @media only screen and (max-width: 768px) {
-    font-size: 11px;
-  }
-`;
-
-const Count = styled.span`
-  font-weight: bold;
-  border-bottom: 1px dotted #999;
-`;
-
-const withTitle = ChartComponent => {
-  function WithTitle(props) {
-    return (
-      <Wrapper>
-        <Title>
-          {props.periodText && <Count>{props.data.reduce((sum, view) => sum + view, 0)}</Count>}
-          {props.periodText ? ` tracked clicks in ${props.periodText}` : props.title}.
-        </Title>
-        {props.periodText && props.updatedAt && (
-          <SubTitle>Last update in {formatDate(new Date(props.updatedAt), 'dddd, hh:mm aa')}.</SubTitle>
-        )}
-        <ChartComponent {...props} />
-      </Wrapper>
-    );
-  }
-  WithTitle.propTypes = {
-    data: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.object])).isRequired,
-    periodText: PropTypes.string,
-    title: PropTypes.string,
-    updatedAt: PropTypes.string.isRequired,
-  };
-  WithTitle.defaultProps = {
-    title: '',
-    periodText: '',
-  };
-  return WithTitle;
-};
-
-export default withTitle;

+ 75 - 0
client/components/Stats/StatsCharts/withTitle.tsx

@@ -0,0 +1,75 @@
+import React, { FC } from 'react';
+import styled from 'styled-components';
+import formatDate from 'date-fns/format';
+import { Flex } from 'reflexbox/styled-components';
+
+interface Props {
+  data: number | any; // TODO: types
+  period?: string;
+  periodText?: string;
+  title?: string;
+  updatedAt: string;
+}
+
+const Title = styled.h3`
+  margin-bottom: 12px;
+  font-size: 24px;
+  font-weight: 300;
+
+  @media only screen and (max-width: 768px) {
+    font-size: 18px;
+  }
+`;
+
+const SubTitle = styled.span`
+  margin-bottom: 32px;
+  font-size: 13px;
+  font-weight: 300;
+  color: #aaa;
+
+  @media only screen and (max-width: 768px) {
+    font-size: 11px;
+  }
+`;
+
+const Count = styled.span`
+  font-weight: bold;
+  border-bottom: 1px dotted #999;
+`;
+
+const withTitle = (ChartComponent: FC<any>) => {
+  function WithTitle(props: Props) {
+    return (
+      <Flex
+        flexGrow={1}
+        flexShrink={1}
+        flexBasis={['100%', '100%', '50%']}
+        flexDirection="column"
+      >
+        <Title>
+          {props.periodText && (
+            <Count>{props.data.reduce((sum, view) => sum + view, 0)}</Count>
+          )}
+          {props.periodText
+            ? ` tracked clicks in ${props.periodText}`
+            : props.title}
+          .
+        </Title>
+        {props.periodText && props.updatedAt && (
+          <SubTitle>
+            Last update in{' '}
+            {formatDate(new Date(props.updatedAt), 'dddd, hh:mm aa')}.
+          </SubTitle>
+        )}
+        <ChartComponent {...props} />
+      </Flex>
+    );
+  }
+  WithTitle.defaultProps = {
+    title: '',
+    periodText: '',
+  };
+  return WithTitle;
+};
+
+export default withTitle;

+ 8 - 14
client/components/Stats/StatsError.js → client/components/Stats/StatsError.tsx

@@ -1,12 +1,10 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 import styled from 'styled-components';
+import { Flex } from 'reflexbox/styled-components';
 
-const ErrorWrapper = styled.div`
-  display: flex;
-  justify-content: center;
-  align-items: center;
-`;
+interface Props {
+  text?: string;
+}
 
 const ErrorMessage = styled.h3`
   font-size: 24px;
@@ -28,17 +26,13 @@ const Icon = styled.img`
   }
 `;
 
-const StatsError = ({ text }) => (
-  <ErrorWrapper>
+const StatsError: FC<Props> = ({ text }) => (
+  <Flex justifyContent="center" alignItems="center">
     <Icon src="/images/x.svg" />
     <ErrorMessage>{text || 'Could not get the short URL stats.'}</ErrorMessage>
-  </ErrorWrapper>
+  </Flex>
 );
 
-StatsError.propTypes = {
-  text: PropTypes.string,
-};
-
 StatsError.defaultProps = {
   text: '',
 };

+ 29 - 30
client/components/Stats/StatsHead.js → client/components/Stats/StatsHead.tsx

@@ -1,20 +1,24 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 import styled, { css } from 'styled-components';
+import { ifProp } from 'styled-tools';
+import { Flex } from 'reflexbox/styled-components';
 
-const Wrapper = styled.div`
-  display: flex;
-  flex: 1 1 auto;
-  justify-content: space-between;
-  align-items: center;
-  padding: 24px 32px;
+interface Props {
+  changePeriod: any; // TODO: types
+  period: string;
+  total: number;
+}
+
+const Wrapper = styled(Flex).attrs({
+  flex: '1 1 auto',
+  justifyContent: 'center',
+  alignItems: 'center',
+  py: [16, 16, 25],
+  px: 32,
+})`
   background-color: #f1f1f1;
   border-top-left-radius: 12px;
   border-top-right-radius: 12px;
-
-  @media only screen and (max-width: 768px) {
-    padding: 16px;
-  }
 `;
 
 const TotalText = styled.p`
@@ -31,11 +35,7 @@ const TotalText = styled.p`
   }
 `;
 
-const TimeWrapper = styled.div`
-  display: flex;
-`;
-
-const Button = styled.button`
+const Button = styled.button<{ active: boolean }>`
   display: flex;
   padding: 6px 12px;
   margin: 0 4px;
@@ -52,8 +52,8 @@ const Button = styled.button`
     margin-right: 0;
   }
 
-  ${({ active }) =>
-    !active &&
+  ${ifProp(
+    { active: false },
     css`
       border: 1px solid #ddd;
       background-color: #f5f5f5;
@@ -63,7 +63,8 @@ const Button = styled.button`
         border-color: 1px solid #ccc;
         background-color: white;
       }
-    `};
+    `
+  )}
 
   @media only screen and (max-width: 768px) {
     padding: 4px 8px;
@@ -72,9 +73,13 @@ const Button = styled.button`
   }
 `;
 
-const StatsHead = ({ changePeriod, period, total }) => {
+const StatsHead: FC<Props> = ({ changePeriod, period, total }) => {
   const buttonWithPeriod = (periodText, text) => (
-    <Button active={period === periodText} data-period={periodText} onClick={changePeriod}>
+    <Button
+      active={period === periodText}
+      data-period={periodText}
+      onClick={changePeriod}
+    >
       {text}
     </Button>
   );
@@ -83,20 +88,14 @@ const StatsHead = ({ changePeriod, period, total }) => {
       <TotalText>
         Total clicks: <span>{total}</span>
       </TotalText>
-      <TimeWrapper>
+      <Flex>
         {buttonWithPeriod('allTime', 'All Time')}
         {buttonWithPeriod('lastMonth', 'Month')}
         {buttonWithPeriod('lastWeek', 'Week')}
         {buttonWithPeriod('lastDay', 'Day')}
-      </TimeWrapper>
+      </Flex>
     </Wrapper>
   );
 };
 
-StatsHead.propTypes = {
-  changePeriod: PropTypes.func.isRequired,
-  period: PropTypes.string.isRequired,
-  total: PropTypes.number.isRequired,
-};
-
 export default StatsHead;

+ 0 - 0
client/components/Stats/index.js → client/components/Stats/index.tsx


+ 146 - 0
client/components/Table.tsx

@@ -0,0 +1,146 @@
+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} handler={deleteUrl} close={closeModal}>
+        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);

+ 67 - 55
client/components/Table/TBody/TBody.js → client/components/Table/TBody/TBody.tsx

@@ -1,21 +1,38 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 import { connect } from 'react-redux';
 import styled, { css } from 'styled-components';
 import distanceInWordsToNow from 'date-fns/formatDistanceToNow';
+import { ifProp } from 'styled-tools';
+import { Flex } from 'reflexbox/styled-components';
+
 import TBodyShortUrl from './TBodyShortUrl';
 import TBodyCount from './TBodyCount';
 
-const TBody = styled.tbody`
-  display: flex;
-  flex: 1 1 auto;
-  flex-direction: column;
-
-  ${({ loading }) =>
-    loading &&
+interface Props {
+  urls: Array<{
+    id: string;
+    count: number;
+    created_at: string;
+    password: boolean;
+    target: string;
+  }>;
+  copiedIndex: number;
+  showModal: any; // TODO: types
+  tableLoading: boolean;
+  handleCopy: any;
+}
+
+const TBody = styled(Flex).attrs({
+  as: 'tbody',
+  flex: '1 1 auto',
+  flexDirection: 'column',
+})<{ loading: boolean }>`
+  ${ifProp(
+    'loading',
     css`
       opacity: 0.2;
-    `};
+    `
+  )}
 
   tr:hover {
     background-color: #f8f8f8;
@@ -26,23 +43,26 @@ const TBody = styled.tbody`
   }
 `;
 
-const Td = styled.td`
+const Td = styled(Flex).attrs({
+  as: 'td',
+  flexBasis: 0,
+})<{ flex?: string; withFade?: boolean; date?: boolean }>`
   white-space: nowrap;
   overflow: hidden;
 
-  ${({ withFade }) =>
-    withFade &&
+  ${ifProp(
+    'withFade',
     css`
-      :after {
-        content: '';
-        position: absolute;
-        right: 0;
-        top: 0;
-        height: 100%;
-        width: 56px;
-        background: linear-gradient(to left, white, white, transparent);
-      }
-    `};
+    :after {
+      content: '';
+      position: absolute;
+      right: 0;
+      top: 0;
+      height: 100%;
+      width: 56px;
+      background: linear-gradient(to left, white, white, transparent);
+  `
+  )}
 
   :last-child {
     justify-content: space-between;
@@ -60,17 +80,12 @@ const Td = styled.td`
     }
   }
 
-  ${({ date }) =>
-    date &&
+  ${ifProp(
+    'date',
     css`
       font-size: 15px;
-    `};
-
-  ${({ flex }) =>
-    flex &&
-    css`
-      flex: ${`${flex} ${flex}`} 0;
-    `};
+    `
+  )}
 
   @media only screen and (max-width: 768px) {
     flex: 1;
@@ -86,19 +101,30 @@ const Td = styled.td`
   }
 `;
 
-const TableBody = ({ copiedIndex, handleCopy, tableLoading, showModal, urls }) => {
+const TableBody: FC<Props> = ({
+  copiedIndex,
+  handleCopy,
+  tableLoading,
+  showModal,
+  urls,
+}) => {
   const showList = (url, index) => (
     <tr key={`tbody-${index}`}>
-      <Td flex="2" withFade>
+      <Td flex="2 2 0" withFade>
         <a href={url.target}>{url.target}</a>
       </Td>
-      <Td flex="1" date>
+      <Td flex="1 1 0" date>
         {`${distanceInWordsToNow(new Date(url.created_at))} ago`}
       </Td>
-      <Td flex="1" withFade>
-        <TBodyShortUrl index={index} copiedIndex={copiedIndex} handleCopy={handleCopy} url={url} />
+      <Td flex="1 1 0" withFade>
+        <TBodyShortUrl
+          index={index}
+          copiedIndex={copiedIndex}
+          handleCopy={handleCopy}
+          url={url}
+        />
       </Td>
-      <Td flex="1">
+      <Td flex="1 1 0">
         <TBodyCount url={url} showModal={showModal(url)} />
       </Td>
     </tr>
@@ -116,22 +142,8 @@ const TableBody = ({ copiedIndex, handleCopy, tableLoading, showModal, urls }) =
   );
 };
 
-TableBody.propTypes = {
-  urls: PropTypes.arrayOf(
-    PropTypes.shape({
-      id: PropTypes.string.isRequired,
-      count: PropTypes.number,
-      created_at: PropTypes.string.isRequired,
-      password: PropTypes.bool,
-      target: PropTypes.string.isRequired,
-    })
-  ).isRequired,
-  copiedIndex: PropTypes.number.isRequired,
-  showModal: PropTypes.func.isRequired,
-  tableLoading: PropTypes.bool.isRequired,
-  handleCopy: PropTypes.func.isRequired,
-};
-
-const mapStateToProps = ({ loading: { table: tableLoading } }) => ({ tableLoading });
+const mapStateToProps = ({ loading: { table: tableLoading } }) => ({
+  tableLoading,
+});
 
 export default connect(mapStateToProps)(TableBody);

+ 8 - 16
client/components/Table/TBody/TBodyButton.js → client/components/Table/TBody/TBodyButton.tsx

@@ -1,8 +1,11 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 import styled, { css } from 'styled-components';
 
-const Button = styled.button`
+interface Props {
+  withText?: boolean;
+}
+
+const Button = styled.button<Props>`
   display: flex;
   justify-content: center;
   align-items: center;
@@ -55,19 +58,8 @@ const Button = styled.button`
   }
 `;
 
-const TBodyButton = ({ children, withText, ...props }) => (
-  <Button withText={withText} {...props}>
-    {children}
-  </Button>
-);
-
-TBodyButton.propTypes = {
-  children: PropTypes.node.isRequired,
-  withText: PropTypes.bool,
-};
-
-TBodyButton.defaultProps = {
+Button.defaultProps = {
   withText: null,
 };
 
-export default TBodyButton;
+export default Button;

+ 0 - 112
client/components/Table/TBody/TBodyCount.js

@@ -1,112 +0,0 @@
-import React, { Component } from 'react';
-import { bindActionCreators } from 'redux';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import Router from 'next/router';
-import styled from 'styled-components';
-import URL from 'url';
-import QRCode from 'qrcode.react';
-import TBodyButton from './TBodyButton';
-import { showPageLoading } from '../../../actions';
-import Modal from '../../Modal';
-
-const Wrapper = styled.div`
-  display: flex;
-  flex: 1 1 auto;
-  justify-content: space-between;
-  align-items: center;
-`;
-
-const Actions = styled.div`
-  display: flex;
-  justify-content: flex-end;
-  align-items: center;
-
-  button {
-    margin: 0 2px 0 12px;
-  }
-`;
-
-const Icon = styled.img`
-  width: 12px;
-  height: 12px;
-`;
-
-class TBodyCount extends Component {
-  constructor() {
-    super();
-    this.state = {
-      showQrCodeModal: false,
-    };
-    this.goTo = this.goTo.bind(this);
-    this.toggleQrCodeModal = this.toggleQrCodeModal.bind(this);
-  }
-
-  toggleQrCodeModal() {
-    this.setState(prevState => ({
-      showQrCodeModal: !prevState.showQrCodeModal,
-    }));
-  }
-
-  goTo(e) {
-    e.preventDefault();
-    const { id, domain } = this.props.url;
-    this.props.showLoading();
-    Router.push(`/stats?id=${id}${domain ? `&domain=${domain}`: ''}`);
-  }
-
-  render() {
-    const { showModal, url } = this.props;
-    const showQrCode = window.innerWidth > 640;
-
-    return (
-      <Wrapper>
-        {url.visit_count || 0}
-        <Actions>
-          {url.password && <Icon src="/images/lock.svg" lowopacity />}
-          {url.visit_count > 0 && (
-            <TBodyButton withText onClick={this.goTo}>
-              <Icon src="/images/chart.svg" />
-              Stats
-            </TBodyButton>
-          )}
-          {showQrCode && (
-            <TBodyButton onClick={this.toggleQrCodeModal}>
-              <Icon src="/images/qrcode.svg" />
-            </TBodyButton>
-          )}
-          <TBodyButton
-            data-id={url.id}
-            data-host={URL.parse(url.shortLink).hostname}
-            onClick={showModal}
-          >
-            <Icon src="/images/trash.svg" />
-          </TBodyButton>
-        </Actions>
-        <Modal show={this.state.showQrCodeModal} close={this.toggleQrCodeModal}>
-          <QRCode value={url.shortLink} size={196} />
-        </Modal>
-      </Wrapper>
-    );
-  }
-}
-
-TBodyCount.propTypes = {
-  showLoading: PropTypes.func.isRequired,
-  showModal: PropTypes.func.isRequired,
-  url: PropTypes.shape({
-    count: PropTypes.number,
-    id: PropTypes.string,
-    password: PropTypes.bool,
-    shortLink: PropTypes.string,
-  }).isRequired,
-};
-
-const mapDispatchToProps = dispatch => ({
-  showLoading: bindActionCreators(showPageLoading, dispatch),
-});
-
-export default connect(
-  null,
-  mapDispatchToProps
-)(TBodyCount);

+ 80 - 0
client/components/Table/TBody/TBodyCount.tsx

@@ -0,0 +1,80 @@
+import React, { FC, useState } from "react";
+import Router from "next/router";
+import styled from "styled-components";
+import URL from "url";
+import QRCode from "qrcode.react";
+import { Flex } from "reflexbox/styled-components";
+
+import TBodyButton from "./TBodyButton";
+import Modal from "../../Modal";
+
+interface Props {
+  showModal: any;
+  url: {
+    count: number;
+    domain: string;
+    id: string;
+    password: boolean;
+    shortLink: string;
+    visit_count: number;
+  };
+}
+
+const Actions = styled(Flex).attrs({
+  justifyContent: "center",
+  alignItems: "center"
+})`
+  button {
+    margin: 0 2px 0 12px;
+  }
+`;
+
+const Icon = styled.img`
+  width: 12px;
+  height: 12px;
+`;
+
+const TBodyCount: FC<Props> = ({ url }) => {
+  const [showModal, setShowModal] = useState(false);
+  const toggleQrCodeModal = () => setShowModal(current => !current);
+
+  function goTo(e) {
+    e.preventDefault();
+    const { id, domain } = url;
+    Router.push(`/stats?id=${id}${domain ? `&domain=${domain}` : ""}`);
+  }
+
+  const showQrCode = window.innerWidth > 640;
+
+  return (
+    <Flex flex="1 1 auto" justifyContent="space-between" alignItems="center">
+      {url.visit_count || 0}
+      <Actions>
+        {url.password && <Icon src="/images/lock.svg" />}
+        {url.visit_count > 0 && (
+          <TBodyButton withText onClick={goTo}>
+            <Icon src="/images/chart.svg" />
+            Stats
+          </TBodyButton>
+        )}
+        {showQrCode && (
+          <TBodyButton onClick={toggleQrCodeModal}>
+            <Icon src="/images/qrcode.svg" />
+          </TBodyButton>
+        )}
+        <TBodyButton
+          data-id={url.id}
+          data-host={URL.parse(url.shortLink).hostname}
+          onClick={toggleQrCodeModal} // FIXME: what does this do?
+        >
+          <Icon src="/images/trash.svg" />
+        </TBodyButton>
+      </Actions>
+      <Modal show={showModal} close={toggleQrCodeModal}>
+        <QRCode value={url.shortLink} size={196} />
+      </Modal>
+    </Flex>
+  );
+};
+
+export default TBodyCount;

+ 19 - 19
client/components/Table/TBody/TBodyShortUrl.js → client/components/Table/TBody/TBodyShortUrl.tsx

@@ -1,13 +1,19 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 import styled from 'styled-components';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
+import { Flex } from 'reflexbox/styled-components';
+
 import TBodyButton from './TBodyButton';
 
-const Wrapper = styled.div`
-  display: flex;
-  align-items: center;
-`;
+interface Props {
+  copiedIndex: number;
+  handleCopy: any; // TODO: types
+  index: number;
+  url: {
+    id: string;
+    shortLink: string;
+  };
+}
 
 const CopyText = styled.div`
   position: absolute;
@@ -22,25 +28,19 @@ const Icon = styled.img`
   height: 12px;
 `;
 
-const TBodyShortUrl = ({ index, copiedIndex, handleCopy, url }) => (
-  <Wrapper>
+const TBodyShortUrl: FC<Props> = ({ index, copiedIndex, handleCopy, url }) => (
+  <Flex alignItems="center">
     {copiedIndex === index && <CopyText>Copied to clipboard!</CopyText>}
     <CopyToClipboard onCopy={() => handleCopy(index)} text={`${url.shortLink}`}>
       <TBodyButton>
         <Icon src="/images/copy.svg" />
       </TBodyButton>
     </CopyToClipboard>
-    <a href={`${url.shortLink}`}>{`${url.shortLink.replace(/^https?:\/\//, '')}`}</a>
-  </Wrapper>
+    <a href={`${url.shortLink}`}>{`${url.shortLink.replace(
+      /^https?:\/\//,
+      ''
+    )}`}</a>
+  </Flex>
 );
 
-TBodyShortUrl.propTypes = {
-  copiedIndex: PropTypes.number.isRequired,
-  handleCopy: PropTypes.func.isRequired,
-  index: PropTypes.number.isRequired,
-  url: PropTypes.shape({
-    id: PropTypes.string.isRequired,
-  }).isRequired,
-};
-
 export default TBodyShortUrl;

+ 0 - 0
client/components/Table/TBody/index.js → client/components/Table/TBody/index.tsx


+ 0 - 55
client/components/Table/THead/THead.js

@@ -1,55 +0,0 @@
-import React from 'react';
-import styled, { css } from 'styled-components';
-import TableOptions from '../TableOptions';
-
-const THead = styled.thead`
-  display: flex;
-  flex-direction: column;
-  flex: 1 1 auto;
-  background-color: #f1f1f1;
-  border-top-right-radius: 12px;
-  border-top-left-radius: 12px;
-
-  tr {
-    border-bottom: 1px solid #dedede;
-  }
-`;
-
-const Th = styled.th`
-  display: flex;
-  justify-content: start;
-  align-items: center;
-
-  ${({ flex }) =>
-    flex &&
-    css`
-      flex: ${`${flex} ${flex}`} 0;
-    `};
-
-  @media only screen and (max-width: 768px) {
-    flex: 1;
-    :nth-child(2) {
-      display: none;
-    }
-  }
-
-  @media only screen and (max-width: 510px) {
-    :nth-child(1) {
-      display: none;
-    }
-  }
-`;
-
-const TableHead = () => (
-  <THead>
-    <TableOptions />
-    <tr>
-      <Th flex="2">Original URL</Th>
-      <Th flex="1">Created</Th>
-      <Th flex="1">Short URL</Th>
-      <Th flex="1">Clicks</Th>
-    </tr>
-  </THead>
-);
-
-export default TableHead;

+ 53 - 0
client/components/Table/THead/THead.tsx

@@ -0,0 +1,53 @@
+import React, { FC } from 'react';
+import styled, { css } from 'styled-components';
+import { Flex } from 'reflexbox';
+
+import TableOptions from '../../TableOptions';
+
+const THead = styled(Flex).attrs({
+  as: 'thead',
+  flexDirection: 'column',
+  flex: '1 1 auto',
+})`
+  background-color: #f1f1f1;
+  border-top-right-radius: 12px;
+  border-top-left-radius: 12px;
+
+  tr {
+    border-bottom: 1px solid #dedede;
+  }
+`;
+
+const Th = styled(Flex).attrs({
+  as: 'th',
+  justifyContent: 'start',
+  alignItems: 'center',
+  flexBasis: 0,
+})`
+  @media only screen and (max-width: 768px) {
+    flex: 1;
+    :nth-child(2) {
+      display: none;
+    }
+  }
+
+  @media only screen and (max-width: 510px) {
+    :nth-child(1) {
+      display: none;
+    }
+  }
+`;
+
+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>
+    </tr>
+  </THead>
+);
+
+export default TableHead;

+ 0 - 0
client/components/Table/THead/index.js → client/components/Table/THead/index.tsx


+ 0 - 164
client/components/Table/Table.js

@@ -1,164 +0,0 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import { bindActionCreators } from 'redux';
-import { connect } from 'react-redux';
-import styled from 'styled-components';
-import THead from './THead';
-import TBody from './TBody';
-import TableOptions from './TableOptions';
-import { deleteShortUrl, getUrlsList } from '../../actions';
-import Modal from '../Modal';
-
-const Wrapper = styled.div`
-  width: 1200px;
-  max-width: 95%;
-  display: flex;
-  flex-direction: column;
-  margin: 40px 0 120px;
-`;
-
-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;
-`;
-
-class Table extends Component {
-  constructor() {
-    super();
-    this.state = {
-      copiedIndex: -1,
-      modalUrlId: '',
-      modalUrlDomain: '',
-      showModal: false,
-    };
-    this.handleCopy = this.handleCopy.bind(this);
-    this.showModal = this.showModal.bind(this);
-    this.closeModal = this.closeModal.bind(this);
-    this.deleteUrl = this.deleteUrl.bind(this);
-  }
-
-  handleCopy(index) {
-    this.setState({ copiedIndex: index });
-    setTimeout(() => {
-      this.setState({ copiedIndex: -1 });
-    }, 1500);
-  }
-
-  showModal(url) {
-    return e => {
-      e.preventDefault();
-      this.setState({
-        modalUrlId: url.address,
-        modalUrlDomain: url.domain,
-        showModal: true,
-      });
-    }
-  }
-
-  closeModal() {
-    this.setState({
-      modalUrlId: '',
-      modalUrlDomain: '',
-      showModal: false,
-    });
-  }
-
-  deleteUrl() {
-    this.closeModal();
-    const { modalUrlId, modalUrlDomain } = this.state;
-    this.props.deleteShortUrl({ id: modalUrlId, domain: modalUrlDomain });
-  }
-
-  render() {
-    const { copiedIndex } = this.state;
-    const { url } = this.props;
-    return (
-      <Wrapper>
-        <Title>Recent shortened links.</Title>
-        <TableWrapper>
-          <THead />
-          <TBody
-            copiedIndex={copiedIndex}
-            handleCopy={this.handleCopy}
-            urls={url.list}
-            showModal={this.showModal}
-          />
-          <TFoot>
-            <TableOptions nosearch />
-          </TFoot>
-        </TableWrapper>
-        <Modal show={this.state.showModal} handler={this.deleteUrl} close={this.closeModal}>
-          Are you sure do you want to delete the short URL and its stats?
-        </Modal>
-      </Wrapper>
-    );
-  }
-}
-
-Table.propTypes = {
-  deleteShortUrl: PropTypes.func.isRequired,
-  url: PropTypes.shape({
-    list: PropTypes.array.isRequired,
-  }).isRequired,
-};
-
-const mapStateToProps = ({ url }) => ({ url });
-
-const mapDispatchToProps = dispatch => ({
-  deleteShortUrl: bindActionCreators(deleteShortUrl, dispatch),
-  getUrlsList: bindActionCreators(getUrlsList, dispatch),
-});
-
-export default connect(
-  mapStateToProps,
-  mapDispatchToProps
-)(Table);

+ 0 - 204
client/components/Table/TableOptions.js

@@ -1,204 +0,0 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import { bindActionCreators } from 'redux';
-import { connect } from 'react-redux';
-import styled, { css } from 'styled-components';
-import TableNav from './TableNav';
-import TextInput from '../TextInput';
-import { getUrlsList } from '../../actions';
-
-const Tr = styled.tr`
-  display: flex;
-  align-items: center;
-
-  thead & {
-    border-bottom: 1px solid #ddd !important;
-  }
-`;
-
-const Th = styled.th`
-  display: flex;
-  align-items: center;
-
-  ${({ flex }) =>
-    flex &&
-    css`
-      flex: ${`${flex} ${flex}`} 0;
-    `};
-`;
-
-const Divider = styled.div`
-  margin: 0 16px 0 24px;
-  width: 1px;
-  height: 20px;
-  background-color: #ccc;
-
-  @media only screen and (max-width: 768px) {
-    margin: 0 4px 0 12px;
-  }
-
-  @media only screen and (max-width: 510px) {
-    display: none;
-  }
-`;
-
-const ListCount = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const Ul = styled.ul`
-  display: flex;
-  margin: 0;
-  padding: 0;
-  list-style: none;
-
-  li {
-    display: flex;
-    margin: 0 0 0 12px;
-    list-style: none;
-
-    @media only screen and (max-width: 768px) {
-      margin-left: 8px;
-    }
-  }
-
-  @media only screen and (max-width: 510px) {
-    display: none;
-  }
-`;
-
-const Button = styled.button`
-  display: flex;
-  padding: 4px 8px;
-  border: none;
-  font-size: 12px;
-  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;
-
-  ${({ active }) =>
-    !active &&
-    css`
-      border: 1px solid #ddd;
-      background-color: #f5f5f5;
-      box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1);
-
-      :hover {
-        border-color: 1px solid #ccc;
-        background-color: white;
-      }
-    `};
-
-  @media only screen and (max-width: 768px) {
-    font-size: 10px;
-  }
-`;
-
-class TableOptions extends Component {
-  constructor() {
-    super();
-    this.state = {
-      search: '',
-    };
-    this.submitSearch = this.submitSearch.bind(this);
-    this.handleSearch = this.handleSearch.bind(this);
-    this.handleCount = this.handleCount.bind(this);
-    this.handleNav = this.handleNav.bind(this);
-  }
-
-  submitSearch(e) {
-    e.preventDefault();
-    this.props.getUrlsList({ search: this.state.search });
-  }
-
-  handleSearch(e) {
-    this.setState({ search: e.currentTarget.value });
-  }
-
-  handleCount(e) {
-    const count = Number(e.target.textContent);
-    this.props.getUrlsList({ count });
-  }
-
-  handleNav(num) {
-    return (e) => {
-      const { active } = e.target.dataset;
-      if (active === 'false') return null;
-      return this.props.getUrlsList({ page: this.props.url.page + num });
-    }
-  }
-
-  render() {
-    const { count, countAll, page } = this.props.url;
-    return (
-      <Tr>
-        <Th>
-          {!this.props.nosearch && (
-            <form onSubmit={this.submitSearch}>
-              <TextInput
-                id="search"
-                name="search"
-                value={this.state.search}
-                placeholder="Search..."
-                onChange={this.handleSearch}
-                tiny
-              />
-            </form>
-          )}
-        </Th>
-        <Th>
-          <ListCount>
-            <Ul>
-              <li>
-                <Button active={count === 10} onClick={this.handleCount}>
-                  10
-                </Button>
-              </li>
-              <li>
-                <Button active={count === 25} onClick={this.handleCount}>
-                  25
-                </Button>
-              </li>
-              <li>
-                <Button active={count === 50} onClick={this.handleCount}>
-                  50
-                </Button>
-              </li>
-            </Ul>
-          </ListCount>
-          <Divider />
-          <TableNav handleNav={this.handleNav} next={page * count < countAll} prev={page > 1} />
-        </Th>
-      </Tr>
-    );
-  }
-}
-
-TableOptions.propTypes = {
-  getUrlsList: PropTypes.func.isRequired,
-  nosearch: PropTypes.bool,
-  url: PropTypes.shape({
-    page: PropTypes.number.isRequired,
-    count: PropTypes.number.isRequired,
-    countAll: PropTypes.number.isRequired,
-  }).isRequired,
-};
-
-TableOptions.defaultProps = {
-  nosearch: false,
-};
-
-const mapStateToProps = ({ url }) => ({ url });
-
-const mapDispatchToProps = dispatch => ({
-  getUrlsList: bindActionCreators(getUrlsList, dispatch),
-});
-
-export default connect(
-  mapStateToProps,
-  mapDispatchToProps
-)(TableOptions);

+ 0 - 1
client/components/Table/index.js

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

+ 23 - 25
client/components/Table/TableNav.js → client/components/TableNav.tsx

@@ -1,13 +1,15 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 import styled, { css } from 'styled-components';
+import { ifProp } from 'styled-tools';
+import { Flex } from 'reflexbox/styled-components';
 
-const Wrapper = styled.div`
-  display: flex;
-  align-items: center;
-`;
+interface Props {
+  handleNav: any; // TODO: types
+  next: boolean;
+  prev: boolean;
+}
 
-const Nav = styled.button`
+const Nav = styled.button<{ disabled: boolean }>`
   margin-left: 12px;
   padding: 5px 8px 3px;
   border-radius: 4px;
@@ -16,21 +18,23 @@ const Nav = styled.button`
   box-shadow: 0 0px 10px rgba(100, 100, 100, 0.1);
   transition: all 0.2s ease-out;
 
-  ${({ disabled }) =>
-    !disabled &&
+  ${ifProp(
+    'disabled',
     css`
       background-color: white;
       cursor: pointer;
-    `};
+    `
+  )}
 
-  :hover {
-    ${({ disabled }) =>
-      !disabled &&
-      css`
+  ${ifProp(
+    { disabled: false },
+    css`
+      :hover {
         transform: translateY(-2px);
         box-shadow: 0 5px 25px rgba(50, 50, 50, 0.1);
-      `};
-  }
+      }
+    `
+  )}
 
   @media only screen and (max-width: 768px) {
     padding: 4px 6px 2px;
@@ -47,21 +51,15 @@ const Icon = styled.img`
   }
 `;
 
-const TableNav = ({ handleNav, next, prev }) => (
-  <Wrapper>
+const TableNav: FC<Props> = ({ handleNav, next, prev }) => (
+  <Flex alignItems="center">
     <Nav disabled={!prev} onClick={handleNav(-1)}>
       <Icon src="/images/nav-left.svg" />
     </Nav>
     <Nav disabled={!next} onClick={handleNav(1)}>
       <Icon src="/images/nav-right.svg" />
     </Nav>
-  </Wrapper>
+  </Flex>
 );
 
-TableNav.propTypes = {
-  handleNav: PropTypes.func.isRequired,
-  next: PropTypes.bool.isRequired,
-  prev: PropTypes.bool.isRequired,
-};
-
 export default TableNav;

+ 168 - 0
client/components/TableOptions.tsx

@@ -0,0 +1,168 @@
+import React, { FC, useState } from "react";
+import { bindActionCreators } from "redux";
+import { connect } from "react-redux";
+import styled, { css } from "styled-components";
+import { ifProp } from "styled-tools";
+import { Flex } from "reflexbox/styled-components";
+
+import TableNav from "./TableNav";
+import TextInput from "./TextInput";
+import { getUrlsList } from "../actions";
+
+interface Props {
+  getUrlsList: any; // TODO: types
+  nosearch?: boolean;
+  url: {
+    page: number;
+    count: number;
+    countAll: number;
+  };
+}
+
+const Tr = styled(Flex).attrs({ as: "tr", alignItems: "center" })`
+  thead & {
+    border-bottom: 1px solid #ddd !important;
+  }
+`;
+
+const Th = styled(Flex).attrs({ as: "th", alignItems: "center" })``;
+
+const Divider = styled.div`
+  margin: 0 16px 0 24px;
+  width: 1px;
+  height: 20px;
+  background-color: #ccc;
+
+  @media only screen and (max-width: 768px) {
+    margin: 0 4px 0 12px;
+  }
+
+  @media only screen and (max-width: 510px) {
+    display: none;
+  }
+`;
+
+const Ul = styled(Flex).attrs({ as: "ul" })`
+  margin: 0;
+  padding: 0;
+  list-style: none;
+
+  @media only screen and (max-width: 510px) {
+    display: none;
+  }
+`;
+
+const Li = styled(Flex).attrs({ as: "li" })`
+  li {
+    margin: 0 0 0 12px;
+    list-style: none;
+
+    @media only screen and (max-width: 768px) {
+      margin-left: 8px;
+    }
+  }
+`;
+
+const Button = styled.button<{ active: boolean }>`
+  display: flex;
+  padding: 4px 8px;
+  border: none;
+  font-size: 12px;
+  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;
+
+  ${ifProp(
+    { active: false },
+    css`
+      border: 1px solid #ddd;
+      background-color: #f5f5f5;
+      box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1);
+
+      :hover {
+        border-color: 1px solid #ccc;
+        background-color: white;
+      }
+    `
+  )}
+  @media only screen and (max-width: 768px) {
+    font-size: 10px;
+  }
+`;
+
+const TableOptions: FC<Props> = ({ getUrlsList, nosearch, url }) => {
+  const [search, setSearch] = useState();
+  const [count, setCount] = useState();
+
+  function submitSearch(e) {
+    e.preventDefault();
+    getUrlsList({ search });
+  }
+
+  const handleCount = e => setCount(Number(e.target.textContent));
+
+  function handleNav(num) {
+    return e => {
+      const { active } = e.target.dataset;
+      if (active === "false") return null;
+      return getUrlsList({ page: url.page + num });
+    };
+  }
+
+  return (
+    <Tr>
+      <Th>
+        {!nosearch && (
+          <form onSubmit={submitSearch}>
+            <TextInput
+              as="input"
+              id="search"
+              name="search"
+              value={search}
+              placeholder="Search..."
+              onChange={e => setSearch(e.target.value)}
+              tiny
+            />
+          </form>
+        )}
+      </Th>
+      <Th>
+        <Flex alignItems="center">
+          <Ul>
+            {[10, 25, 50].map(c => (
+              <Li key={c}>
+                <Button active={count === c} onClick={handleCount}>
+                  {c}
+                </Button>
+              </Li>
+            ))}
+          </Ul>
+        </Flex>
+        <Divider />
+        <TableNav
+          handleNav={handleNav}
+          next={url.page * count < url.countAll}
+          prev={url.page > 1}
+        />
+      </Th>
+    </Tr>
+  );
+};
+
+TableOptions.defaultProps = {
+  nosearch: false
+};
+
+const mapStateToProps = ({ url }) => ({ url });
+
+const mapDispatchToProps = dispatch => ({
+  getUrlsList: bindActionCreators(getUrlsList, dispatch)
+});
+
+export default connect(
+  mapStateToProps,
+  mapDispatchToProps
+)(TableOptions);

+ 30 - 0
client/components/Text.tsx

@@ -0,0 +1,30 @@
+import styled, { css } from "styled-components";
+import { Box } from "reflexbox/styled-components";
+import { switchProp, ifNotProp } from "styled-tools";
+
+interface Props {
+  weight?: 300 | 400 | 700;
+  htmlFor?: string;
+}
+const Text = styled(Box)<Props>`
+  ${ifNotProp(
+    "fontSize",
+    css`
+      font-size: ${switchProp("a", {
+        p: "1rem",
+        h1: "1.802em",
+        h2: "1.602em",
+        h3: "1.424em",
+        h4: "1.266em",
+        h5: "1.125em"
+      })};
+    `
+  )}
+`;
+
+Text.defaultProps = {
+  as: "p",
+  fontWeight: 400
+};
+
+export default Text;

+ 57 - 40
client/components/TextInput/TextInput.js → client/components/TextInput.tsx

@@ -1,23 +1,34 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import styled, { css } from 'styled-components';
-import { fadeIn } from '../../helpers/animations';
-
-const LinkInput = styled.input`
+import styled, { css } from "styled-components";
+import { ifProp, withProp } from "styled-tools";
+import { Flex } from "reflexbox/styled-components";
+
+import { fadeIn } from "../helpers/animations";
+
+interface Props {
+  autoFocus?: boolean;
+  name?: string;
+  id?: string;
+  type?: string;
+  value?: string;
+  required?: boolean;
+  small?: boolean;
+  onChange?: any;
+  tiny?: boolean;
+  placeholderSize?: number[];
+}
+
+const TextInput = styled(Flex).attrs({
+  as: "input"
+})<Props>`
   position: relative;
-  width: auto;
-  flex: 1 1 auto;
-  height: 72px;
-  padding: 0 84px 0 40px;
-  font-size: 20px;
+  box-sizing: border-box;
   letter-spacing: 0.05em;
   color: #444;
-  box-sizing: border-box;
   background-color: white;
   box-shadow: 0 10px 35px rgba(50, 50, 50, 0.1);
   border-radius: 100px;
   border: none;
-  border-bottom: 6px solid #f5f5f5;
+  border-bottom: 5px solid #f5f5f5;
   animation: ${fadeIn} 0.5s ease-out;
   transition: all 0.5s ease-out;
 
@@ -27,23 +38,33 @@ const LinkInput = styled.input`
   }
 
   ::placeholder {
-    font-size: 16px;
-    letter-spacing: 0.1em;
+    font-size: ${withProp("placeholderSize", s => s[0] || 14)};
+    letter-spacing: 0.05em;
     color: #888;
   }
 
-  @media only screen and (max-width: 488px) {
-    height: 56px;
-    padding: 0 48px 0 32px;
-    font-size: 14px;
-    border-bottom-width: 5px;
+  @media screen and (min-width: 64em) {
+    ::placeholder {
+      font-size: ${withProp("placeholderSize", s => s[3] || 16)}px;
+    }
+  }
+
+  @media screen and (min-width: 52em) {
+    letter-spacing: 0.1em;
+    border-bottom-width: 6px;
+    ::placeholder {
+      font-size: ${withProp("placeholderSize", s => s[2] || 15)}px;
+    }
+  }
+
+  @media screen and (min-width: 40em) {
     ::placeholder {
-      font-size: 14px;
+      font-size: ${withProp("placeholderSize", s => s[1] || 15)}px;
     }
   }
 
-  ${({ small }) =>
-    small &&
+  /* ${ifProp(
+    "small",
     css`
       width: 240px;
       height: 54px;
@@ -62,10 +83,11 @@ const LinkInput = styled.input`
         font-size: 13px;
         border-bottom-width: 3px;
       }
-    `};
+    `
+  )}
 
-  ${({ tiny }) =>
-    tiny &&
+  ${ifProp(
+    "tiny",
     css`
       flex: 0 0 auto;
       width: 280px;
@@ -98,25 +120,20 @@ const LinkInput = styled.input`
         font-size: 12px;
         border-bottom-width: 3px;
       }
-    `};
-
-  ${({ height }) =>
-    height &&
-    css`
-      height: ${height}px;
-    `};
+    `
+  )} */
 `;
 
-const TextInput = props => <LinkInput {...props} />;
-
-TextInput.propTypes = {
-  small: PropTypes.bool,
-  tiny: PropTypes.bool,
-};
-
 TextInput.defaultProps = {
+  value: "",
   small: false,
   tiny: false,
+  height: [56, 72],
+  py: 0,
+  pr: [48, 84],
+  pl: [32, 40],
+  fontSize: [14, 16],
+  placeholderSize: []
 };
 
 export default TextInput;

+ 0 - 1
client/components/TextInput/index.js

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

+ 10 - 0
client/consts/consts.ts

@@ -0,0 +1,10 @@
+export enum API {
+  LOGIN = "/api/auth/login",
+  SIGNUP = "/api/auth/signup",
+  REPORT = "/api/url/report",
+  RESET_PASSWORD = "/api/auth/resetpassword",
+  CHANGE_PASSWORD = "/api/auth/changepassword",
+  BAN_LINK = "/api/url/admin/ban",
+  CUSTOM_DOMAIN = "/api/url/customdomain",
+  GENERATE_APIKEY = "/api/auth/generateapikey"
+}

+ 1 - 0
client/consts/index.ts

@@ -0,0 +1 @@
+export * from "./consts";

+ 15 - 0
client/hooks.ts

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

+ 14 - 0
client/module.d.ts

@@ -0,0 +1,14 @@
+import "next";
+import { initializeStore } from "./store";
+
+declare global {
+  interface Window {
+    GA_INITIALIZED: boolean;
+  }
+}
+
+declare module "next" {
+  export interface NextPageContext {
+    store: ReturnType<typeof initializeStore>;
+  }
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов