feat: refactor client and improve design (#260)
* refactor: (wip) * refactor: finish settings, add icons and stuff * 🐬 * 🐬 * 2.2.0
This commit is contained in:
parent
362aa1058e
commit
4680a0dbec
10
.babelrc
10
.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"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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=
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"endOfLine": "lf"
|
||||
}
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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());
|
||||
}
|
||||
};
|
|
@ -1,3 +0,0 @@
|
|||
export * from './url';
|
||||
export * from './settings';
|
||||
export * from './auth';
|
|
@ -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);
|
||||
}
|
||||
};
|
|
@ -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));
|
||||
}
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
|
@ -1 +0,0 @@
|
|||
export { default } from './BodyWrapper';
|
|
@ -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]
|
||||
};
|
|
@ -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;
|
|
@ -1 +0,0 @@
|
|||
export { default } from './Button';
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,3 @@
|
|||
export { default as Area } from "./Area";
|
||||
export { default as Bar } from "./Bar";
|
||||
export { default as Pie } from "./Pie";
|
|
@ -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;
|
|
@ -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;
|
|
@ -1 +0,0 @@
|
|||
export { default } from './Checkbox';
|
|
@ -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;
|
|
@ -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);
|
|
@ -1 +0,0 @@
|
|||
export { default } from './Error';
|
|
@ -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;
|
|
@ -1 +0,0 @@
|
|||
export { default } from './Extensions';
|
|
@ -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 & open source" icon="heart">
|
||||
Completely open source and free. You can host it on your own server.
|
||||
</FeaturesItem>
|
||||
</Flex>
|
||||
</ColCenterH>
|
||||
);
|
||||
|
||||
export default Features;
|
|
@ -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 & open source" icon="heart">
|
||||
Completely open source and free. You can host it on your own server.
|
||||
</FeaturesItem>
|
||||
</Wrapper>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Features;
|
|
@ -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;
|
|
@ -1 +0,0 @@
|
|||
export { default } from './Features';
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
|
@ -1 +0,0 @@
|
|||
export { default } from './Footer';
|
|
@ -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;
|
|
@ -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);
|
|
@ -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);
|
|
@ -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;
|
|
@ -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);
|
|
@ -1 +0,0 @@
|
|||
export { default } from './Header';
|
|
@ -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;
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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;
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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;
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./Icon";
|
|
@ -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}
|
||||
/>
|
||||
);
|
|
@ -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;
|
|
@ -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);
|
|
@ -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;
|
|
@ -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;
|
|
@ -1 +0,0 @@
|
|||
export { default } from './Login';
|
|
@ -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;
|
|
@ -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;
|
|
@ -1 +0,0 @@
|
|||
export { default } from './Modal';
|
|
@ -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>
|
||||
);
|
|
@ -1 +0,0 @@
|
|||
export { default } from './NeedToLogin';
|
|
@ -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;
|
|
@ -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;
|
|
@ -1 +0,0 @@
|
|||
export { default } from './PageLoading';
|
|
@ -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}
|
|
@ -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);
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -1 +0,0 @@
|
|||
export { default } from './Settings';
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./Settings";
|
|
@ -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;
|
|
@ -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);
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -1 +0,0 @@
|
|||
export { default } from './Shortener';
|
|
@ -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);
|
|
@ -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);
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue