ソースを参照

feat: refactor client and improve design (#260)

* refactor: (wip)

* refactor: finish settings, add icons and stuff

* 🐬

* 🐬

* 2.2.0
Pouria Ezzati 6 年 前
コミット
4680a0dbec
100 ファイル変更2620 行追加3717 行削除
  1. 9 1
      .babelrc
  2. 3 3
      .eslintrc
  3. 1 1
      .example.env
  4. 8 0
      .prettierrc
  5. 0 172
      client/actions/__test__/auth.js
  6. 0 176
      client/actions/__test__/settings.js
  7. 0 159
      client/actions/__test__/url.js
  8. 0 32
      client/actions/actionTypes.js
  9. 0 92
      client/actions/auth.js
  10. 0 3
      client/actions/index.js
  11. 0 85
      client/actions/settings.js
  12. 0 71
      client/actions/url.js
  13. 36 0
      client/components/ALink.tsx
  14. 17 0
      client/components/Animation.ts
  15. 40 0
      client/components/AppWrapper.tsx
  16. 0 97
      client/components/BodyWrapper/BodyWrapper.js
  17. 0 1
      client/components/BodyWrapper/index.js
  18. 176 0
      client/components/Button.tsx
  19. 0 155
      client/components/Button/Button.js
  20. 0 1
      client/components/Button/index.js
  21. 30 26
      client/components/Charts/Area.tsx
  22. 40 0
      client/components/Charts/Bar.tsx
  23. 33 0
      client/components/Charts/Pie.tsx
  24. 3 0
      client/components/Charts/index.tsx
  25. 97 0
      client/components/Checkbox.tsx
  26. 0 108
      client/components/Checkbox/Checkbox.js
  27. 0 1
      client/components/Checkbox/index.js
  28. 14 0
      client/components/Divider.tsx
  29. 0 63
      client/components/Error/Error.js
  30. 0 1
      client/components/Error/index.js
  31. 28 40
      client/components/Extensions.tsx
  32. 0 1
      client/components/Extensions/index.js
  33. 44 0
      client/components/Features.tsx
  34. 0 71
      client/components/Features/Features.js
  35. 0 97
      client/components/Features/FeaturesItem.js
  36. 0 1
      client/components/Features/index.js
  37. 81 0
      client/components/FeaturesItem.tsx
  38. 63 0
      client/components/Footer.tsx
  39. 0 84
      client/components/Footer/Footer.js
  40. 0 1
      client/components/Footer/index.js
  41. 157 0
      client/components/Header.tsx
  42. 0 58
      client/components/Header/Header.js
  43. 0 62
      client/components/Header/HeaderLeftMenu.js
  44. 0 38
      client/components/Header/HeaderMenuItem.js
  45. 0 89
      client/components/Header/HeaderRightMenu.js
  46. 0 1
      client/components/Header/index.js
  47. 5 11
      client/components/HeaderLogo.tsx
  48. 21 0
      client/components/Icon/Check.tsx
  49. 22 0
      client/components/Icon/ChevronLeft.tsx
  50. 22 0
      client/components/Icon/ChevronRight.tsx
  51. 23 0
      client/components/Icon/Clipboard.tsx
  52. 23 0
      client/components/Icon/Copy.tsx
  53. 153 0
      client/components/Icon/Icon.tsx
  54. 22 0
      client/components/Icon/Key.tsx
  55. 22 0
      client/components/Icon/Lock.tsx
  56. 21 0
      client/components/Icon/PieChart.tsx
  57. 23 0
      client/components/Icon/Plus.tsx
  58. 19 0
      client/components/Icon/QRCode.tsx
  59. 24 0
      client/components/Icon/Refresh.tsx
  60. 18 0
      client/components/Icon/Send.tsx
  61. 43 0
      client/components/Icon/Spinner.tsx
  62. 25 0
      client/components/Icon/Trash.tsx
  63. 22 0
      client/components/Icon/Zap.tsx
  64. 1 0
      client/components/Icon/index.ts
  65. 37 0
      client/components/Layout.tsx
  66. 383 0
      client/components/LinksTable.tsx
  67. 0 174
      client/components/Login/Login.js
  68. 0 31
      client/components/Login/LoginBox.js
  69. 0 20
      client/components/Login/LoginInputLabel.js
  70. 0 1
      client/components/Login/index.js
  71. 56 0
      client/components/Modal.tsx
  72. 0 67
      client/components/Modal/Modal.js
  73. 0 1
      client/components/Modal/index.js
  74. 22 33
      client/components/NeedToLogin.tsx
  75. 0 1
      client/components/NeedToLogin/index.js
  76. 19 0
      client/components/PageLoading.tsx
  77. 0 28
      client/components/PageLoading/PageLoading.js
  78. 0 1
      client/components/PageLoading/index.js
  79. 3 6
      client/components/ReCaptcha.tsx
  80. 0 334
      client/components/Settings/Settings.js
  81. 0 117
      client/components/Settings/SettingsApi.js
  82. 95 0
      client/components/Settings/SettingsApi.tsx
  83. 0 98
      client/components/Settings/SettingsBan.js
  84. 89 0
      client/components/Settings/SettingsBan.tsx
  85. 0 174
      client/components/Settings/SettingsDomain.js
  86. 189 0
      client/components/Settings/SettingsDomain.tsx
  87. 0 59
      client/components/Settings/SettingsPassword.js
  88. 79 0
      client/components/Settings/SettingsPassword.tsx
  89. 0 29
      client/components/Settings/SettingsWelcome.js
  90. 0 1
      client/components/Settings/index.js
  91. 1 0
      client/components/Settings/index.tsx
  92. 258 0
      client/components/Shortener.tsx
  93. 0 173
      client/components/Shortener/Shortener.js
  94. 0 77
      client/components/Shortener/ShortenerInput.js
  95. 0 134
      client/components/Shortener/ShortenerOptions.js
  96. 0 127
      client/components/Shortener/ShortenerResult.js
  97. 0 25
      client/components/Shortener/ShortenerTitle.js
  98. 0 1
      client/components/Shortener/index.js
  99. 0 172
      client/components/Stats/Stats.js
  100. 0 31
      client/components/Stats/StatsCharts/Bar.js

+ 9 - 1
.babelrc

@@ -1,4 +1,12 @@
 {
   "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 }
+    ],
+    "inline-react-svg",
+    "@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

@@ -75,7 +75,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"
+}

+ 0 - 172
client/actions/__test__/auth.js

@@ -1,172 +0,0 @@
-import nock from 'nock';
-import sinon from 'sinon';
-import { expect } from 'chai';
-import cookie from 'js-cookie';
-import thunk from 'redux-thunk';
-import Router from 'next/router';
-import configureMockStore from 'redux-mock-store';
-
-import { signupUser, loginUser, logoutUser, renewAuthUser } from '../auth';
-import {
-  SIGNUP_LOADING,
-  SENT_VERIFICATION,
-  LOGIN_LOADING,
-  AUTH_RENEW,
-  AUTH_USER,
-  SET_DOMAIN,
-  SHOW_PAGE_LOADING,
-  UNAUTH_USER
-} from '../actionTypes';
-
-const middlewares = [thunk];
-const mockStore = configureMockStore(middlewares);
-
-describe('auth actions', () => {
-  const jwt = {
-    domain: '',
-    exp: 1529137738725,
-    iat: 1529137738725,
-    iss: 'ApiAuth',
-    sub: 'test@mail.com',
-  };
-  const email = 'test@mail.com';
-  const password = 'password';
-  const token =
-    'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJBcGlBdXRoIiwic3ViIjoidGVzdEBtYWlsLmNvbSIsImRvbWFpbiI6IiIsImlhdCI6MTUyOTEzNzczODcyNSwiZXhwIjoxNTI5MTM3NzM4NzI1fQ.tdI7r11bmSCUmbcJBBKIDt7Hkb7POLMRl8VNJv_8O_s';
-
-  describe('#signupUser()', () => {
-    it('should dispatch SENT_VERIFICATION when signing up user has been done', done => {
-      nock('http://localhost')
-        .post('/api/auth/signup')
-        .reply(200, {
-          email,
-          message: 'Verification email has been sent.'
-        });
-
-      const store = mockStore({});
-
-      const expectedActions = [
-        { type: SIGNUP_LOADING },
-        {
-          type: SENT_VERIFICATION,
-          payload: email
-        }
-      ];
-
-      store
-        .dispatch(signupUser(email, password))
-        .then(() => {
-          expect(store.getActions()).to.deep.equal(expectedActions);
-          done();
-        })
-        .catch(error => done(error));
-    });
-  });
-
-  describe('#loginUser()', () => {
-    it('should dispatch AUTH_USER when logining user has been done', done => {
-      const pushStub = sinon.stub(Router, 'push');
-      pushStub.withArgs('/').returns('/');
-      const expectedRoute = '/';
-
-      nock('http://localhost')
-        .post('/api/auth/login')
-        .reply(200, {
-          token
-        });
-
-      const store = mockStore({});
-
-      const expectedActions = [
-        { type: LOGIN_LOADING },
-        { type: AUTH_RENEW },
-        {
-          type: AUTH_USER,
-          payload: jwt
-        },
-        {
-          type: SET_DOMAIN,
-          payload: {
-            customDomain: '',
-          }
-        },
-        { type: SHOW_PAGE_LOADING }
-      ];
-
-      store
-        .dispatch(loginUser(email, password))
-        .then(() => {
-          expect(store.getActions()).to.deep.equal(expectedActions);
-
-          pushStub.restore();
-          sinon.assert.calledWith(pushStub, expectedRoute);
-
-          done();
-        })
-        .catch(error => done(error));
-    });
-  });
-
-  describe('#logoutUser()', () => {
-    it('should dispatch UNAUTH_USER when loging out user has been done', () => {
-      const pushStub = sinon.stub(Router, 'push');
-      pushStub.withArgs('/login').returns('/login');
-      const expectedRoute = '/login';
-
-      const store = mockStore({});
-
-      const expectedActions = [
-        { type: SHOW_PAGE_LOADING },
-        { type: UNAUTH_USER }
-      ];
-
-      store.dispatch(logoutUser());
-      expect(store.getActions()).to.deep.equal(expectedActions);
-
-      pushStub.restore();
-      sinon.assert.calledWith(pushStub, expectedRoute);
-    });
-  });
-
-  describe('#renewAuthUser()', () => {
-    it('should dispatch AUTH_RENEW when renewing auth user has been done', done => {
-      const cookieStub = sinon.stub(cookie, 'get');
-      cookieStub.withArgs('token').returns(token);
-
-      nock('http://localhost', {
-        reqheaders: {
-          Authorization: token
-        }
-      })
-        .post('/api/auth/renew')
-        .reply(200, {
-          token
-        });
-
-      const store = mockStore({ auth: { renew: false } });
-
-      const expectedActions = [
-        { type: AUTH_RENEW },
-        {
-          type: AUTH_USER,
-          payload: jwt
-        },
-        {
-          type: SET_DOMAIN,
-          payload: {
-            customDomain: '',
-          }
-        }
-      ];
-
-      store
-        .dispatch(renewAuthUser())
-        .then(() => {
-          expect(store.getActions()).to.deep.equal(expectedActions);
-          cookieStub.restore();
-          done();
-        })
-        .catch(error => done(error));
-    });
-  });
-});

+ 0 - 176
client/actions/__test__/settings.js

@@ -1,176 +0,0 @@
-import nock from 'nock';
-import sinon from 'sinon';
-import { expect } from 'chai';
-import cookie from 'js-cookie';
-import thunk from 'redux-thunk';
-import configureMockStore from 'redux-mock-store';
-
-import {
-  getUserSettings,
-  setCustomDomain,
-  deleteCustomDomain,
-  generateApiKey
-} from '../settings';
-import {
-  DELETE_DOMAIN,
-  DOMAIN_LOADING,
-  API_LOADING,
-  SET_DOMAIN,
-  SET_APIKEY
-} from '../actionTypes';
-
-const middlewares = [thunk];
-const mockStore = configureMockStore(middlewares);
-
-describe('settings actions', () => {
-  const token =
-    'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJBcGlBdXRoIiwic3ViIjoidGVzdEBtYWlsLmNvbSIsImRvbWFpbiI6IiIsImlhdCI6MTUyOTEzNzczODcyNSwiZXhwIjoxNTI5MTM3NzM4NzI1fQ.tdI7r11bmSCUmbcJBBKIDt7Hkb7POLMRl8VNJv_8O_s';
-
-  let cookieStub;
-
-  beforeEach(() => {
-    cookieStub = sinon.stub(cookie, 'get');
-    cookieStub.withArgs('token').returns(token);
-  });
-
-  afterEach(() => {
-    cookieStub.restore();
-  });
-
-  describe('#getUserSettings()', () => {
-    it('should dispatch SET_APIKEY and SET_DOMAIN when getting user settings have been done', done => {
-      const apikey = '123';
-      const customDomain = 'test.com';
-      const homepage = '';
-
-      nock('http://localhost', {
-        reqheaders: {
-          Authorization: token
-        }
-      })
-        .get('/api/auth/usersettings')
-        .reply(200, { apikey, customDomain, homepage });
-
-      const store = mockStore({});
-
-      const expectedActions = [
-        {
-          type: SET_DOMAIN,
-          payload: {
-            customDomain,
-            homepage: '',
-          }
-        },
-        {
-          type: SET_APIKEY,
-          payload: apikey
-        }
-      ];
-
-      store
-        .dispatch(getUserSettings())
-        .then(() => {
-          expect(store.getActions()).to.deep.equal(expectedActions);
-          done();
-        })
-        .catch(error => done(error));
-    });
-  });
-
-  describe('#setCustomDomain()', () => {
-    it('should dispatch SET_DOMAIN when setting custom domain has been done', done => {
-      const customDomain = 'test.com';
-      const homepage = '';
-
-      nock('http://localhost', {
-        reqheaders: {
-          Authorization: token
-        }
-      })
-        .post('/api/url/customdomain')
-        .reply(200, { customDomain, homepage });
-
-      const store = mockStore({});
-
-      const expectedActions = [
-        { type: DOMAIN_LOADING },
-        {
-          type: SET_DOMAIN,
-          payload: {
-            customDomain,
-            homepage: '',
-          }
-        }
-      ];
-
-      store
-        .dispatch(setCustomDomain({
-          customDomain,
-          homepage: '',
-        }))
-        .then(() => {
-          expect(store.getActions()).to.deep.equal(expectedActions);
-          done();
-        })
-        .catch(error => done(error));
-    });
-  });
-
-  describe('#deleteCustomDomain()', () => {
-    it('should dispatch DELETE_DOMAIN when deleting custom domain has been done', done => {
-      const customDomain = 'test.com';
-
-      nock('http://localhost', {
-        reqheaders: {
-          Authorization: token
-        }
-      })
-        .delete('/api/url/customdomain')
-        .reply(200, { customDomain });
-
-      const store = mockStore({});
-
-      const expectedActions = [{ type: DELETE_DOMAIN }];
-
-      store
-        .dispatch(deleteCustomDomain(customDomain))
-        .then(() => {
-          expect(store.getActions()).to.deep.equal(expectedActions);
-          done();
-        })
-        .catch(error => done(error));
-    });
-  });
-
-  describe('#generateApiKey()', () => {
-    it('should dispatch SET_APIKEY when generating api key has been done', done => {
-      const apikey = '123';
-
-      nock('http://localhost', {
-        reqheaders: {
-          Authorization: token
-        }
-      })
-        .post('/api/auth/generateapikey')
-        .reply(200, { apikey });
-
-      const store = mockStore({});
-
-      const expectedActions = [
-        { type: API_LOADING },
-        {
-          type: SET_APIKEY,
-          payload: apikey
-        }
-      ];
-
-      store
-        .dispatch(generateApiKey())
-        .then(() => {
-          expect(store.getActions()).to.deep.equal(expectedActions);
-          done();
-        })
-        .catch(error => done(error));
-    });
-  });
-});

+ 0 - 159
client/actions/__test__/url.js

@@ -1,159 +0,0 @@
-import nock from 'nock';
-import sinon from 'sinon';
-import { expect } from 'chai';
-import cookie from 'js-cookie';
-import thunk from 'redux-thunk';
-import configureMockStore from 'redux-mock-store';
-
-import { createShortUrl, getUrlsList, deleteShortUrl } from '../url';
-import { ADD_URL, LIST_URLS, DELETE_URL, TABLE_LOADING } from '../actionTypes';
-
-const middlewares = [thunk];
-const mockStore = configureMockStore(middlewares);
-
-describe('url actions', () => {
-  const token =
-    'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJBcGlBdXRoIiwic3ViIjoidGVzdEBtYWlsLmNvbSIsImRvbWFpbiI6IiIsImlhdCI6MTUyOTEzNzczODcyNSwiZXhwIjoxNTI5MTM3NzM4NzI1fQ.tdI7r11bmSCUmbcJBBKIDt7Hkb7POLMRl8VNJv_8O_s';
-
-  let cookieStub;
-
-  beforeEach(() => {
-    cookieStub = sinon.stub(cookie, 'get');
-    cookieStub.withArgs('token').returns(token);
-  });
-
-  afterEach(() => {
-    cookieStub.restore();
-  });
-
-  describe('#createShortUrl()', () => {
-    it('should dispatch ADD_URL when creating short url has been done', done => {
-      const url = 'test.com';
-      const mockedItems = {
-        createdAt: '2018-06-16T15:40:35.243Z',
-        id: '123',
-        target: url,
-        password: false,
-        reuse: false,
-        shortLink: 'http://kutt.it/123'
-      };
-
-      nock('http://localhost', {
-        reqheaders: {
-          Authorization: token
-        }
-      })
-        .post('/api/url/submit')
-        .reply(200, mockedItems);
-
-      const store = mockStore({});
-
-      const expectedActions = [
-        {
-          type: ADD_URL,
-          payload: mockedItems
-        }
-      ];
-
-      store
-        .dispatch(createShortUrl(url))
-        .then(() => {
-          expect(store.getActions()).to.deep.equal(expectedActions);
-          done();
-        })
-        .catch(error => done(error));
-    });
-  });
-
-  describe('#getUrlsList()', () => {
-    it('should dispatch LIST_URLS when getting urls list has been done', done => {
-      const mockedQueryParams = {
-        isShortened: false,
-        count: 10,
-        countAll: 1,
-        page: 1,
-        search: ''
-      };
-
-      const mockedItems = {
-        list: [
-          {
-            createdAt: '2018-06-16T16:45:28.607Z',
-            id: 'UkEs33',
-            target: 'https://kutt.it/',
-            password: false,
-            count: 0,
-            shortLink: 'http://test.com/UkEs33'
-          }
-        ],
-        countAll: 1
-      };
-
-      nock('http://localhost', {
-        reqheaders: {
-          Authorization: token
-        }
-      })
-        .get('/api/url/geturls')
-        .query(mockedQueryParams)
-        .reply(200, mockedItems);
-
-      const store = mockStore({ url: { list: [], ...mockedQueryParams } });
-
-      const expectedActions = [
-        { type: TABLE_LOADING },
-        {
-          type: LIST_URLS,
-          payload: mockedItems
-        }
-      ];
-
-      store
-        .dispatch(getUrlsList())
-        .then(() => {
-          expect(store.getActions()).to.deep.equal(expectedActions);
-          done();
-        })
-        .catch(error => done(error));
-    });
-  });
-
-  describe('#deleteShortUrl()', () => {
-    it('should dispatch DELETE_URL when deleting short url has been done', done => {
-      const id = '123';
-      const mockedItems = [
-        {
-          createdAt: '2018-06-16T15:40:35.243Z',
-          id: '123',
-          target: 'test.com',
-          password: false,
-          reuse: false,
-          shortLink: 'http://kutt.it/123'
-        }
-      ];
-
-      nock('http://localhost', {
-        reqheaders: {
-          Authorization: token
-        }
-      })
-        .post('/api/url/deleteurl')
-        .reply(200, { message: 'Short URL deleted successfully' });
-
-      const store = mockStore({ url: { list: mockedItems } });
-
-      const expectedActions = [
-        { type: TABLE_LOADING },
-        { type: DELETE_URL, payload: id }
-      ];
-
-      store
-        .dispatch(deleteShortUrl({ id }))
-        .then(() => {
-          expect(store.getActions()).to.deep.equal(expectedActions);
-          done();
-        })
-        .catch(error => done(error));
-    });
-  });
-});

+ 0 - 32
client/actions/actionTypes.js

@@ -1,32 +0,0 @@
-/* Homepage input actions */
-export const ADD_URL = 'ADD_URL';
-export const UPDATE_URL = 'UPDATE_URL';
-export const UPDATE_URL_LIST = 'UPDATE_URL_LIST';
-export const LIST_URLS = 'LIST_URLS';
-export const DELETE_URL = 'DELETE_URL';
-export const SHORTENER_ERROR = 'SHORTENER_ERROR';
-export const SHORTENER_LOADING = 'SHORTENER_LOADING';
-export const TABLE_LOADING = 'TABLE_LOADING';
-
-/* Page loading actions */
-export const SHOW_PAGE_LOADING = 'SHOW_PAGE_LOADING';
-export const HIDE_PAGE_LOADING = 'HIDE_PAGE_LOADING';
-
-/* Login & signup actions */
-export const AUTH_USER = 'AUTH_USER';
-export const AUTH_RENEW = 'AUTH_RENEW';
-export const UNAUTH_USER = 'UNAUTH_USER';
-export const SENT_VERIFICATION = 'SENT_VERIFICATION';
-export const AUTH_ERROR = 'AUTH_ERROR';
-export const LOGIN_LOADING = 'LOGIN_LOADING';
-export const SIGNUP_LOADING = 'SIGNUP_LOADING';
-
-/* Settings actions */
-export const SET_DOMAIN = 'SET_DOMAIN';
-export const SET_APIKEY = 'SET_APIKEY';
-export const DELETE_DOMAIN = 'DELETE_DOMAIN';
-export const DOMAIN_LOADING = 'DOMAIN_LOADING';
-export const API_LOADING = 'API_LOADING';
-export const DOMAIN_ERROR = 'DOMAIN_ERROR';
-export const SHOW_DOMAIN_INPUT = 'SHOW_DOMAIN_INPUT';
-export const BAN_URL = 'BAN_URL';

+ 0 - 92
client/actions/auth.js

@@ -1,92 +0,0 @@
-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,
-  HIDE_PAGE_LOADING,
-  AUTH_USER,
-  UNAUTH_USER,
-  SENT_VERIFICATION,
-  AUTH_ERROR,
-  LOGIN_LOADING,
-  SIGNUP_LOADING,
-  AUTH_RENEW,
-} from './actionTypes';
-
-const setDomain = payload => ({ type: SET_DOMAIN, payload });
-
-export const showPageLoading = () => ({ type: SHOW_PAGE_LOADING });
-export const hidePageLoading = () => ({ type: HIDE_PAGE_LOADING });
-export const authUser = payload => ({ type: AUTH_USER, payload });
-export const unauthUser = () => ({ type: UNAUTH_USER });
-export const sentVerification = payload => ({
-  type: SENT_VERIFICATION,
-  payload,
-});
-export const showAuthError = payload => ({ type: AUTH_ERROR, payload });
-export const showLoginLoading = () => ({ type: LOGIN_LOADING });
-export const showSignupLoading = () => ({ type: SIGNUP_LOADING });
-export const authRenew = () => ({ type: AUTH_RENEW });
-
-export const signupUser = payload => async dispatch => {
-  dispatch(showSignupLoading());
-  try {
-    const {
-      data: { email },
-    } = await axios.post('/api/auth/signup', payload);
-    dispatch(sentVerification(email));
-  } catch ({ response }) {
-    dispatch(showAuthError(response.data.error));
-  }
-};
-
-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 });
-    dispatch(authRenew());
-    dispatch(authUser(decodeJwt(token)));
-    dispatch(setDomain({ customDomain: decodeJwt(token).domain }));
-    dispatch(showPageLoading());
-    Router.push('/');
-  } catch ({ response }) {
-    dispatch(showAuthError(response.data.error));
-  }
-};
-
-export const logoutUser = () => dispatch => {
-  dispatch(showPageLoading());
-  cookie.remove('token');
-  dispatch(unauthUser());
-  Router.push('/login');
-};
-
-export const renewAuthUser = () => async (dispatch, getState) => {
-  if (getState().auth.renew) {
-    return;
-  }
-
-  const options = {
-    method: 'POST',
-    headers: { Authorization: cookie.get('token') },
-    url: '/api/auth/renew',
-  };
-
-  try {
-    const {
-      data: { token },
-    } = await axios(options);
-    cookie.set('token', token, { expires: 7 });
-    dispatch(authRenew());
-    dispatch(authUser(decodeJwt(token)));
-    dispatch(setDomain({ customDomain: decodeJwt(token).domain }));
-  } catch (error) {
-    cookie.remove('token');
-    dispatch(unauthUser());
-  }
-};

+ 0 - 3
client/actions/index.js

@@ -1,3 +0,0 @@
-export * from './url';
-export * from './settings';
-export * from './auth';

+ 0 - 85
client/actions/settings.js

@@ -1,85 +0,0 @@
-import axios from 'axios';
-import cookie from 'js-cookie';
-import {
-  DELETE_DOMAIN,
-  DOMAIN_ERROR,
-  DOMAIN_LOADING,
-  API_LOADING,
-  SET_DOMAIN,
-  SET_APIKEY,
-  SHOW_DOMAIN_INPUT,
-  BAN_URL,
-} from './actionTypes';
-
-const deleteDomain = () => ({ type: DELETE_DOMAIN });
-const setDomainError = payload => ({ type: DOMAIN_ERROR, payload });
-const showDomainLoading = () => ({ type: DOMAIN_LOADING });
-const showApiLoading = () => ({ type: API_LOADING });
-const urlBanned = () => ({ type: BAN_URL });
-
-export const setDomain = payload => ({ type: SET_DOMAIN, payload });
-export const setApiKey = payload => ({ type: SET_APIKEY, payload });
-export const showDomainInput = () => ({ type: SHOW_DOMAIN_INPUT });
-
-export const getUserSettings = () => async dispatch => {
-  try {
-    const {
-      data: { apikey, customDomain, homepage },
-    } = await axios.get('/api/auth/usersettings', {
-      headers: { Authorization: cookie.get('token') },
-    });
-    dispatch(setDomain({ customDomain, homepage }));
-    dispatch(setApiKey(apikey));
-  } catch (error) {
-    //
-  }
-};
-
-export const setCustomDomain = params => async dispatch => {
-  dispatch(showDomainLoading());
-  try {
-    const {
-      data: { customDomain, homepage },
-    } = await axios.post('/api/url/customdomain', params, {
-      headers: { Authorization: cookie.get('token') },
-    });
-    dispatch(setDomain({ customDomain, homepage }));
-  } catch ({ response }) {
-    dispatch(setDomainError(response.data.error));
-  }
-};
-
-export const deleteCustomDomain = () => async dispatch => {
-  try {
-    await axios.delete('/api/url/customdomain', {
-      headers: { Authorization: cookie.get('token') },
-    });
-    dispatch(deleteDomain());
-  } catch ({ response }) {
-    dispatch(setDomainError(response.data.error));
-  }
-};
-
-export const generateApiKey = () => async dispatch => {
-  dispatch(showApiLoading());
-  try {
-    const { data } = await axios.post('/api/auth/generateapikey', null, {
-      headers: { Authorization: cookie.get('token') },
-    });
-    dispatch(setApiKey(data.apikey));
-  } catch (error) {
-    //
-  }
-};
-
-export const banUrl = params => async dispatch => {
-  try {
-    const { data } = await axios.post('/api/url/admin/ban', params, {
-      headers: { Authorization: cookie.get('token') },
-    });
-    dispatch(urlBanned());
-    return data.message;
-  } catch ({ response }) {
-    return Promise.reject(response.data && response.data.error);
-  }
-};

+ 0 - 71
client/actions/url.js

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

+ 36 - 0
client/components/ALink.tsx

@@ -0,0 +1,36 @@
+import { Box, BoxProps } from "reflexbox/styled-components";
+import styled, { css } from "styled-components";
+import { ifProp } from "styled-tools";
+
+interface Props extends BoxProps {
+  href?: string;
+  title?: string;
+  target?: string;
+  rel?: string;
+  forButton?: boolean;
+}
+const ALink = styled(Box).attrs({
+  as: "a"
+})<Props>`
+  cursor: pointer;
+  color: #2196f3;
+  border-bottom: 1px dotted transparent;
+  text-decoration: none;
+  transition: all 0.2s ease-out;
+
+  ${ifProp(
+    { forButton: false },
+    css`
+      :hover {
+        border-bottom-color: #2196f3;
+      }
+    `
+  )}
+`;
+
+ALink.defaultProps = {
+  pb: "1px",
+  forButton: false
+};
+
+export default ALink;

+ 17 - 0
client/components/Animation.ts

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

+ 40 - 0
client/components/AppWrapper.tsx

@@ -0,0 +1,40 @@
+import { Flex } from "reflexbox/styled-components";
+import styled from "styled-components";
+import React from "react";
+
+import { useStoreState } from "../store";
+import PageLoading from "./PageLoading";
+import Header from "./Header";
+
+const Wrapper = styled(Flex)`
+  input {
+    filter: none;
+  }
+
+  * {
+    box-sizing: border-box;
+  }
+
+  *::-moz-focus-inner {
+    border: none;
+  }
+`;
+
+const AppWrapper = ({ children }: { children: any }) => {
+  const loading = useStoreState(s => s.loading.loading);
+
+  return (
+    <Wrapper
+      minHeight="100vh"
+      width={1}
+      flex="0 0 auto"
+      alignItems="center"
+      flexDirection="column"
+    >
+      <Header />
+      {loading ? <PageLoading /> : children}
+    </Wrapper>
+  );
+};
+
+export default AppWrapper;

+ 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';

+ 176 - 0
client/components/Button.tsx

@@ -0,0 +1,176 @@
+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" | "red";
+  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: ${switchProp(prop("color", "blue"), {
+    blue: "white",
+    red: "white",
+    purple: "white",
+    gray: "#444"
+  })};
+  background: ${switchProp(prop("color", "blue"), {
+    blue: "linear-gradient(to right, #42a5f5, #2979ff)",
+    red: "linear-gradient(to right, #ee3b3b, #e11c1c)",
+    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)",
+    red: "0 5px 6px rgba(168, 45, 45, 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)",
+      red: "0 6px 15px rgba(168, 45, 45, 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);
+  }
+`;
+
+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;
+    }
+  }
+`;
+
+export 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
+};
+
+interface NavButtonProps extends BoxProps {
+  disabled?: boolean;
+  onClick?: any; // TODO: better typing
+  type?: "button" | "submit" | "reset";
+}
+
+export const NavButton = styled(Flex)<NavButtonProps>`
+  display: flex;
+  border: none;
+  border-radius: 4px;
+  box-shadow: 0 0px 10px rgba(100, 100, 100, 0.1);
+  background-color: white;
+  cursor: pointer;
+  transition: all 0.2s ease-out;
+  box-sizing: border-box;
+
+  :hover {
+    transform: translateY(-2px);
+  }
+
+  ${ifProp(
+    "disabled",
+    css`
+      background-color: #f6f6f6;
+      box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1);
+      cursor: default;
+
+      :hover {
+        transform: none;
+      }
+    `
+  )}
+`;
+
+NavButton.defaultProps = {
+  as: "button",
+  type: "button",
+  flex: "0 0 auto",
+  alignItems: "center",
+  justifyContent: "center",
+  width: "auto",
+  height: [26, 28],
+  py: 0,
+  px: ["6px", "8px"],
+  fontSize: [12]
+};

+ 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';

+ 30 - 26
client/components/Stats/StatsCharts/Area.js → client/components/Charts/Area.tsx

@@ -1,9 +1,8 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import subHours from 'date-fns/subHours';
-import subDays from 'date-fns/subDays';
-import subMonths from 'date-fns/subMonths';
-import formatDate from 'date-fns/format';
+import subMonths from "date-fns/subMonths";
+import subHours from "date-fns/subHours";
+import formatDate from "date-fns/format";
+import subDays from "date-fns/subDays";
+import React, { FC } from "react";
 import {
   AreaChart,
   Area,
@@ -11,38 +10,48 @@ import {
   YAxis,
   CartesianGrid,
   ResponsiveContainer,
-  Tooltip,
-} from 'recharts';
-import withTitle from './withTitle';
+  Tooltip
+} from "recharts";
 
-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');
-      case 'lastDay':
-        return formatDate(subHours(now, rawData.length - index - 1), 'HH:00');
-      case 'lastMonth':
-      case 'lastWeek':
+      case "allTime":
+        return formatDate(
+          subMonths(now, rawData.length - index - 1),
+          "MMM yyy"
+        );
+      case "lastDay":
+        return formatDate(subHours(now, rawData.length - index - 1), "HH:00");
+      case "lastMonth":
+      case "lastWeek":
       default:
-        return formatDate(subDays(now, rawData.length - index - 1), 'MMM dd');
+        return formatDate(subDays(now, rawData.length - index - 1), "MMM dd");
     }
   };
   const data = rawData.map((view, index) => ({
     name: getDate(index),
-    views: view,
+    views: view
   }));
 
   return (
-    <ResponsiveContainer width="100%" height={window.innerWidth < 468 ? 240 : 320}>
+    <ResponsiveContainer
+      width="100%"
+      height={window.innerWidth < 468 ? 240 : 320}
+    >
       <AreaChart
         data={data}
         margin={{
           top: 16,
           right: 0,
           left: 0,
-          bottom: 16,
+          bottom: 16
         }}
       >
         <defs>
@@ -68,9 +77,4 @@ const ChartArea = ({ data: rawData, period }) => {
   );
 };
 
-ChartArea.propTypes = {
-  data: PropTypes.arrayOf(PropTypes.number.isRequired).isRequired,
-  period: PropTypes.string.isRequired,
-};
-
-export default withTitle(ChartArea);
+export default ChartArea;

+ 40 - 0
client/components/Charts/Bar.tsx

@@ -0,0 +1,40 @@
+import React, { FC } from "react";
+import {
+  BarChart,
+  Bar,
+  XAxis,
+  YAxis,
+  CartesianGrid,
+  Tooltip,
+  ResponsiveContainer
+} from "recharts";
+
+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"
+      margin={{
+        top: 0,
+        right: 0,
+        left: 24,
+        bottom: 0
+      }}
+    >
+      <XAxis type="number" dataKey="value" />
+      <YAxis type="category" dataKey="name" />
+      <CartesianGrid strokeDasharray="1 1" />
+      <Tooltip />
+      <Bar dataKey="value" fill="#B39DDB" />
+    </BarChart>
+  </ResponsiveContainer>
+);
+
+export default ChartBar;

+ 33 - 0
client/components/Charts/Pie.tsx

@@ -0,0 +1,33 @@
+import { PieChart, Pie, Tooltip, ResponsiveContainer } from "recharts";
+import React, { FC } from "react";
+
+interface Props {
+  data: any[]; // TODO: types
+}
+
+const ChartPie: FC<Props> = ({ data }) => (
+  <ResponsiveContainer
+    width="100%"
+    height={window.innerWidth < 468 ? 240 : 320}
+  >
+    <PieChart
+      margin={{
+        top: window.innerWidth < 468 ? 56 : 0,
+        right: window.innerWidth < 468 ? 56 : 0,
+        bottom: window.innerWidth < 468 ? 56 : 0,
+        left: window.innerWidth < 468 ? 56 : 0
+      }}
+    >
+      <Pie
+        data={data}
+        dataKey="value"
+        innerRadius={window.innerWidth < 468 ? 20 : 80}
+        fill="#B39DDB"
+        label={({ name }) => name}
+      />
+      <Tooltip />
+    </PieChart>
+  </ResponsiveContainer>
+);
+
+export default ChartPie;

+ 3 - 0
client/components/Charts/index.tsx

@@ -0,0 +1,3 @@
+export { default as Area } from "./Area";
+export { default as Bar } from "./Bar";
+export { default as Pie } from "./Pie";

+ 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, { Span } 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} />
+      <Span ml={12} color="#555">
+        {label}
+      </Span>
+    </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';

+ 14 - 0
client/components/Divider.tsx

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

+ 0 - 63
client/components/Error/Error.js

@@ -1,63 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import styled, { css } from 'styled-components';
-import { fadeIn } from '../../helpers/animations';
-
-const ErrorMessage = styled.p`
-  content: '';
-  position: absolute;
-  right: 36px;
-  bottom: -64px;
-  display: block;
-  font-size: 14px;
-  color: red;
-  animation: ${fadeIn} 0.3s ease-out;
-
-  @media only screen and (max-width: 768px) {
-    right: 8px;
-    bottom: -40px;
-    font-size: 12px;
-  }
-
-  ${({ left }) =>
-    left > -1 &&
-    css`
-      right: auto;
-      left: ${left}px;
-    `};
-
-  ${({ bottom }) =>
-    bottom &&
-    css`
-      bottom: ${bottom}px;
-    `};
-`;
-
-const Error = ({ bottom, error, left, type }) => {
-  const message = error[type] && (
-    <ErrorMessage left={left} bottom={bottom}>
-      {error[type]}
-    </ErrorMessage>
-  );
-  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,
-};
-
-const mapStateToProps = ({ error }) => ({ error });
-
-export default connect(mapStateToProps)(Error);

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

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

+ 28 - 40
client/components/Extensions/Extensions.js → client/components/Extensions.tsx

@@ -1,36 +1,10 @@
-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;
-  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;
-  }
-`;
+import React from "react";
+import styled from "styled-components";
+import { Flex } from "reflexbox/styled-components";
+import SVG from "react-inlinesvg"; // TODO: another solution
+import { Colors } from "../consts";
+import { ColCenterH } from "./Layout";
+import Text, { H3 } from "./Text";
 
 const Title = styled.h3`
   font-size: 28px;
@@ -55,7 +29,7 @@ const Button = styled.button`
   justify-content: center;
   margin: 0 16px;
   padding: 12px 28px;
-  font-family: 'Nunito', sans-serif;
+  font-family: "Nunito", sans-serif;
   background-color: #eee;
   border: 1px solid #aaa;
   font-size: 14px;
@@ -106,7 +80,7 @@ const Icon = styled(SVG)`
     width: 18px;
     height: 18px;
     margin-right: 16px;
-    fill: ${props => props.color || '#333'};
+    fill: ${props => props.color || "#333"};
 
     @media only screen and (max-width: 768px) {
       width: 13px;
@@ -117,9 +91,23 @@ const Icon = styled(SVG)`
 `;
 
 const Extensions = () => (
-  <Section>
-    <Title>Browser extensions.</Title>
-    <Wrapper>
+  <ColCenterH
+    width={1}
+    flex="0 0 auto"
+    flexWrap={["wrap", "wrap", "nowrap"]}
+    py={[64, 96]}
+    backgroundColor={Colors.ExtensionsBg}
+  >
+    <H3 fontSize={[26, 28]} mb={5} color="white" light>
+      Browser extensions.
+    </H3>
+    <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,8 +128,8 @@ const Extensions = () => (
           <span>Download for Firefox</span>
         </FirefoxButton>
       </Link>
-    </Wrapper>
-  </Section>
+    </Flex>
+  </ColCenterH>
 );
 
 export default Extensions;

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

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

+ 44 - 0
client/components/Features.tsx

@@ -0,0 +1,44 @@
+import React from "react";
+import styled from "styled-components";
+import { Flex } from "reflexbox/styled-components";
+
+import FeaturesItem from "./FeaturesItem";
+import { ColCenterH } from "./Layout";
+import { Colors } from "../consts";
+import Text, { H3 } from "./Text";
+
+const Features = () => (
+  <ColCenterH
+    width={1}
+    flex="0 0 auto"
+    py={[64, 100]}
+    backgroundColor={Colors.FeaturesBg}
+  >
+    <H3 fontSize={[26, 28]} mb={72} light>
+      Kutting edge features.
+    </H3>
+    <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.
+      </FeaturesItem>
+      <FeaturesItem title="Custom domain" icon="navigation">
+        Use custom domains for your links. Add or remove them for free.
+      </FeaturesItem>
+      <FeaturesItem title="API" icon="zap">
+        Use the provided API to create, delete and get URLs from anywhere.
+      </FeaturesItem>
+      <FeaturesItem title="Free &amp; open source" icon="heart">
+        Completely open source and free. You can host it on your own server.
+      </FeaturesItem>
+    </Flex>
+  </ColCenterH>
+);
+
+export default Features;

+ 0 - 71
client/components/Features/Features.js

@@ -1,71 +0,0 @@
-import React from 'react';
-import styled from 'styled-components';
-import FeaturesItem from './FeaturesItem';
-
-const Section = styled.div`
-  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`
-  font-size: 28px;
-  font-weight: 300;
-  margin: 0 0 72px;
-
-  @media only screen and (max-width: 768px) {
-    font-size: 24px;
-    margin-bottom: 56px;
-  }
-
-  @media only screen and (max-width: 448px) {
-    font-size: 20px;
-    margin-bottom: 40px;
-  }
-`;
-
-const Features = () => (
-  <Section>
-    <Title>Kutting edge features.</Title>
-    <Wrapper>
-      <FeaturesItem title="Managing links" icon="edit">
-        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.
-      </FeaturesItem>
-      <FeaturesItem title="API" icon="zap">
-        Use the provided API to create, delete and get URLs from anywhere.
-      </FeaturesItem>
-      <FeaturesItem title="Free &amp; open source" icon="heart">
-        Completely open source and free. You can host it on your own server.
-      </FeaturesItem>
-    </Wrapper>
-  </Section>
-);
-
-export default Features;

+ 0 - 97
client/components/Features/FeaturesItem.js

@@ -1,97 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-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;
-  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;
-  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`
-  display: inline-block;
-  width: 16px;
-  height: 16px;
-  margin: 0;
-  padding: 0;
-
-  @media only screen and (max-width: 448px) {
-    width: 14px;
-    height: 14px;
-  }
-`;
-
-const Title = styled.h3`
-  margin: 16px;
-  font-size: 15px;
-
-  @media only screen and (max-width: 448px) {
-    margin: 12px;
-    font-size: 14px;
-  }
-`;
-
-const Description = styled.p`
-  margin: 0;
-  font-size: 14px;
-  font-weight: 300;
-  text-align: center;
-
-  @media only screen and (max-width: 448px) {
-    font-size: 13px;
-  }
-`;
-
-const FeaturesItem = ({ children, icon, title }) => (
-  <Block>
-    <IconBox>
-      <Icon src={`/images/${icon}.svg`} />
-    </IconBox>
-    <Title>{title}</Title>
-    <Description>{children}</Description>
-  </Block>
-);
-
-FeaturesItem.propTypes = {
-  children: PropTypes.node.isRequired,
-  icon: PropTypes.string.isRequired,
-  title: PropTypes.string.isRequired,
-};
-
-export default FeaturesItem;

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

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

+ 81 - 0
client/components/FeaturesItem.tsx

@@ -0,0 +1,81 @@
+import React, { FC } from "react";
+import styled from "styled-components";
+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;
+  }
+`;
+
+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;
+`;
+
+const Icon = styled.img`
+  display: inline-block;
+  width: 16px;
+  height: 16px;
+  margin: 0;
+  padding: 0;
+
+  @media only screen and (max-width: 448px) {
+    width: 14px;
+    height: 14px;
+  }
+`;
+
+const Title = styled.h3`
+  margin: 16px;
+  font-size: 15px;
+
+  @media only screen and (max-width: 448px) {
+    margin: 12px;
+    font-size: 14px;
+  }
+`;
+
+const Description = styled.p`
+  margin: 0;
+  font-size: 14px;
+  font-weight: 300;
+  text-align: center;
+
+  @media only screen and (max-width: 448px) {
+    font-size: 13px;
+  }
+`;
+
+const FeaturesItem: FC<Props> = ({ children, icon, title }) => (
+  <Block>
+    <IconBox>
+      <Icon src={`/images/${icon}.svg`} />
+    </IconBox>
+    <Title>{title}</Title>
+    <Description>{children}</Description>
+  </Block>
+);
+
+export default FeaturesItem;

+ 63 - 0
client/components/Footer.tsx

@@ -0,0 +1,63 @@
+import React, { FC, useEffect } from "react";
+
+import showRecaptcha from "../helpers/recaptcha";
+import { useStoreState } from "../store";
+import { ColCenter } from "./Layout";
+import ReCaptcha from "./ReCaptcha";
+import ALink from "./ALink";
+import Text from "./Text";
+
+const Footer: FC = () => {
+  const { isAuthenticated } = useStoreState(s => s.auth);
+
+  useEffect(() => {
+    showRecaptcha();
+  }, []);
+
+  return (
+    <ColCenter
+      as="footer"
+      width={1}
+      backgroundColor="white"
+      p={isAuthenticated ? 2 : 24}
+    >
+      {!isAuthenticated && <ReCaptcha />}
+      <Text fontSize={[12, 13]} py={2}>
+        Made with love by{" "}
+        <ALink href="//thedevs.network/" title="The Devs">
+          The Devs
+        </ALink>
+        .{" | "}
+        <ALink
+          href="https://github.com/thedevs-network/kutt"
+          title="GitHub"
+          target="_blank"
+        >
+          GitHub
+        </ALink>
+        {" | "}
+        <ALink href="/terms" title="Terms of Service">
+          Terms of Service
+        </ALink>
+        {" | "}
+        <ALink href="/report" title="Report abuse">
+          Report Abuse
+        </ALink>
+        {process.env.CONTACT_EMAIL && (
+          <>
+            {" | "}
+            <ALink
+              href={`mailto:${process.env.CONTACT_EMAIL}`}
+              title="Contact us"
+            >
+              Contact us
+            </ALink>
+          </>
+        )}
+        .
+      </Text>
+    </ColCenter>
+  );
+};
+
+export default 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';

+ 157 - 0
client/components/Header.tsx

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

@@ -1,62 +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 Router from 'next/router';
-import HeaderMenuItem from './HeaderMenuItem';
-import { showPageLoading } from '../../actions';
-
-const List = styled.ul`
-  display: flex;
-  align-items: flex-end;
-  list-style: none;
-  margin: 0 0 3px;
-  padding: 0;
-
-  @media only screen and (max-width: 488px) {
-    display: none;
-  }
-`;
-
-const HeaderLeftMenu = props => {
-  const goTo = e => {
-    e.preventDefault();
-    const path = e.currentTarget.getAttribute('href');
-    if (!path || window.location.pathname === path) return;
-    props.showPageLoading();
-    Router.push(path);
-  };
-  return (
-    <List>
-      <HeaderMenuItem>
-        <a
-          href="//github.com/thedevs-network/kutt"
-          target="_blank"
-          rel="noopener noreferrer"
-          title="GitHub"
-        >
-          GitHub
-        </a>
-      </HeaderMenuItem>
-      <HeaderMenuItem>
-        <a href="/report" title="Report abuse" onClick={goTo}>
-          Report
-        </a>
-      </HeaderMenuItem>
-    </List>
-  );
-};
-
-HeaderLeftMenu.propTypes = {
-  showPageLoading: PropTypes.func.isRequired,
-};
-
-const mapDispatchToProps = dispatch => ({
-  showPageLoading: bindActionCreators(showPageLoading, dispatch),
-});
-
-export default connect(
-  null,
-  mapDispatchToProps
-)(HeaderLeftMenu);

+ 0 - 38
client/components/Header/HeaderMenuItem.js

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

+ 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';

+ 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;

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

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

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

@@ -0,0 +1,22 @@
+import React from "react";
+
+function ChevronLeft() {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="24"
+      height="24"
+      fill="none"
+      stroke="currentColor"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      strokeWidth="2"
+      className="feather feather-chevron-left"
+      viewBox="0 0 24 24"
+    >
+      <path d="M15 18L9 12 15 6"></path>
+    </svg>
+  );
+}
+
+export default React.memo(ChevronLeft);

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

@@ -0,0 +1,22 @@
+import React from "react";
+
+function ChevronRight() {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="24"
+      height="24"
+      fill="none"
+      stroke="currentColor"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      strokeWidth="2"
+      className="feather feather-chevron-right"
+      viewBox="0 0 24 24"
+    >
+      <path d="M9 18L15 12 9 6"></path>
+    </svg>
+  );
+}
+
+export default React.memo(ChevronRight);

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

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

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

@@ -0,0 +1,23 @@
+import React from "react";
+
+function Copy() {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="24"
+      height="24"
+      fill="none"
+      stroke="currentColor"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      strokeWidth="2"
+      className="feather feather-copy"
+      viewBox="0 0 24 24"
+    >
+      <rect width="13" height="13" x="9" y="9" rx="2" ry="2"></rect>
+      <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
+    </svg>
+  );
+}
+
+export default React.memo(Copy);

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

@@ -0,0 +1,153 @@
+import { Flex } from "reflexbox/styled-components";
+import styled, { css } from "styled-components";
+import { prop, ifProp } from "styled-tools";
+import React, { FC } from "react";
+
+import ChevronRight from "./ChevronRight";
+import ChevronLeft from "./ChevronLeft";
+import { Colors } from "../../consts";
+import Clipboard from "./Clipboard";
+import PieChart from "./PieChart";
+import Refresh from "./Refresh";
+import Spinner from "./Spinner";
+import QRCode from "./QRCode";
+import Trash from "./Trash";
+import Check from "./Check";
+import Plus from "./Plus";
+import Lock from "./Lock";
+import Copy from "./Copy";
+import Send from "./Send";
+import Key from "./Key";
+import Zap from "./Zap";
+
+export interface IIcons {
+  clipboard: JSX.Element;
+  chevronRight: JSX.Element;
+  chevronLeft: JSX.Element;
+  pieChart: JSX.Element;
+  key: JSX.Element;
+  plus: JSX.Element;
+  Lock: JSX.Element;
+  copy: JSX.Element;
+  refresh: JSX.Element;
+  check: JSX.Element;
+  send: JSX.Element;
+  spinner: JSX.Element;
+  trash: JSX.Element;
+  zap: JSX.Element;
+  qrcode: JSX.Element;
+}
+
+const icons = {
+  clipboard: Clipboard,
+  chevronRight: ChevronRight,
+  chevronLeft: ChevronLeft,
+  pieChart: PieChart,
+  key: Key,
+  lock: Lock,
+  check: Check,
+  plus: Plus,
+  copy: Copy,
+  refresh: Refresh,
+  send: Send,
+  spinner: Spinner,
+  trash: Trash,
+  zap: Zap,
+  qrcode: QRCode
+};
+
+interface Props extends React.ComponentProps<typeof Flex> {
+  name: keyof typeof icons;
+  stroke?: string;
+  fill?: string;
+  hoverFill?: string;
+  hoverStroke?: string;
+  strokeWidth?: string;
+}
+
+const CustomIcon: FC<React.ComponentProps<typeof Flex>> = styled(Flex)`
+  position: relative;
+
+  svg {
+    transition: all 0.2s ease-out;
+    width: 100%;
+    height: 100%;
+
+    ${ifProp(
+      "fill",
+      css`
+        fill: ${prop("fill")};
+      `
+    )}
+
+    ${ifProp(
+      "stroke",
+      css`
+        stroke: ${prop("stroke")};
+      `
+    )}
+
+    ${ifProp(
+      "strokeWidth",
+      css`
+        stroke-width: ${prop("strokeWidth")};
+      `
+    )}
+  }
+
+  ${ifProp(
+    "hoverFill",
+    css`
+      :hover {
+        svg {
+          fill: ${prop("hoverFill")};
+        }
+      }
+    `
+  )}
+
+  ${ifProp(
+    "hoverStroke",
+    css`
+      :hover {
+        svg {
+          stroke: ${prop("stroke")};
+        }
+      }
+    `
+  )}
+
+  ${ifProp(
+    { as: "button" },
+    css`
+      border: none;
+      outline: none;
+      transition: transform 0.4s ease-out;
+      border-radius: 100%;
+      background-color: none !important;
+      cursor: pointer;
+      box-sizing: border-box;
+      box-shadow: 0 2px 1px ${Colors.IconShadow};
+
+      :hover,
+      :focus {
+        transform: translateY(-2px) scale(1.02, 1.02);
+      }
+      :focus {
+        outline: 3px solid rgba(65, 164, 245, 0.5);
+      }
+    `
+  )}
+`;
+
+const Icon: FC<Props> = ({ name, ...rest }) => (
+  <CustomIcon {...rest}>{React.createElement(icons[name])}</CustomIcon>
+);
+
+Icon.defaultProps = {
+  size: 16,
+  alignItems: "center",
+  justifyContent: "center"
+};
+
+export default Icon;

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

@@ -0,0 +1,22 @@
+import React from "react";
+
+function Key() {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="24"
+      height="24"
+      fill="none"
+      stroke="currentColor"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      strokeWidth="2"
+      className="feather feather-key"
+      viewBox="0 0 24 24"
+    >
+      <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path>
+    </svg>
+  );
+}
+
+export default React.memo(Key);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 37 - 0
client/components/Layout.tsx

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

+ 383 - 0
client/components/LinksTable.tsx

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

+ 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';

+ 56 - 0
client/components/Modal.tsx

@@ -0,0 +1,56 @@
+import { Flex } from "reflexbox/styled-components";
+import styled from "styled-components";
+import React, { FC } from "react";
+
+import Animation from "./Animation";
+
+interface Props extends React.ComponentProps<typeof Flex> {
+  show: boolean;
+  id?: string;
+  closeHandler?: () => unknown;
+}
+
+const Wrapper = styled.div`
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: rgba(50, 50, 50, 0.8);
+  z-index: 1000;
+`;
+
+const Modal: FC<Props> = ({ children, id, show, closeHandler, ...rest }) => {
+  if (!show) return null;
+
+  const onClickOutside = e => {
+    if (e.target.id === id) closeHandler();
+  };
+
+  return (
+    <Wrapper id={id} onClick={onClickOutside}>
+      <Animation
+        offset="-20px"
+        duration="0.2s"
+        minWidth={[400, 450]}
+        maxWidth={0.9}
+        py={[32, 32, 48]}
+        px={[24, 24, 32]}
+        style={{ borderRadius: 8, backgroundColor: "white" }}
+        flexDirection="column"
+        {...rest}
+      >
+        {children}
+      </Animation>
+    </Wrapper>
+  );
+};
+
+Modal.defaultProps = {
+  show: false
+};
+
+export default Modal;

+ 0 - 67
client/components/Modal/Modal.js

@@ -1,67 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import styled from 'styled-components';
-import Button from '../Button';
-
-const Wrapper = styled.div`
-  position: fixed;
-  width: 100%;
-  height: 100%;
-  top: 0;
-  left: 0;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  background-color: rgba(50, 50, 50, 0.8);
-  z-index: 1000;
-`;
-
-const Content = styled.div`
-  padding: 48px 64px;
-  text-align: center;
-  border-radius: 8px;
-  background-color: white;
-
-  @media only screen and (max-width: 768px) {
-    width: 90%;
-    padding: 32px;
-  }
-`;
-
-const ButtonsWrapper = styled.div`
-  display: flex;
-  justify-content: center;
-  margin-top: 40px;
-  button {
-    margin: 0 16px;
-  }
-`;
-
-const Modal = ({ children, handler, show, close }) =>
-  show ? (
-    <Wrapper>
-      <Content>
-        {children}
-        <ButtonsWrapper>
-          <Button color="gray" onClick={close}>
-            {handler ? 'No' : 'Close'}
-          </Button>
-          {handler && <Button onClick={handler}>Yes</Button>}
-        </ButtonsWrapper>
-      </Content>
-    </Wrapper>
-  ) : null;
-
-Modal.propTypes = {
-  children: PropTypes.node.isRequired,
-  close: PropTypes.func.isRequired,
-  handler: PropTypes.func,
-  show: PropTypes.bool,
-};
-
-Modal.defaultProps = {
-  show: false,
-  handler: null,
-};
-
-export default Modal;

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

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

+ 22 - 33
client/components/NeedToLogin/NeedToLogin.js → client/components/NeedToLogin.tsx

@@ -1,40 +1,25 @@
-import React from 'react';
-import Link from 'next/link';
-import styled from 'styled-components';
-import Button from '../Button';
-import { fadeIn } from '../../helpers/animations';
+import React from "react";
+import Link from "next/link";
+import styled from "styled-components";
+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";
+import { Col } from "./Layout";
+
+const Wrapper = styled(Flex).attrs({
+  width: 1200,
+  maxWidth: "98%",
+  alignItems: "center",
+  margin: "150px 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,16 +57,20 @@ const Image = styled.img`
 
 const NeedToLogin = () => (
   <Wrapper>
-    <TitleWrapper>
+    <Col
+      alignItems={["center", "center", "flex-start"]}
+      mt={-32}
+      mb={[32, 32, 0]}
+    >
       <Title>
         Manage links, set custom <b>domains</b> and view <b>stats</b>.
       </Title>
-      <Link href="/login" prefetch>
+      <Link href="/login">
         <a href="/login" title="login / signup">
           <Button>Login / Signup</Button>
         </a>
       </Link>
-    </TitleWrapper>
+    </Col>
     <Image src="/images/callout.png" />
   </Wrapper>
 );

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

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

+ 19 - 0
client/components/PageLoading.tsx

@@ -0,0 +1,19 @@
+import { Flex } from "reflexbox/styled-components";
+import React from "react";
+
+import { Colors } from "../consts";
+import Icon from "./Icon";
+
+const PageLoading = () => (
+  <Flex
+    flex="1 1 250px"
+    alignItems="center"
+    alignSelf="center"
+    justifyContent="center"
+    margin="0 0 48px"
+  >
+    <Icon name="spinner" size={24} stroke={Colors.Spinner} />
+  </Flex>
+);
+
+export default PageLoading;

+ 0 - 28
client/components/PageLoading/PageLoading.js

@@ -1,28 +0,0 @@
-import React from 'react';
-import styled from 'styled-components';
-import { spin } from '../../helpers/animations';
-
-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;
-`;
-
-const Icon = styled.img`
-  display: block;
-  width: 28px;
-  height: 28px;
-  animation: ${spin} 1s linear infinite;
-`;
-
-const pageLoading = () => (
-  <Loading>
-    <Icon src="/images/loader.svg" />
-  </Loading>
-);
-
-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);

+ 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;

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

@@ -0,0 +1,95 @@
+import { CopyToClipboard } from "react-copy-to-clipboard";
+import { Flex } from "reflexbox/styled-components";
+import React, { FC, useState } from "react";
+import styled from "styled-components";
+
+import { useStoreState, useStoreActions } from "../../store";
+import { Button } from "../Button";
+import ALink from "../ALink";
+import Icon from "../Icon";
+import Text, { H2 } from "../Text";
+import { Col } from "../Layout";
+
+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 () => {
+    if (loading) return;
+    setLoading(true);
+    await generateApiKey();
+    setLoading(false);
+  };
+
+  return (
+    <Col alignItems="flex-start">
+      <H2 mb={4} bold>
+        API
+      </H2>
+      <Text 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 && (
+        <Col style={{ position: "relative" }} my={3}>
+          {copied && (
+            <Text
+              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>
+        </Col>
+      )}
+      <Button color="purple" onClick={onSubmit} disabled={loading}>
+        <Icon name={loading ? "spinner" : "zap"} mr={2} stroke="white" />
+        {loading ? "Generating..." : apikey ? "Regenerate" : "Generate"} key
+      </Button>
+    </Col>
+  );
+};
+
+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;

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

@@ -0,0 +1,89 @@
+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 { useMessage } from "../../hooks";
+import TextInput from "../TextInput";
+import Checkbox from "../Checkbox";
+import { API } from "../../consts";
+import { Button } from "../Button";
+import Icon from "../Icon";
+import Text, { H2 } from "../Text";
+import { Col } from "../Layout";
+
+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 (
+    <Col>
+      <H2 mb={4} bold>
+        Ban link
+      </H2>
+      <Col as="form" 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]}
+            required
+          />
+          <Button type="submit" disabled={submitting}>
+            <Icon
+              name={submitting ? "spinner" : "lock"}
+              stroke="white"
+              mr={2}
+            />
+            {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>
+      </Col>
+    </Col>
+  );
+};
+
+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;

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

@@ -0,0 +1,189 @@
+import { Flex } from "reflexbox/styled-components";
+import React, { FC, useState } from "react";
+import styled from "styled-components";
+
+import { useStoreState, useStoreActions } from "../../store";
+import { useFormState } from "react-use-form-state";
+import { Domain } from "../../store/settings";
+import { useMessage } from "../../hooks";
+import { Colors } from "../../consts";
+import TextInput from "../TextInput";
+import { Button } from "../Button";
+import Table from "../Table";
+import Modal from "../Modal";
+import Icon from "../Icon";
+import Text, { H2, Span } from "../Text";
+import { Col } from "../Layout";
+
+const Th = styled(Flex).attrs({ as: "th", py: 3, px: 3 })`
+  font-size: 15px;
+`;
+const Td = styled(Flex).attrs({ as: "td", py: 12, px: 3 })`
+  font-size: 15px;
+`;
+
+const SettingsDomain: FC = () => {
+  const [modal, setModal] = useState(false);
+  const [loading, setLoading] = useState(false);
+  const [deleteLoading, setDeleteLoading] = useState(false);
+  const [domainToDelete, setDomainToDelete] = useState<Domain>(null);
+  const [message, setMessage] = useMessage(2000);
+  const domains = useStoreState(s => s.settings.domains);
+  const { saveDomain, deleteDomain } = useStoreActions(s => s.settings);
+  const [formState, { label, text }] = useFormState<{
+    customDomain: string;
+    homepage: string;
+  }>(null, { withIds: true });
+
+  const onSubmit = async e => {
+    e.preventDefault();
+    setLoading(true);
+
+    try {
+      await saveDomain(formState.values);
+    } catch (err) {
+      setMessage(err?.response?.data?.error || "Couldn't add domain.");
+    }
+    formState.clear();
+    setLoading(false);
+  };
+
+  const closeModal = () => {
+    setDomainToDelete(null);
+    setModal(false);
+  };
+
+  const onDelete = async () => {
+    setDeleteLoading(true);
+    try {
+      await deleteDomain();
+      setMessage("Domain has been deleted successfully.", "green");
+    } catch (err) {
+      setMessage(err?.response?.data?.error || "Couldn't delete the domain.");
+    }
+    closeModal();
+    setDeleteLoading(false);
+  };
+
+  return (
+    <Col alignItems="flex-start">
+      <H2 mb={4} bold>
+        Custom domain
+      </H2>
+      <Text 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 ? (
+        <Table my={3}>
+          <thead>
+            <tr>
+              <Th width={2 / 5}>Domain</Th>
+              <Th width={2 / 5}>Homepage</Th>
+              <Th width={1 / 5}></Th>
+            </tr>
+          </thead>
+          <tbody>
+            {domains.map(d => (
+              <tr>
+                <Td width={2 / 5}>{d.customDomain}</Td>
+                <Td width={2 / 5}>{d.homepage || "default"}</Td>
+                <Td width={1 / 5} justifyContent="center">
+                  <Icon
+                    as="button"
+                    name="trash"
+                    stroke={Colors.TrashIcon}
+                    strokeWidth="2.5"
+                    backgroundColor={Colors.TrashIconBg}
+                    py={0}
+                    px={0}
+                    size={[23, 24]}
+                    p={["4px", "5px"]}
+                    onClick={() => {
+                      setDomainToDelete(d);
+                      setModal(true);
+                    }}
+                  />
+                </Td>
+              </tr>
+            ))}
+          </tbody>
+        </Table>
+      ) : (
+        <Col
+          alignItems="flex-start"
+          onSubmit={onSubmit}
+          width={1}
+          as="form"
+          my={4}
+        >
+          <Flex width={1}>
+            <Col mr={2} flex="1 1 auto">
+              <Text {...label("customDomain")} as="label" mb={3} bold>
+                Domain
+              </Text>
+              <TextInput
+                {...text("customDomain")}
+                placeholder="example.com"
+                height={44}
+                pl={24}
+                pr={24}
+                required
+              />
+            </Col>
+            <Col ml={2} flex="1 1 auto">
+              <Text {...label("homepage")} as="label" mb={3} bold>
+                Homepage (optional)
+              </Text>
+              <TextInput
+                {...text("homepage")}
+                placeholder="Homepage URL"
+                flex="1 1 auto"
+                height={44}
+                pl={24}
+                pr={24}
+              />
+            </Col>
+          </Flex>
+          <Button type="submit" color="purple" mt={3} disabled={loading}>
+            <Icon name={loading ? "spinner" : "plus"} mr={2} stroke="white" />
+            {loading ? "Setting..." : "Set domain"}
+          </Button>
+        </Col>
+      )}
+      <Text color={message.color}>{message.text}</Text>
+      <Modal id="delete-custom-domain" show={modal} closeHandler={closeModal}>
+        <H2 mb={24} textAlign="center" bold>
+          Delete domain?
+        </H2>
+        <Text textAlign="center">
+          Are you sure do you want to delete the domain{" "}
+          <Span bold>"{domainToDelete && domainToDelete.customDomain}"</Span>?
+        </Text>
+        <Flex justifyContent="center" mt={44}>
+          {deleteLoading ? (
+            <>
+              <Icon name="spinner" size={20} stroke={Colors.Spinner} />
+            </>
+          ) : (
+            <>
+              <Button color="gray" mr={3} onClick={closeModal}>
+                Cancel
+              </Button>
+              <Button color="red" ml={3} onClick={onDelete}>
+                <Icon name="trash" stroke="white" mr={2} />
+                Delete
+              </Button>
+            </>
+          )}
+        </Flex>
+      </Modal>
+    </Col>
+  );
+};
+
+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;

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

@@ -0,0 +1,79 @@
+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 Icon from "../Icon";
+import Text, { H2 } from "../Text";
+import { Col } from "../Layout";
+
+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 (
+    <Col alignItems="flex-start">
+      <H2 mb={4} bold>
+        Change password
+      </H2>
+      <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" disabled={loading}>
+          <Icon name={loading ? "spinner" : "refresh"} mr={2} stroke="white" />
+          {loading ? "Updating..." : "Update"}
+        </Button>
+      </Flex>
+      <Text color={message.color} mt={3} fontSize={15}>
+        {message.text}
+      </Text>
+    </Col>
+  );
+};
+
+export default SettingsPassword;

+ 0 - 29
client/components/Settings/SettingsWelcome.js

@@ -1,29 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import styled from 'styled-components';
-
-const Title = styled.h2`
-  font-size: 28px;
-  font-weight: 300;
-
-  span {
-    padding-bottom: 2px;
-    border-bottom: 2px dotted #999;
-  }
-
-  @media only screen and (max-width: 768px) {
-    font-size: 22px;
-  }
-`;
-
-const SettingsWelcome = ({ 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";

+ 258 - 0
client/components/Shortener.tsx

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

+ 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);

+ 0 - 77
client/components/Shortener/ShortenerInput.js

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

+ 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;

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

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

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

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

+ 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);

+ 0 - 31
client/components/Stats/StatsCharts/Bar.js

@@ -1,31 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-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}>
-    <BarChart
-      data={data}
-      layout="vertical"
-      margin={{
-        top: 0,
-        right: 0,
-        left: 24,
-        bottom: 0,
-      }}
-    >
-      <XAxis type="number" dataKey="value" />
-      <YAxis type="category" dataKey="name" />
-      <CartesianGrid strokeDasharray="1 1" />
-      <Tooltip />
-      <Bar dataKey="value" fill="#B39DDB" />
-    </BarChart>
-  </ResponsiveContainer>
-);
-
-ChartBar.propTypes = {
-  data: PropTypes.arrayOf(PropTypes.object.isRequired).isRequired,
-};
-
-export default withTitle(ChartBar);

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません