Browse Source

feat: refactor client and improve design (#260)

* refactor: (wip)

* refactor: finish settings, add icons and stuff

* 🐬

* 🐬

* 2.2.0
tags/v2.2.1
Pouria Ezzati 9 months ago
committed by GitHub
parent
commit
4680a0dbec
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
100 changed files with 2620 additions and 3717 deletions
  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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

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

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

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

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

+ 0
- 85
client/actions/settings.js View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

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

+ 176
- 0
client/components/Button.tsx View File

@@ -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 View File

@@ -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 View File

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

client/components/Stats/StatsCharts/Area.js → client/components/Charts/Area.tsx View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

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

+ 14
- 0
client/components/Divider.tsx View File

@@ -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 View File

@@ -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 View File

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

client/components/Extensions/Extensions.js → client/components/Extensions.tsx View File

@@ -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 View File

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

+ 44
- 0
client/components/Features.tsx View File

@@ -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.