feat: refactor client and improve design (#260)

* refactor: (wip)

* refactor: finish settings, add icons and stuff

* 🐬

* 🐬

* 2.2.0
This commit is contained in:
Pouria Ezzati 2020-01-02 20:09:59 +03:30 committed by GitHub
parent 362aa1058e
commit 4680a0dbec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
195 changed files with 7936 additions and 8314 deletions

View File

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

View File

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

View File

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

8
.prettierrc Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,40 @@
import React, { FC } from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer
} from "recharts";
interface Props {
data: any[]; // TODO: types
}
const ChartBar: FC<Props> = ({ data }) => (
<ResponsiveContainer
width="100%"
height={window.innerWidth < 468 ? 240 : 320}
>
<BarChart
data={data}
layout="vertical"
margin={{
top: 0,
right: 0,
left: 24,
bottom: 0
}}
>
<XAxis type="number" dataKey="value" />
<YAxis type="category" dataKey="name" />
<CartesianGrid strokeDasharray="1 1" />
<Tooltip />
<Bar dataKey="value" fill="#B39DDB" />
</BarChart>
</ResponsiveContainer>
);
export default ChartBar;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,71 +0,0 @@
import React from 'react';
import styled from 'styled-components';
import FeaturesItem from './FeaturesItem';
const Section = styled.div`
position: relative;
width: 100%;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
margin: 0;
padding: 102px 0 110px;
background-color: #eaeaea;
@media only screen and (max-width: 768px) {
margin: 0;
padding: 64px 0 16px;
flex-wrap: wrap;
}
`;
const Wrapper = styled.div`
width: 1200px;
max-width: 100%;
flex: 1 1 auto;
display: flex;
justify-content: center;
@media only screen and (max-width: 1200px) {
flex-wrap: wrap;
}
`;
const Title = styled.h3`
font-size: 28px;
font-weight: 300;
margin: 0 0 72px;
@media only screen and (max-width: 768px) {
font-size: 24px;
margin-bottom: 56px;
}
@media only screen and (max-width: 448px) {
font-size: 20px;
margin-bottom: 40px;
}
`;
const Features = () => (
<Section>
<Title>Kutting edge features.</Title>
<Wrapper>
<FeaturesItem title="Managing links" icon="edit">
Create, protect and delete your links and monitor them with detailed statistics.
</FeaturesItem>
<FeaturesItem title="Custom domain" icon="navigation">
Use custom domains for your links. Add or remove them for free.
</FeaturesItem>
<FeaturesItem title="API" icon="zap">
Use the provided API to create, delete and get URLs from anywhere.
</FeaturesItem>
<FeaturesItem title="Free &amp; open source" icon="heart">
Completely open source and free. You can host it on your own server.
</FeaturesItem>
</Wrapper>
</Section>
);
export default Features;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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}
/>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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