👾
This commit is contained in:
commit
6af694826d
4
.babelrc
Normal file
4
.babelrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"presets": ["next/babel", "env"],
|
||||
"plugins": [["styled-components", { "ssr": true, "displayName": true, "preprocess": false }]]
|
||||
}
|
3
.eslintignore
Normal file
3
.eslintignore
Normal file
@ -0,0 +1,3 @@
|
||||
.next/
|
||||
flow-typed/
|
||||
node_modules/
|
34
.eslintrc
Normal file
34
.eslintrc
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"extends": [
|
||||
"airbnb",
|
||||
"prettier",
|
||||
"prettier/react"
|
||||
],
|
||||
"parser": "babel-eslint",
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"rules": {
|
||||
"react/jsx-filename-extension": [
|
||||
1,
|
||||
{
|
||||
"extensions": [
|
||||
".js",
|
||||
".jsx"
|
||||
]
|
||||
}
|
||||
],
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
"prettier"
|
||||
]
|
||||
}
|
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
.vscode/
|
||||
client/.next/
|
||||
node_modules/
|
||||
client/config.js
|
||||
server/config.js
|
12
.travis.yml
Normal file
12
.travis.yml
Normal file
@ -0,0 +1,12 @@
|
||||
language: node_js
|
||||
|
||||
node_js:
|
||||
- "8"
|
||||
|
||||
before_install:
|
||||
- cp ./server/config.example.js ./server/config.js
|
||||
- cp ./client/config.example.js ./client/config.js
|
||||
|
||||
script:
|
||||
- npm run lint:nofix
|
||||
- npm run build
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 The Devs
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
84
README.md
Normal file
84
README.md
Normal file
@ -0,0 +1,84 @@
|
||||
<a href="https://kutt.it" title="kutt.it"><img src="https://camo.githubusercontent.com/073e709d02d3cf6ee5439ee6ce0bb0895f9f3733/687474703a2f2f6f6936372e74696e797069632e636f6d2f3636797a346f2e6a7067" alt="Kutt.it"></a>
|
||||
|
||||
# Kutt.it
|
||||
|
||||
**Kutt** is a modern URL shortener which lets you set custom domains for your shortened URLs, manage your links and view the click rate statistics.
|
||||
|
||||
*Contributions and bug reports are welcome.*
|
||||
|
||||
[https://kutt.it](https://kutt.it)
|
||||
|
||||
[](https://travis-ci.org/thedevs-network/kutt)
|
||||
[](https://github.com/thedevs-network/kutt/#contributing)
|
||||
[](https://github.com/thedevs-network/kutt/blob/develop/LICENSE)
|
||||
[](https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgithub.com%2Fthedevs-network%2Fkutt%2F)
|
||||
|
||||
## Table of Contents
|
||||
* [Key Features](#key-features)
|
||||
* [Stack](#stack)
|
||||
* [Setup](#setup)
|
||||
* [API](#api)
|
||||
* [Contributing](#contributing)
|
||||
|
||||
## Key Features
|
||||
* Free and open source.
|
||||
* Setting custom domain.
|
||||
* Using custom URLs for shortened links
|
||||
* Setting password for links.
|
||||
* Private statistics for shortened URLs.
|
||||
* View and manage your links.
|
||||
* Provided API.
|
||||
|
||||
## Stack
|
||||
* Node (Web server)
|
||||
* Express (Web server framework)
|
||||
* Passport (Authentication)
|
||||
* React (UI library)
|
||||
* Next (Universal/server-side rendered React)
|
||||
* Redux (State management)
|
||||
* styled-components (CSS styling solution library)
|
||||
* Recharts (Chart library)
|
||||
* Neo4j (Graph database)
|
||||
|
||||
## Setup
|
||||
You need to have [Node.js](https://nodejs.org/) and [Neo4j](https://neo4j.com/) installed on your system.
|
||||
|
||||
1. Clone this repository on [downlaod zip](https://github.com/thedevs-network/kutt/archive/master.zip).
|
||||
2. Copy `config.example.js` to `config.js` in both server and client folders and fill them properly.
|
||||
3. Install dependencies: `npm install`.
|
||||
4. Start Neo4j database.
|
||||
5. Run for development: `npm run dev`.
|
||||
6. Run for production: `npm run build` then `npm start`.
|
||||
|
||||
## API
|
||||
In additional to website, you can use these APIs to create, delete and get URLs.
|
||||
|
||||
In order to use these APIs you need to generate an API key from settings. Don not ever put this key in the client side of your app or anywhere that is exposed to others.
|
||||
|
||||
Include API key as `apikey` in the body of all below requests. Available API URLs with body parameters:
|
||||
|
||||
**Get shortened URLs list:**
|
||||
```
|
||||
POST /api/url/geturls
|
||||
```
|
||||
|
||||
**Submit a links to be shortened**:
|
||||
```
|
||||
POST /api/url/submit
|
||||
```
|
||||
Body:
|
||||
* `target`: Original long URL to be shortened.
|
||||
|
||||
**Delete a shortened URL** and **Get stats for a shortened URL:**
|
||||
```
|
||||
POST /api/url/deleteurl
|
||||
POST /api/url/stats
|
||||
```
|
||||
Body
|
||||
* `id`: ID of the shortened URL.
|
||||
* `domain` (optional): Required if a custom domain is used for short URL.
|
||||
|
||||
## Contributing
|
||||
Pull requests are welcome. You'll probably find lots of improvements to be made.
|
||||
|
||||
Open issues for feadback, needed features, reporting bugs or discussing ideas.
|
30
client/actions/actionTypes.js
Normal file
30
client/actions/actionTypes.js
Normal file
@ -0,0 +1,30 @@
|
||||
/* Homepage input actions */
|
||||
export const ADD_URL = 'ADD_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';
|
137
client/actions/index.js
Normal file
137
client/actions/index.js
Normal file
@ -0,0 +1,137 @@
|
||||
import Router from 'next/router';
|
||||
import axios from 'axios';
|
||||
import cookie from 'js-cookie';
|
||||
import decodeJwt from 'jwt-decode';
|
||||
import * as types from './actionTypes';
|
||||
|
||||
/* Homepage input actions */
|
||||
const addUrl = payload => ({ type: types.ADD_URL, payload });
|
||||
const listUrls = payload => ({ type: types.LIST_URLS, payload });
|
||||
const updateUrlList = payload => ({ type: types.UPDATE_URL_LIST, payload });
|
||||
const deleteUrl = payload => ({ type: types.DELETE_URL, payload });
|
||||
const showShortenerLoading = () => ({ type: types.SHORTENER_LOADING });
|
||||
const showTableLoading = () => ({ type: types.TABLE_LOADING });
|
||||
export const setShortenerFormError = payload => ({ type: types.SHORTENER_ERROR, payload });
|
||||
|
||||
export const createShortUrl = params => dispatch => {
|
||||
dispatch(showShortenerLoading());
|
||||
return axios
|
||||
.post('/api/url/submit', params, { headers: { Authorization: cookie.get('token') } })
|
||||
.then(({ data }) => dispatch(addUrl(data)))
|
||||
.catch(({ response }) => dispatch(setShortenerFormError(response.data.error)));
|
||||
};
|
||||
|
||||
export const getUrlsList = params => (dispatch, getState) => {
|
||||
if (params) dispatch(updateUrlList(params));
|
||||
dispatch(showTableLoading());
|
||||
return axios
|
||||
.post('/api/url/geturls', getState().url, { headers: { Authorization: cookie.get('token') } })
|
||||
.then(({ data }) => dispatch(listUrls(data)));
|
||||
};
|
||||
|
||||
export const deleteShortUrl = params => dispatch => {
|
||||
dispatch(showTableLoading());
|
||||
return axios
|
||||
.post('/api/url/deleteurl', params, { headers: { Authorization: cookie.get('token') } })
|
||||
.then(() => dispatch(deleteUrl(params.id)))
|
||||
.catch(({ response }) => dispatch(setShortenerFormError(response.data.error)));
|
||||
};
|
||||
/* Page loading actions */
|
||||
export const showPageLoading = () => ({ type: types.SHOW_PAGE_LOADING });
|
||||
export const hidePageLoading = () => ({ type: types.HIDE_PAGE_LOADING });
|
||||
|
||||
/* Settings actions */
|
||||
export const setDomain = payload => ({ type: types.SET_DOMAIN, payload });
|
||||
export const setApiKey = payload => ({ type: types.SET_APIKEY, payload });
|
||||
const deleteDomain = () => ({ type: types.DELETE_DOMAIN });
|
||||
const setDomainError = payload => ({ type: types.DOMAIN_ERROR, payload });
|
||||
const showDomainLoading = () => ({ type: types.DOMAIN_LOADING });
|
||||
const showApiLoading = () => ({ type: types.API_LOADING });
|
||||
export const showDomainInput = () => ({ type: types.SHOW_DOMAIN_INPUT });
|
||||
|
||||
export const getUserSettings = () => dispatch =>
|
||||
axios
|
||||
.post('/api/auth/usersettings', null, { headers: { Authorization: cookie.get('token') } })
|
||||
.then(({ data }) => {
|
||||
dispatch(setDomain(data.customDomain));
|
||||
dispatch(setApiKey(data.apikey));
|
||||
});
|
||||
|
||||
export const setCustomDomain = params => dispatch => {
|
||||
dispatch(showDomainLoading());
|
||||
return axios
|
||||
.post('/api/url/customdomain', params, { headers: { Authorization: cookie.get('token') } })
|
||||
.then(({ data }) => dispatch(setDomain(data.customDomain)))
|
||||
.catch(({ response }) => dispatch(setDomainError(response.data.error)));
|
||||
};
|
||||
|
||||
export const deleteCustomDomain = () => dispatch =>
|
||||
axios
|
||||
.delete('/api/url/customdomain', { headers: { Authorization: cookie.get('token') } })
|
||||
.then(() => dispatch(deleteDomain()))
|
||||
.catch(({ response }) => dispatch(setDomainError(response.data.error)));
|
||||
|
||||
export const generateApiKey = () => dispatch => {
|
||||
dispatch(showApiLoading());
|
||||
return axios
|
||||
.post('/api/auth/generateapikey', null, { headers: { Authorization: cookie.get('token') } })
|
||||
.then(({ data }) => dispatch(setApiKey(data.apikey)));
|
||||
};
|
||||
|
||||
/* Login & signup actions */
|
||||
export const authUser = payload => ({ type: types.AUTH_USER, payload: decodeJwt(payload).sub });
|
||||
export const unauthUser = () => ({ type: types.UNAUTH_USER });
|
||||
export const sentVerification = payload => ({ type: types.SENT_VERIFICATION, payload });
|
||||
export const showAuthError = payload => ({ type: types.AUTH_ERROR, payload });
|
||||
export const showLoginLoading = () => ({ type: types.LOGIN_LOADING });
|
||||
export const showSignupLoading = () => ({ type: types.SIGNUP_LOADING });
|
||||
export const authRenew = () => ({ type: types.AUTH_RENEW });
|
||||
|
||||
export const signupUser = body => dispatch => {
|
||||
dispatch(showSignupLoading());
|
||||
return axios
|
||||
.post('/api/auth/signup', body)
|
||||
.then(res => {
|
||||
const { email } = res.data;
|
||||
dispatch(sentVerification(email));
|
||||
})
|
||||
.catch(err => dispatch(showAuthError(err.response.data.error)));
|
||||
};
|
||||
|
||||
export const loginUser = body => dispatch => {
|
||||
dispatch(showLoginLoading());
|
||||
return axios
|
||||
.post('/api/auth/login', body)
|
||||
.then(res => {
|
||||
const { token } = res.data;
|
||||
cookie.set('token', token, { expires: 7 });
|
||||
dispatch(authRenew());
|
||||
dispatch(authUser(token));
|
||||
dispatch(showPageLoading());
|
||||
Router.push('/');
|
||||
})
|
||||
.catch(err => dispatch(showAuthError(err.response.data.error)));
|
||||
};
|
||||
|
||||
export const logoutUser = () => dispatch => {
|
||||
dispatch(showPageLoading());
|
||||
cookie.remove('token');
|
||||
dispatch(unauthUser());
|
||||
return Router.push('/login');
|
||||
};
|
||||
|
||||
export const renewAuthUser = () => (dispatch, getState) => {
|
||||
if (getState().auth.renew) return null;
|
||||
return axios
|
||||
.post('/api/auth/renew', null, { headers: { Authorization: cookie.get('token') } })
|
||||
.then(res => {
|
||||
const { token } = res.data;
|
||||
cookie.set('token', token, { expires: 7 });
|
||||
dispatch(authRenew());
|
||||
dispatch(authUser(token));
|
||||
})
|
||||
.catch(() => {
|
||||
cookie.remove('token');
|
||||
dispatch(unauthUser());
|
||||
});
|
||||
};
|
95
client/components/BodyWrapper/BodyWrapper.js
Normal file
95
client/components/BodyWrapper/BodyWrapper.js
Normal file
@ -0,0 +1,95 @@
|
||||
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';
|
||||
import { GOOGLE_ANALYTICS_ID } from '../../config';
|
||||
|
||||
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 (GOOGLE_ANALYTICS_ID) {
|
||||
if (!window.GA_INITIALIZED) {
|
||||
initGA();
|
||||
window.GA_INITIALIZED = true;
|
||||
}
|
||||
logPageView();
|
||||
}
|
||||
|
||||
const token = cookie.get('token');
|
||||
this.props.hidePageLoading();
|
||||
if (!token || this.props.norenew) return null;
|
||||
return this.props.renewAuthUser(token);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, pageLoading } = this.props;
|
||||
|
||||
const content = pageLoading ? <PageLoading /> : children;
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<ContentWrapper>
|
||||
<Header />
|
||||
{content}
|
||||
</ContentWrapper>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BodyWrapper.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
hidePageLoading: PropTypes.func.isRequired,
|
||||
norenew: PropTypes.bool,
|
||||
pageLoading: PropTypes.bool.isRequired,
|
||||
renewAuthUser: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
BodyWrapper.defaultProps = {
|
||||
norenew: false,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({ loading: { page: pageLoading } }) => ({ pageLoading });
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
hidePageLoading: bindActionCreators(hidePageLoading, dispatch),
|
||||
renewAuthUser: bindActionCreators(renewAuthUser, dispatch),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BodyWrapper);
|
1
client/components/BodyWrapper/index.js
Normal file
1
client/components/BodyWrapper/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './BodyWrapper';
|
155
client/components/Button/Button.js
Normal file
155
client/components/Button/Button.js
Normal file
@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled, { css } from 'styled-components';
|
||||
import SVG from 'react-inlinesvg';
|
||||
import { spin } from '../../helpers/animations';
|
||||
|
||||
const StyledButton = styled.button`
|
||||
position: relative;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0px 32px;
|
||||
font-size: 13px;
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
word-break: keep-all;
|
||||
color: white;
|
||||
background: linear-gradient(to right, #42a5f5, #2979ff);
|
||||
box-shadow: 0 5px 6px rgba(66, 165, 245, 0.5);
|
||||
border: none;
|
||||
border-radius: 100px;
|
||||
transition: all 0.4s ease-out;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
:hover,
|
||||
:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 6px 15px rgba(66, 165, 245, 0.5);
|
||||
transform: translateY(-2px) scale(1.02, 1.02);
|
||||
}
|
||||
|
||||
a & {
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 448px) {
|
||||
height: 32px;
|
||||
padding: 0 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
${({ color }) => {
|
||||
if (color === 'purple') {
|
||||
return css`
|
||||
background: linear-gradient(to right, #7e57c2, #6200ea);
|
||||
box-shadow: 0 5px 6px rgba(81, 45, 168, 0.5);
|
||||
|
||||
:focus,
|
||||
:hover {
|
||||
box-shadow: 0 6px 15px rgba(81, 45, 168, 0.5);
|
||||
}
|
||||
`;
|
||||
}
|
||||
if (color === 'gray') {
|
||||
return css`
|
||||
color: black;
|
||||
background: linear-gradient(to right, #e0e0e0, #bdbdbd);
|
||||
box-shadow: 0 5px 6px rgba(160, 160, 160, 0.5);
|
||||
|
||||
:focus,
|
||||
:hover {
|
||||
box-shadow: 0 6px 15px rgba(160, 160, 160, 0.5);
|
||||
}
|
||||
`;
|
||||
}
|
||||
return null;
|
||||
}};
|
||||
|
||||
${({ big }) =>
|
||||
big &&
|
||||
css`
|
||||
height: 56px;
|
||||
@media only screen and (max-width: 448px) {
|
||||
height: 40px;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const Icon = styled(SVG)`
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 12px;
|
||||
stroke: #fff;
|
||||
|
||||
${({ type }) =>
|
||||
type === 'loader' &&
|
||||
css`
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
animation: ${spin} 1s linear infinite;
|
||||
`};
|
||||
|
||||
${({ round }) =>
|
||||
round &&
|
||||
css`
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin: 0;
|
||||
`};
|
||||
|
||||
${({ color }) =>
|
||||
color === 'gray' &&
|
||||
css`
|
||||
stroke: #444;
|
||||
`};
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Button = props => {
|
||||
const SVGIcon = props.icon ? (
|
||||
<Icon
|
||||
type={props.icon}
|
||||
round={props.round}
|
||||
color={props.color}
|
||||
src={`/images/${props.icon}.svg`}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
);
|
||||
return (
|
||||
<StyledButton {...props}>
|
||||
{SVGIcon}
|
||||
{props.icon !== 'loader' && props.children}
|
||||
</StyledButton>
|
||||
);
|
||||
};
|
||||
|
||||
Button.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
color: PropTypes.string,
|
||||
icon: PropTypes.string,
|
||||
round: PropTypes.bool,
|
||||
type: PropTypes.string,
|
||||
};
|
||||
|
||||
Button.defaultProps = {
|
||||
color: 'blue',
|
||||
icon: '',
|
||||
type: '',
|
||||
round: false,
|
||||
};
|
||||
|
||||
export default Button;
|
1
client/components/Button/index.js
Normal file
1
client/components/Button/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Button';
|
92
client/components/Checkbox/Checkbox.js
Normal file
92
client/components/Checkbox/Checkbox.js
Normal file
@ -0,0 +1,92 @@
|
||||
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: 24px 32px 24px 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, onClick }) => (
|
||||
<Wrapper>
|
||||
<Box checked={checked} id={id} onClick={onClick}>
|
||||
{label}
|
||||
</Box>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
Checkbox.propTypes = {
|
||||
checked: PropTypes.bool,
|
||||
label: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
Checkbox.defaultProps = {
|
||||
checked: false,
|
||||
onClick: f => f,
|
||||
};
|
||||
|
||||
export default Checkbox;
|
1
client/components/Checkbox/index.js
Normal file
1
client/components/Checkbox/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Checkbox';
|
63
client/components/Error/Error.js
Normal file
63
client/components/Error/Error.js
Normal file
@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { fadeIn } from '../../helpers/animations';
|
||||
|
||||
const ErrorMessage = styled.p`
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 36px;
|
||||
bottom: -64px;
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: red;
|
||||
animation: ${fadeIn} 0.3s ease-out;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
right: 8px;
|
||||
bottom: -40px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
${({ left }) =>
|
||||
left > -1 &&
|
||||
css`
|
||||
right: auto;
|
||||
left: ${left}px;
|
||||
`};
|
||||
|
||||
${({ bottom }) =>
|
||||
bottom &&
|
||||
css`
|
||||
bottom: ${bottom}px;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Error = ({ bottom, error, left, type }) => {
|
||||
const message = error[type] && (
|
||||
<ErrorMessage left={left} bottom={bottom}>
|
||||
{error[type]}
|
||||
</ErrorMessage>
|
||||
);
|
||||
return <div>{message}</div>;
|
||||
};
|
||||
|
||||
Error.propTypes = {
|
||||
bottom: PropTypes.number,
|
||||
error: PropTypes.shape({
|
||||
auth: PropTypes.string.isRequired,
|
||||
shortener: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
left: PropTypes.number,
|
||||
};
|
||||
|
||||
Error.defaultProps = {
|
||||
bottom: -64,
|
||||
left: -1,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({ error }) => ({ error });
|
||||
|
||||
export default connect(mapStateToProps)(Error);
|
1
client/components/Error/index.js
Normal file
1
client/components/Error/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Error';
|
71
client/components/Features/Features.js
Normal file
71
client/components/Features/Features.js
Normal file
@ -0,0 +1,71 @@
|
||||
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;
|
||||
background-color: #eaeaea;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
margin: 0;
|
||||
padding: 64px 0 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
width: 1200px;
|
||||
max-width: 100%;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@media only screen and (max-width: 1200px) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
`;
|
||||
|
||||
const Title = styled.h3`
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
margin: 0 0 72px;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
font-size: 24px;
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 448px) {
|
||||
font-size: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Features = () => (
|
||||
<Section>
|
||||
<Title>Kutting edge features.</Title>
|
||||
<Wrapper>
|
||||
<FeaturesItem title="Managing links" icon="edit">
|
||||
Create, protect and delete your links and monitor them with detailed statistics.
|
||||
</FeaturesItem>
|
||||
<FeaturesItem title="Custom domain" icon="navigation">
|
||||
Use custom domains for your links. Add or remove them for free.
|
||||
</FeaturesItem>
|
||||
<FeaturesItem title="API" icon="zap">
|
||||
Use the provided API to create, delete and get URLs from anywhere.
|
||||
</FeaturesItem>
|
||||
<FeaturesItem title="Free & open source" icon="heart">
|
||||
Completely open source and free. You can host it on your own server.
|
||||
</FeaturesItem>
|
||||
</Wrapper>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Features;
|
97
client/components/Features/FeaturesItem.js
Normal file
97
client/components/Features/FeaturesItem.js
Normal file
@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from 'styled-components';
|
||||
import { fadeIn } from '../../helpers/animations';
|
||||
|
||||
const Block = styled.div`
|
||||
max-width: 25%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
animation: ${fadeIn} 0.8s ease-out;
|
||||
|
||||
:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1200px) {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 980px) {
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 760px) {
|
||||
max-width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const IconBox = styled.div`
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: #2196f3;
|
||||
|
||||
@media only screen and (max-width: 448px) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Icon = styled.img`
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
@media only screen and (max-width: 448px) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Title = styled.h3`
|
||||
margin: 16px;
|
||||
font-size: 15px;
|
||||
|
||||
@media only screen and (max-width: 448px) {
|
||||
margin: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Description = styled.p`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
text-align: center;
|
||||
|
||||
@media only screen and (max-width: 448px) {
|
||||
font-size: 13px;
|
||||
}
|
||||
`;
|
||||
|
||||
const FeaturesItem = ({ children, icon, title }) => (
|
||||
<Block>
|
||||
<IconBox>
|
||||
<Icon src={`/images/${icon}.svg`} />
|
||||
</IconBox>
|
||||
<Title>{title}</Title>
|
||||
<Description>{children}</Description>
|
||||
</Block>
|
||||
);
|
||||
|
||||
FeaturesItem.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
icon: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default FeaturesItem;
|
1
client/components/Features/index.js
Normal file
1
client/components/Features/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Features';
|
49
client/components/Footer/Footer.js
Normal file
49
client/components/Footer/Footer.js
Normal file
@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.footer`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4px 0;
|
||||
background-color: white;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #2196f3;
|
||||
}
|
||||
`;
|
||||
|
||||
const Text = styled.p`
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: #666;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
font-size: 11px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Footer = () => (
|
||||
<Wrapper>
|
||||
<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" // eslint-disable-line react/jsx-no-target-blank
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
{' | '}
|
||||
<a href="/terms" title="Terms of Service" target="_blank">
|
||||
Terms of Service
|
||||
</a>.
|
||||
</Text>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
export default Footer;
|
1
client/components/Footer/index.js
Normal file
1
client/components/Footer/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Footer';
|
54
client/components/Header/Header.js
Normal file
54
client/components/Header/Header.js
Normal file
@ -0,0 +1,54 @@
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
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);
|
32
client/components/Header/HeaderLeftMenu.js
Normal file
32
client/components/Header/HeaderLeftMenu.js
Normal file
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import HeaderMenuItem from './HeaderMenuItem';
|
||||
|
||||
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 = () => (
|
||||
<List>
|
||||
<HeaderMenuItem>
|
||||
<a
|
||||
href="//github.com/thedevs-network/kutt"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="GitHub"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</HeaderMenuItem>
|
||||
</List>
|
||||
);
|
||||
|
||||
export default HeaderLeftMenu;
|
54
client/components/Header/HeaderLogo.js
Normal file
54
client/components/Header/HeaderLogo.js
Normal file
@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Router from 'next/router';
|
||||
import styled from 'styled-components';
|
||||
|
||||
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 HeaderLogo = props => {
|
||||
const goTo = e => {
|
||||
e.preventDefault();
|
||||
const path = e.target.getAttribute('href');
|
||||
if (window.location.pathname === path) return;
|
||||
props.showPageLoading();
|
||||
Router.push(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<LogoImage>
|
||||
<a href="/" title="Homepage" onClick={goTo}>
|
||||
<img src="/images/logo.svg" alt="Kutt.it" />
|
||||
Kutt.it
|
||||
</a>
|
||||
</LogoImage>
|
||||
);
|
||||
};
|
||||
|
||||
HeaderLogo.propTypes = {
|
||||
showPageLoading: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default HeaderLogo;
|
38
client/components/Header/HeaderMenuItem.js
Normal file
38
client/components/Header/HeaderMenuItem.js
Normal file
@ -0,0 +1,38 @@
|
||||
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;
|
74
client/components/Header/HeaderRightMenu.js
Normal file
74
client/components/Header/HeaderRightMenu.js
Normal file
@ -0,0 +1,74 @@
|
||||
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 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}>
|
||||
Logout
|
||||
</a>
|
||||
</HeaderMenuItem>
|
||||
);
|
||||
const settings = props.auth.isAuthenticated && (
|
||||
<HeaderMenuItem>
|
||||
<a href="/settings" title="settings" onClick={goTo}>
|
||||
<Button>Settings</Button>
|
||||
</a>
|
||||
</HeaderMenuItem>
|
||||
);
|
||||
return (
|
||||
<List>
|
||||
{logout}
|
||||
{settings}
|
||||
{login}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
HeaderMenu.propTypes = {
|
||||
auth: PropTypes.shape({
|
||||
isAuthenticated: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
showPageLoading: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({ auth }) => ({ auth });
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
logoutUser: bindActionCreators(logoutUser, dispatch),
|
||||
showPageLoading: bindActionCreators(showPageLoading, dispatch),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(HeaderMenu);
|
1
client/components/Header/index.js
Normal file
1
client/components/Header/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Header';
|
195
client/components/Login/Login.js
Normal file
195
client/components/Login/Login.js
Normal file
@ -0,0 +1,195 @@
|
||||
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';
|
||||
import showRecaptcha from '../../helpers/recaptcha';
|
||||
import config from '../../config';
|
||||
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
const Recaptcha = styled.div`
|
||||
display: block;
|
||||
margin: 0 0 32px 0;
|
||||
`;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
showRecaptcha();
|
||||
}
|
||||
|
||||
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;
|
||||
const { value: reCaptchaToken } = form.elements['g-recaptcha-input'];
|
||||
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.');
|
||||
}
|
||||
if (!reCaptchaToken) {
|
||||
window.grecaptcha.reset();
|
||||
return showError('reCAPTCHA is not valid. Try again.');
|
||||
}
|
||||
window.grecaptcha.reset();
|
||||
return type === 'login'
|
||||
? this.props.login({ email, password, reCaptchaToken })
|
||||
: this.props.signup({ email, password, reCaptchaToken });
|
||||
}
|
||||
|
||||
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" />
|
||||
<Recaptcha
|
||||
id="g-recaptcha"
|
||||
className="g-recaptcha"
|
||||
data-sitekey={config.RECAPTCHA_SITE_KEY}
|
||||
data-callback="recaptchaCallback"
|
||||
/>
|
||||
<input type="hidden" id="g-recaptcha-input" name="g-recaptcha-input" />
|
||||
<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
|
||||
>
|
||||
Login
|
||||
</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);
|
31
client/components/Login/LoginBox.js
Normal file
31
client/components/Login/LoginBox.js
Normal file
@ -0,0 +1,31 @@
|
||||
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;
|
21
client/components/Login/LoginInputLabel.js
Normal file
21
client/components/Login/LoginInputLabel.js
Normal file
@ -0,0 +1,21 @@
|
||||
/* eslint-disable jsx-a11y/label-has-for */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Label = styled.div`
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const LoginInputLabel = ({ children, htmlFor }) => (
|
||||
<Label>
|
||||
<label htmlFor={htmlFor}>{children}</label>
|
||||
</Label>
|
||||
);
|
||||
|
||||
LoginInputLabel.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
htmlFor: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default LoginInputLabel;
|
1
client/components/Login/index.js
Normal file
1
client/components/Login/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Login';
|
66
client/components/Modal/Modal.js
Normal file
66
client/components/Modal/Modal.js
Normal file
@ -0,0 +1,66 @@
|
||||
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}>
|
||||
No
|
||||
</Button>
|
||||
<Button onClick={handler}>Yes</Button>
|
||||
</ButtonsWrapper>
|
||||
</Content>
|
||||
</Wrapper>
|
||||
) : null;
|
||||
|
||||
Modal.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
close: PropTypes.func.isRequired,
|
||||
handler: PropTypes.func.isRequired,
|
||||
show: PropTypes.bool,
|
||||
};
|
||||
|
||||
Modal.defaultProps = {
|
||||
show: false,
|
||||
};
|
||||
|
||||
export default Modal;
|
1
client/components/Modal/index.js
Normal file
1
client/components/Modal/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Modal';
|
89
client/components/NeedToLogin/NeedToLogin.js
Normal file
89
client/components/NeedToLogin/NeedToLogin.js
Normal file
@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import styled from 'styled-components';
|
||||
import Button from '../Button';
|
||||
import { fadeIn } from '../../helpers/animations';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
width: 1200px;
|
||||
max-width: 98%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 16px 0 0;
|
||||
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`
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
padding-right: 32px;
|
||||
margin-bottom: 48px;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
font-size: 22px;
|
||||
text-align: center;
|
||||
padding-right: 0;
|
||||
margin-bottom: 32px;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 448px) {
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Image = styled.img`
|
||||
flex: 0 0 60%;
|
||||
width: 60%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
flex-basis: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const NeedToLogin = () => (
|
||||
<Wrapper>
|
||||
<TitleWrapper>
|
||||
<Title>
|
||||
Manage links, set custom <b>domains</b> and view <b>stats</b>.
|
||||
</Title>
|
||||
<Link href="/login" prefetch>
|
||||
<a href="/login" title="login / signup">
|
||||
<Button>Login / sign up</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</TitleWrapper>
|
||||
<Image src="/images/callout.png" />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
export default NeedToLogin;
|
1
client/components/NeedToLogin/index.js
Normal file
1
client/components/NeedToLogin/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './NeedToLogin';
|
28
client/components/PageLoading/PageLoading.js
Normal file
28
client/components/PageLoading/PageLoading.js
Normal file
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { spin } from '../../helpers/animations';
|
||||
|
||||
const Loading = styled.div`
|
||||
margin: 0 0 48px;
|
||||
flex: 1 1 auto;
|
||||
flex-basis: 250px;
|
||||
display: flex;
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const Icon = styled.img`
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
animation: ${spin} 1s linear infinite;
|
||||
`;
|
||||
|
||||
const pageLoading = () => (
|
||||
<Loading>
|
||||
<Icon src="/images/loader.svg" />
|
||||
</Loading>
|
||||
);
|
||||
|
||||
export default pageLoading;
|
1
client/components/PageLoading/index.js
Normal file
1
client/components/PageLoading/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './PageLoading';
|
224
client/components/Settings/Settings.js
Normal file
224
client/components/Settings/Settings.js
Normal file
@ -0,0 +1,224 @@
|
||||
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 cookie from 'js-cookie';
|
||||
import axios from 'axios';
|
||||
import SettingsWelcome from './SettingsWelcome';
|
||||
import SettingsDomain from './SettingsDomain';
|
||||
import SettingsPassword from './SettingsPassword';
|
||||
import SettingsApi from './SettingsApi';
|
||||
import Modal from '../Modal';
|
||||
import { fadeIn } from '../../helpers/animations';
|
||||
import {
|
||||
deleteCustomDomain,
|
||||
generateApiKey,
|
||||
getUserSettings,
|
||||
setCustomDomain,
|
||||
showDomainInput,
|
||||
} from '../../actions';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
poistion: relative;
|
||||
width: 600px;
|
||||
max-width: 97%;
|
||||
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: '',
|
||||
};
|
||||
this.handleCustomDomain = this.handleCustomDomain.bind(this);
|
||||
this.deleteDomain = this.deleteDomain.bind(this);
|
||||
this.showModal = this.showModal.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();
|
||||
}
|
||||
|
||||
handleCustomDomain(e) {
|
||||
e.preventDefault();
|
||||
if (this.props.domainLoading) return null;
|
||||
const customDomain = e.currentTarget.elements.customdomain.value;
|
||||
return this.props.setCustomDomain({ customDomain });
|
||||
}
|
||||
|
||||
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() {
|
||||
return (
|
||||
<Wrapper>
|
||||
<SettingsWelcome user={this.props.auth.user} />
|
||||
<SettingsDomain
|
||||
handleCustomDomain={this.handleCustomDomain}
|
||||
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}
|
||||
/>
|
||||
<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({
|
||||
isAuthenticated: PropTypes.bool.isRequired,
|
||||
user: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
apiLoading: PropTypes.bool,
|
||||
deleteCustomDomain: PropTypes.func.isRequired,
|
||||
domainLoading: PropTypes.bool,
|
||||
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 => ({
|
||||
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);
|
82
client/components/Settings/SettingsApi.js
Normal file
82
client/components/Settings/SettingsApi.js
Normal file
@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled, { css } from 'styled-components';
|
||||
import Button from '../Button';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
`;
|
||||
|
||||
const ApiKeyWrapper = styled.div`
|
||||
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 ApiKey = styled.span`
|
||||
max-width: 100%;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px dotted #999;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
font-size: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Link = styled.a`
|
||||
margin: 16px 0;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
margin: 8px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const SettingsApi = ({ apikey, generateKey, loader }) => (
|
||||
<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}>
|
||||
{apikey && <ApiKey>{apikey}</ApiKey>}
|
||||
<Button color="purple" icon={loader ? 'loader' : 'zap'} onClick={generateKey}>
|
||||
{apikey ? 'Regenerate' : 'Generate'} key
|
||||
</Button>
|
||||
</ApiKeyWrapper>
|
||||
<Link href="http://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,
|
||||
generateKey: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SettingsApi;
|
103
client/components/Settings/SettingsDomain.js
Normal file
103
client/components/Settings/SettingsDomain.js
Normal file
@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from 'styled-components';
|
||||
import TextInput from '../TextInput';
|
||||
import Button from '../Button';
|
||||
import Error from '../Error';
|
||||
import { fadeIn } from '../../helpers/animations';
|
||||
|
||||
const Form = styled.form`
|
||||
position: relative;
|
||||
display: flex;
|
||||
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;
|
||||
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.span`
|
||||
margin-right: 16px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px dotted #999;
|
||||
`;
|
||||
|
||||
const SettingsDomain = ({ settings, handleCustomDomain, loading, showDomainInput, showModal }) => (
|
||||
<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>31.220.54.35</b> then add the domain via form below:
|
||||
</p>
|
||||
{settings.customDomain && !settings.domainInput ? (
|
||||
<DomainWrapper>
|
||||
<Domain>{settings.customDomain}</Domain>
|
||||
<Button icon="edit" onClick={showDomainInput}>
|
||||
Change
|
||||
</Button>
|
||||
<Button color="gray" icon="x" onClick={showModal}>
|
||||
Delete
|
||||
</Button>
|
||||
</DomainWrapper>
|
||||
) : (
|
||||
<Form onSubmit={handleCustomDomain}>
|
||||
<Error type="domain" left={0} bottom={-48} />
|
||||
<TextInput
|
||||
id="customdomain"
|
||||
name="customdomain"
|
||||
type="text"
|
||||
placeholder="example.com"
|
||||
defaultValue={settings.customDomain}
|
||||
height={44}
|
||||
small
|
||||
/>
|
||||
<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,
|
||||
};
|
||||
|
||||
export default SettingsDomain;
|
59
client/components/Settings/SettingsPassword.js
Normal file
59
client/components/Settings/SettingsPassword.js
Normal file
@ -0,0 +1,59 @@
|
||||
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;
|
29
client/components/Settings/SettingsWelcome.js
Normal file
29
client/components/Settings/SettingsWelcome.js
Normal file
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Title = styled.h2`
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
|
||||
span {
|
||||
padding-bottom: 2px;
|
||||
border-bottom: 2px dotted #999;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
font-size: 22px;
|
||||
}
|
||||
`;
|
||||
|
||||
const SettingsWelcome = ({ user }) => (
|
||||
<Title>
|
||||
Welcome, <span>{user}</span>.
|
||||
</Title>
|
||||
);
|
||||
|
||||
SettingsWelcome.propTypes = {
|
||||
user: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default SettingsWelcome;
|
1
client/components/Settings/index.js
Normal file
1
client/components/Settings/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Settings';
|
155
client/components/Shortener/Shortener.js
Normal file
155
client/components/Shortener/Shortener.js
Normal file
@ -0,0 +1,155 @@
|
||||
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 ShortenerCaptcha from './ShortenerCaptcha';
|
||||
import { createShortUrl, setShortenerFormError } from '../../actions';
|
||||
import showRecaptcha from '../../helpers/recaptcha';
|
||||
import { fadeIn } from '../../helpers/animations';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
width: 800px;
|
||||
max-width: 100%;
|
||||
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);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
showRecaptcha();
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
const { isAuthenticated, shortenerError, shortenerLoading, url: { isShortened } } = this.props;
|
||||
return (
|
||||
isAuthenticated !== nextProps.isAuthenticated ||
|
||||
shortenerError !== nextProps.shortenerError ||
|
||||
isShortened !== nextProps.url.isShortened ||
|
||||
shortenerLoading !== nextProps.shortenerLoading ||
|
||||
this.state.isCopied !== nextState.isCopied
|
||||
);
|
||||
}
|
||||
|
||||
handleSubmit(e) {
|
||||
const { isAuthenticated } = this.props;
|
||||
e.preventDefault();
|
||||
const shortenerForm = document.getElementById('shortenerform');
|
||||
const {
|
||||
target: originalUrl,
|
||||
customurl: customurlInput,
|
||||
password: pwd,
|
||||
'g-recaptcha-input': recaptcha,
|
||||
} = shortenerForm.elements;
|
||||
const target = originalUrl.value.trim();
|
||||
const customurl = customurlInput && customurlInput.value.trim();
|
||||
const password = pwd && pwd.value;
|
||||
const reCaptchaToken = !isAuthenticated && recaptcha && recaptcha.value;
|
||||
if (!isAuthenticated && !reCaptchaToken) {
|
||||
window.grecaptcha.reset();
|
||||
return this.props.setShortenerFormError('reCAPTCHA is not valid. Try again.');
|
||||
}
|
||||
const options = isAuthenticated && { customurl, password };
|
||||
shortenerForm.reset();
|
||||
if (!isAuthenticated && recaptcha) window.grecaptcha.reset();
|
||||
return this.props.createShortUrl({ target, reCaptchaToken, ...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}
|
||||
/>
|
||||
{!isAuthenticated && <ShortenerCaptcha />}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Shortener.propTypes = {
|
||||
isAuthenticated: PropTypes.bool.isRequired,
|
||||
createShortUrl: PropTypes.func.isRequired,
|
||||
shortenerError: PropTypes.string.isRequired,
|
||||
shortenerLoading: PropTypes.bool.isRequired,
|
||||
setShortenerFormError: PropTypes.func.isRequired,
|
||||
url: PropTypes.shape({
|
||||
isShortened: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({
|
||||
auth: { isAuthenticated },
|
||||
error: { shortener: shortenerError },
|
||||
loading: { shortener: shortenerLoading },
|
||||
url,
|
||||
}) => ({
|
||||
isAuthenticated,
|
||||
shortenerError,
|
||||
shortenerLoading,
|
||||
url,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
createShortUrl: bindActionCreators(createShortUrl, dispatch),
|
||||
setShortenerFormError: bindActionCreators(setShortenerFormError, dispatch),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Shortener);
|
19
client/components/Shortener/ShortenerCaptcha.js
Normal file
19
client/components/Shortener/ShortenerCaptcha.js
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import config from '../../config';
|
||||
|
||||
const Recaptcha = styled.div`
|
||||
display: block;
|
||||
margin: 32px 0;
|
||||
`;
|
||||
|
||||
const ShortenerCaptcha = () => (
|
||||
<Recaptcha
|
||||
id="g-recaptcha"
|
||||
className="g-recaptcha"
|
||||
data-sitekey={config.RECAPTCHA_SITE_KEY}
|
||||
data-callback="recaptchaCallback"
|
||||
/>
|
||||
);
|
||||
|
||||
export default ShortenerCaptcha;
|
76
client/components/Shortener/ShortenerInput.js
Normal file
76
client/components/Shortener/ShortenerInput.js
Normal file
@ -0,0 +1,76 @@
|
||||
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, 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>
|
||||
<input type="hidden" id="g-recaptcha-input" name="g-recaptcha-input" />
|
||||
<Error type="shortener" />
|
||||
<ShortenerOptions
|
||||
isAuthenticated={isAuthenticated}
|
||||
setShortenerFormError={setShortenerFormError}
|
||||
/>
|
||||
</ShortenerForm>
|
||||
);
|
||||
|
||||
ShortenerInput.propTypes = {
|
||||
handleSubmit: PropTypes.func.isRequired,
|
||||
isAuthenticated: PropTypes.bool.isRequired,
|
||||
setShortenerFormError: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ShortenerInput;
|
141
client/components/Shortener/ShortenerOptions.js
Normal file
141
client/components/Shortener/ShortenerOptions.js
Normal file
@ -0,0 +1,141 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled, { css } 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;
|
||||
|
||||
${({ isAuthenticated }) =>
|
||||
!isAuthenticated &&
|
||||
css`
|
||||
top: 180px;
|
||||
`};
|
||||
|
||||
@media only screen and (max-width: 448px) {
|
||||
top: 56px;
|
||||
}
|
||||
|
||||
${({ isAuthenticated }) =>
|
||||
!isAuthenticated &&
|
||||
css`
|
||||
@media only screen and (max-width: 448px) {
|
||||
top: 156px;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const CheckboxWrapper = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
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 ||
|
||||
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 } = this.props;
|
||||
const customUrlInput = customurlCheckbox && (
|
||||
<div>
|
||||
<Label htmlFor="customurl">{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,
|
||||
setShortenerFormError: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ShortenerOptions;
|
70
client/components/Shortener/ShortenerResult.js
Normal file
70
client/components/Shortener/ShortenerResult.js
Normal file
@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from 'styled-components';
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||
import Button from '../Button';
|
||||
import Loading from '../PageLoading';
|
||||
import { fadeIn } from '../../helpers/animations';
|
||||
|
||||
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 ShortenerResult = ({ copyHandler, isCopied, loading, url }) =>
|
||||
loading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<Wrapper>
|
||||
{isCopied && <CopyMessage>Copied to clipboard.</CopyMessage>}
|
||||
<CopyToClipboard text={url.list[0].shortUrl} onCopy={copyHandler}>
|
||||
<Url>{url.list[0].shortUrl.replace(/^https?:\/\//, '')}</Url>
|
||||
</CopyToClipboard>
|
||||
<CopyToClipboard text={url.list[0].shortUrl} onCopy={copyHandler}>
|
||||
<Button icon="copy">Copy</Button>
|
||||
</CopyToClipboard>
|
||||
</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;
|
25
client/components/Shortener/ShortenerTitle.js
Normal file
25
client/components/Shortener/ShortenerTitle.js
Normal file
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 32px;
|
||||
font-weight: 300;
|
||||
margin: 8px 0 0;
|
||||
color: #333;
|
||||
|
||||
@media only screen and (max-width: 448px) {
|
||||
font-size: 22px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Underline = styled.span`
|
||||
border-bottom: 2px dotted #999;
|
||||
`;
|
||||
|
||||
const ShortenerTitle = () => (
|
||||
<Title>
|
||||
Kutt your links <Underline>shorter</Underline>.
|
||||
</Title>
|
||||
);
|
||||
|
||||
export default ShortenerTitle;
|
1
client/components/Shortener/index.js
Normal file
1
client/components/Shortener/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Shortener';
|
168
client/components/Stats/Stats.js
Normal file
168
client/components/Stats/Stats.js
Normal file
@ -0,0 +1,168 @@
|
||||
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 { id } = this.props;
|
||||
if (!id) return null;
|
||||
return axios
|
||||
.post('/api/url/stats', { id }, { 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.shortUrl} title="Short URL">
|
||||
{stats.shortUrl.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]} period={period} />
|
||||
</Content>
|
||||
<ButtonWrapper>
|
||||
<Button icon="arrow-left" onClick={this.goToHomepage}>
|
||||
Back to homepage
|
||||
</Button>
|
||||
</ButtonWrapper>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Stats.propTypes = {
|
||||
isAuthenticated: PropTypes.bool.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);
|
76
client/components/Stats/StatsCharts/Area.js
Normal file
76
client/components/Stats/StatsCharts/Area.js
Normal file
@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import subHours from 'date-fns/sub_hours';
|
||||
import subDays from 'date-fns/sub_days';
|
||||
import subMonths from 'date-fns/sub_months';
|
||||
import formatDate from 'date-fns/format';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from 'recharts';
|
||||
import withTitle from './withTitle';
|
||||
|
||||
const ChartArea = ({ data: rawData, period }) => {
|
||||
const now = new Date();
|
||||
const getDate = index => {
|
||||
switch (period) {
|
||||
case 'allTime':
|
||||
return formatDate(subMonths(now, rawData.length - index), 'MMM YY');
|
||||
case 'lastDay':
|
||||
return formatDate(subHours(now, rawData.length - index), 'HH:00');
|
||||
case 'lastMonth':
|
||||
case 'lastWeek':
|
||||
default:
|
||||
return formatDate(subDays(now, rawData.length - index), 'MMM DD');
|
||||
}
|
||||
};
|
||||
const data = rawData.map((view, index) => ({
|
||||
name: getDate(index),
|
||||
views: view,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={window.innerWidth < 468 ? 240 : 320}>
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 16,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 16,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#B39DDB" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#B39DDB" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<CartesianGrid strokeDasharray="1 1" />
|
||||
<Tooltip />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="views"
|
||||
isAnimationActive={false}
|
||||
stroke="#B39DDB"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUv)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
ChartArea.propTypes = {
|
||||
data: PropTypes.arrayOf(PropTypes.number.isRequired).isRequired,
|
||||
period: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default withTitle(ChartArea);
|
31
client/components/Stats/StatsCharts/Bar.js
Normal file
31
client/components/Stats/StatsCharts/Bar.js
Normal file
@ -0,0 +1,31 @@
|
||||
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);
|
34
client/components/Stats/StatsCharts/Pie.js
Normal file
34
client/components/Stats/StatsCharts/Pie.js
Normal file
@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { PieChart, Pie, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import withTitle from './withTitle';
|
||||
|
||||
const renderCustomLabel = ({ name }) => name;
|
||||
|
||||
const ChartPie = ({ 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={renderCustomLabel}
|
||||
/>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
ChartPie.propTypes = {
|
||||
data: PropTypes.arrayOf(PropTypes.object.isRequired).isRequired,
|
||||
};
|
||||
|
||||
export default withTitle(ChartPie);
|
78
client/components/Stats/StatsCharts/StatsCharts.js
Normal file
78
client/components/Stats/StatsCharts/StatsCharts.js
Normal file
@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from 'styled-components';
|
||||
import Area from './Area';
|
||||
import Pie from './Pie';
|
||||
import Bar from './Bar';
|
||||
|
||||
const ChartsWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 32px;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
padding: 16px 16px 32px 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Row = styled.div`
|
||||
display: flex;
|
||||
border-bottom: 1px dotted #aaa;
|
||||
padding: 0 0 40px 0;
|
||||
margin: 0 0 32px 0;
|
||||
|
||||
:last-child {
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
border-bottom: none;
|
||||
|
||||
> *:not(:last-child) {
|
||||
padding-bottom: 24px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px dotted #aaa;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StatsCharts = ({ stats, period }) => {
|
||||
const periodText = period.includes('last')
|
||||
? `the last ${period.replace('last', '').toLocaleLowerCase()}`
|
||||
: 'all time';
|
||||
const hasView = stats.views.filter(view => view > 0);
|
||||
return (
|
||||
<ChartsWrapper>
|
||||
<Row>
|
||||
<Area data={stats.views} period={period} periodText={periodText} />
|
||||
</Row>
|
||||
{hasView.length
|
||||
? [
|
||||
<Row key="second-row">
|
||||
<Pie data={stats.stats.referrer} title="Referrals" />
|
||||
<Bar data={stats.stats.browser} title="Browsers" />
|
||||
</Row>,
|
||||
<Row key="third-row">
|
||||
<Pie data={stats.stats.country} title="Country" />
|
||||
<Bar data={stats.stats.os} title="OS" />
|
||||
</Row>,
|
||||
]
|
||||
: null}
|
||||
</ChartsWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
StatsCharts.propTypes = {
|
||||
period: PropTypes.string.isRequired,
|
||||
stats: PropTypes.shape({
|
||||
stats: PropTypes.object.isRequired,
|
||||
views: PropTypes.array.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default StatsCharts;
|
1
client/components/Stats/StatsCharts/index.js
Normal file
1
client/components/Stats/StatsCharts/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './StatsCharts';
|
54
client/components/Stats/StatsCharts/withTitle.js
Normal file
54
client/components/Stats/StatsCharts/withTitle.js
Normal file
@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
flex: 1 1 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const Title = styled.h3`
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
font-size: 18px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Count = styled.span`
|
||||
font-weight: bold;
|
||||
border-bottom: 1px dotted #999;
|
||||
`;
|
||||
|
||||
const withTitle = ChartComponent => {
|
||||
function WithTitle(props) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Title>
|
||||
{props.periodText && <Count>{props.data.reduce((sum, view) => sum + view, 0)}</Count>}
|
||||
{props.periodText ? ` clicks in ${props.periodText}` : props.title}.
|
||||
</Title>
|
||||
<ChartComponent {...props} />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
WithTitle.propTypes = {
|
||||
data: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.object])).isRequired,
|
||||
periodText: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
};
|
||||
WithTitle.defaultProps = {
|
||||
title: '',
|
||||
periodText: '',
|
||||
};
|
||||
return WithTitle;
|
||||
};
|
||||
|
||||
export default withTitle;
|
46
client/components/Stats/StatsError.js
Normal file
46
client/components/Stats/StatsError.js
Normal file
@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const ErrorWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const ErrorMessage = styled.h3`
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
font-size: 18px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Icon = styled.img`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 6px 12px 0 0;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StatsError = ({ text }) => (
|
||||
<ErrorWrapper>
|
||||
<Icon src="/images/x.svg" />
|
||||
<ErrorMessage>{text || 'Could not get the short URL stats.'}</ErrorMessage>
|
||||
</ErrorWrapper>
|
||||
);
|
||||
|
||||
StatsError.propTypes = {
|
||||
text: PropTypes.string,
|
||||
};
|
||||
|
||||
StatsError.defaultProps = {
|
||||
text: '',
|
||||
};
|
||||
|
||||
export default StatsError;
|
102
client/components/Stats/StatsHead.js
Normal file
102
client/components/Stats/StatsHead.js
Normal file
@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 32px;
|
||||
background-color: #f1f1f1;
|
||||
border-top-left-radius: 12px;
|
||||
border-top-right-radius: 12px;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
padding: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const TotalText = styled.p`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
span {
|
||||
font-weight: bold;
|
||||
border-bottom: 1px dotted #999;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
font-size: 13px;
|
||||
}
|
||||
`;
|
||||
|
||||
const TimeWrapper = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const Button = styled.button`
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
margin: 0 4px;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0px 10px rgba(100, 100, 100, 0.1);
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-out;
|
||||
box-sizing: border-box;
|
||||
|
||||
:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
${({ active }) =>
|
||||
!active &&
|
||||
css`
|
||||
border: 1px solid #ddd;
|
||||
background-color: #f5f5f5;
|
||||
box-shadow: 0 2px 6px rgba(150, 150, 150, 0.2);
|
||||
|
||||
:hover {
|
||||
border-color: 1px solid #ccc;
|
||||
background-color: white;
|
||||
}
|
||||
`};
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
padding: 4px 8px;
|
||||
margin: 0 2px;
|
||||
font-size: 11px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StatsHead = ({ changePeriod, period, total }) => {
|
||||
const buttonWithPeriod = (periodText, text) => (
|
||||
<Button active={period === periodText} data-period={periodText} onClick={changePeriod}>
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<Wrapper>
|
||||
<TotalText>
|
||||
Total clicks: <span>{total}</span>
|
||||
</TotalText>
|
||||
<TimeWrapper>
|
||||
{buttonWithPeriod('allTime', 'All Time')}
|
||||
{buttonWithPeriod('lastMonth', 'Month')}
|
||||
{buttonWithPeriod('lastWeek', 'Week')}
|
||||
{buttonWithPeriod('lastDay', 'Day')}
|
||||
</TimeWrapper>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
StatsHead.propTypes = {
|
||||
changePeriod: PropTypes.func.isRequired,
|
||||
period: PropTypes.string.isRequired,
|
||||
total: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default StatsHead;
|
1
client/components/Stats/index.js
Normal file
1
client/components/Stats/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Stats';
|
137
client/components/Table/TBody/TBody.js
Normal file
137
client/components/Table/TBody/TBody.js
Normal file
@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import styled, { css } from 'styled-components';
|
||||
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
|
||||
import TBodyShortUrl from './TBodyShortUrl';
|
||||
import TBodyCount from './TBodyCount';
|
||||
|
||||
const TBody = styled.tbody`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
|
||||
${({ loading }) =>
|
||||
loading &&
|
||||
css`
|
||||
opacity: 0.2;
|
||||
`};
|
||||
|
||||
tr:hover {
|
||||
background-color: #f8f8f8;
|
||||
|
||||
td:after {
|
||||
background: linear-gradient(to left, #f8f8f8, #f8f8f8, transparent);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Td = styled.td`
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
${({ withFade }) =>
|
||||
withFade &&
|
||||
css`
|
||||
:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 56px;
|
||||
background: linear-gradient(to left, white, white, transparent);
|
||||
}
|
||||
`};
|
||||
|
||||
:last-child {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #2196f3;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px dotted transparent;
|
||||
transition: all 0.2s ease-out;
|
||||
|
||||
:hover {
|
||||
border-bottom-color: #2196f3;
|
||||
}
|
||||
}
|
||||
|
||||
${({ date }) =>
|
||||
date &&
|
||||
css`
|
||||
font-size: 15px;
|
||||
`};
|
||||
|
||||
${({ flex }) =>
|
||||
flex &&
|
||||
css`
|
||||
flex: ${`${flex} ${flex}`} 0;
|
||||
`};
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
flex: 1;
|
||||
:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 510px) {
|
||||
:nth-child(1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const TableBody = ({ copiedIndex, handleCopy, tableLoading, showModal, urls }) => {
|
||||
const showList = (url, index) => (
|
||||
<tr key={`tbody-${index}`}>
|
||||
<Td flex="2" withFade>
|
||||
<a href={url.target}>{url.target}</a>
|
||||
</Td>
|
||||
<Td flex="1" date>
|
||||
{`${distanceInWordsToNow(url.createdAt)} ago`}
|
||||
</Td>
|
||||
<Td flex="1" withFade>
|
||||
<TBodyShortUrl index={index} copiedIndex={copiedIndex} handleCopy={handleCopy} url={url} />
|
||||
</Td>
|
||||
<Td flex="1">
|
||||
<TBodyCount url={url} showModal={showModal} />
|
||||
</Td>
|
||||
</tr>
|
||||
);
|
||||
return (
|
||||
<TBody loading={tableLoading}>
|
||||
{urls.length ? (
|
||||
urls.map(showList)
|
||||
) : (
|
||||
<tr>
|
||||
<Td>Nothing to show.</Td>
|
||||
</tr>
|
||||
)}
|
||||
</TBody>
|
||||
);
|
||||
};
|
||||
|
||||
TableBody.propTypes = {
|
||||
urls: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
count: PropTypes.number,
|
||||
createdAt: PropTypes.string.isRequired,
|
||||
password: PropTypes.bool,
|
||||
target: PropTypes.string.isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
copiedIndex: PropTypes.number.isRequired,
|
||||
showModal: PropTypes.func.isRequired,
|
||||
tableLoading: PropTypes.bool.isRequired,
|
||||
handleCopy: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({ loading: { table: tableLoading } }) => ({ tableLoading });
|
||||
|
||||
export default connect(mapStateToProps)(TableBody);
|
73
client/components/Table/TBody/TBodyButton.js
Normal file
73
client/components/Table/TBody/TBodyButton.js
Normal file
@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
const Button = styled.button`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin: 0 12px 0 2px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
border-radius: 100%;
|
||||
box-shadow: 0 2px 4px rgba(100, 100, 100, 0.1);
|
||||
background-color: #dedede;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-out;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
margin: 0 8px 0 2px;
|
||||
|
||||
img {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
${({ withText }) =>
|
||||
withText &&
|
||||
css`
|
||||
width: auto;
|
||||
padding: 0 12px;
|
||||
border-radius: 100px;
|
||||
|
||||
img {
|
||||
margin: 4px 6px 0 0;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
width: auto;
|
||||
}
|
||||
`};
|
||||
|
||||
:active,
|
||||
:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
`;
|
||||
|
||||
const TBodyButton = ({ children, withText, ...props }) => (
|
||||
<Button withText={withText} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
|
||||
TBodyButton.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
withText: PropTypes.bool,
|
||||
};
|
||||
|
||||
TBodyButton.defaultProps = {
|
||||
withText: null,
|
||||
};
|
||||
|
||||
export default TBodyButton;
|
87
client/components/Table/TBody/TBodyCount.js
Normal file
87
client/components/Table/TBody/TBodyCount.js
Normal file
@ -0,0 +1,87 @@
|
||||
import React, { Component } from 'react';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import Router from 'next/router';
|
||||
import styled from 'styled-components';
|
||||
import URL from 'url';
|
||||
import TBodyButton from './TBodyButton';
|
||||
import { showPageLoading } from '../../../actions';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Actions = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
margin: 0 2px 0 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Icon = styled.img`
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
`;
|
||||
|
||||
class TBodyCount extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.goTo = this.goTo.bind(this);
|
||||
}
|
||||
|
||||
goTo(e) {
|
||||
e.preventDefault();
|
||||
this.props.showLoading();
|
||||
const host = URL.parse(this.props.url.shortUrl).hostname;
|
||||
Router.push(`/stats?id=${this.props.url.id}${`&domain=${host}`}`);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { showModal, url } = this.props;
|
||||
return (
|
||||
<Wrapper>
|
||||
{url.count || 0}
|
||||
<Actions>
|
||||
{url.password && <Icon src="/images/lock.svg" lowopacity />}
|
||||
{url.count > 0 && (
|
||||
<TBodyButton withText onClick={this.goTo}>
|
||||
<Icon src="/images/chart.svg" />
|
||||
Stats
|
||||
</TBodyButton>
|
||||
)}
|
||||
<TBodyButton
|
||||
data-id={url.id}
|
||||
data-host={URL.parse(url.shortUrl).hostname}
|
||||
onClick={showModal}
|
||||
>
|
||||
<Icon src="/images/trash.svg" />
|
||||
</TBodyButton>
|
||||
</Actions>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TBodyCount.propTypes = {
|
||||
showLoading: PropTypes.func.isRequired,
|
||||
showModal: PropTypes.func.isRequired,
|
||||
url: PropTypes.shape({
|
||||
count: PropTypes.number,
|
||||
id: PropTypes.string,
|
||||
password: PropTypes.bool,
|
||||
shortUrl: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
showLoading: bindActionCreators(showPageLoading, dispatch),
|
||||
});
|
||||
|
||||
export default connect(null, mapDispatchToProps)(TBodyCount);
|
46
client/components/Table/TBody/TBodyShortUrl.js
Normal file
46
client/components/Table/TBody/TBodyShortUrl.js
Normal file
@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from 'styled-components';
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||
import TBodyButton from './TBodyButton';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const CopyText = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 40px;
|
||||
font-size: 11px;
|
||||
color: green;
|
||||
`;
|
||||
|
||||
const Icon = styled.img`
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
`;
|
||||
|
||||
const TBodyShortUrl = ({ index, copiedIndex, handleCopy, url }) => (
|
||||
<Wrapper>
|
||||
{copiedIndex === index && <CopyText>Copied to clipboard!</CopyText>}
|
||||
<CopyToClipboard onCopy={() => handleCopy(index)} text={`${url.shortUrl}`}>
|
||||
<TBodyButton>
|
||||
<Icon src="/images/copy.svg" />
|
||||
</TBodyButton>
|
||||
</CopyToClipboard>
|
||||
<a href={`${url.shortUrl}`}>{`${url.shortUrl.replace(/^https?:\/\//, '')}`}</a>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
TBodyShortUrl.propTypes = {
|
||||
copiedIndex: PropTypes.number.isRequired,
|
||||
handleCopy: PropTypes.func.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
url: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default TBodyShortUrl;
|
1
client/components/Table/TBody/index.js
Normal file
1
client/components/Table/TBody/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './TBody';
|
55
client/components/Table/THead/THead.js
Normal file
55
client/components/Table/THead/THead.js
Normal file
@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import styled, { css } from 'styled-components';
|
||||
import TableOptions from '../TableOptions';
|
||||
|
||||
const THead = styled.thead`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
background-color: #f1f1f1;
|
||||
border-top-right-radius: 12px;
|
||||
border-top-left-radius: 12px;
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid #dedede;
|
||||
}
|
||||
`;
|
||||
|
||||
const Th = styled.th`
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
|
||||
${({ flex }) =>
|
||||
flex &&
|
||||
css`
|
||||
flex: ${`${flex} ${flex}`} 0;
|
||||
`};
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
flex: 1;
|
||||
:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 510px) {
|
||||
:nth-child(1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const TableHead = () => (
|
||||
<THead>
|
||||
<TableOptions />
|
||||
<tr>
|
||||
<Th flex="2">Original URL</Th>
|
||||
<Th flex="1">Created</Th>
|
||||
<Th flex="1">Short URL</Th>
|
||||
<Th flex="1">Clicks</Th>
|
||||
</tr>
|
||||
</THead>
|
||||
);
|
||||
|
||||
export default TableHead;
|
1
client/components/Table/THead/index.js
Normal file
1
client/components/Table/THead/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './THead';
|
161
client/components/Table/Table.js
Normal file
161
client/components/Table/Table.js
Normal file
@ -0,0 +1,161 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
import THead from './THead';
|
||||
import TBody from './TBody';
|
||||
import TableOptions from './TableOptions';
|
||||
import { deleteShortUrl, getUrlsList } from '../../actions';
|
||||
import Modal from '../Modal';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
width: 1200px;
|
||||
max-width: 95%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 40px 0 120px;
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
font-size: 18px;
|
||||
}
|
||||
`;
|
||||
|
||||
const TableWrapper = styled.table`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 6px 30px rgba(50, 50, 50, 0.2);
|
||||
|
||||
tr {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
padding: 0 24px;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #eaeaea;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 16px 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 510px) {
|
||||
tr {
|
||||
padding: 0 16px;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const TFoot = styled.tfoot`
|
||||
background-color: #f1f1f1;
|
||||
border-bottom-right-radius: 12px;
|
||||
border-bottom-left-radius: 12px;
|
||||
`;
|
||||
|
||||
class Table extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
copiedIndex: -1,
|
||||
modalUrlId: '',
|
||||
modalUrlDomain: '',
|
||||
showModal: false,
|
||||
};
|
||||
this.handleCopy = this.handleCopy.bind(this);
|
||||
this.showModal = this.showModal.bind(this);
|
||||
this.closeModal = this.closeModal.bind(this);
|
||||
this.deleteUrl = this.deleteUrl.bind(this);
|
||||
}
|
||||
|
||||
handleCopy(index) {
|
||||
this.setState({ copiedIndex: index });
|
||||
setTimeout(() => {
|
||||
this.setState({ copiedIndex: -1 });
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
showModal(e) {
|
||||
e.preventDefault();
|
||||
const modalUrlId = e.currentTarget.dataset.id;
|
||||
const modalUrlDomain = e.currentTarget.dataset.host;
|
||||
this.setState({
|
||||
modalUrlId,
|
||||
modalUrlDomain,
|
||||
showModal: true,
|
||||
});
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
this.setState({
|
||||
modalUrlId: '',
|
||||
modalUrlDomain: '',
|
||||
showModal: false,
|
||||
});
|
||||
}
|
||||
|
||||
deleteUrl() {
|
||||
this.closeModal();
|
||||
const { modalUrlId, modalUrlDomain } = this.state;
|
||||
this.props.deleteShortUrl({ id: modalUrlId, domain: modalUrlDomain });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { copiedIndex } = this.state;
|
||||
const { url } = this.props;
|
||||
return (
|
||||
<Wrapper>
|
||||
<Title>Recent shortened links.</Title>
|
||||
<TableWrapper>
|
||||
<THead />
|
||||
<TBody
|
||||
copiedIndex={copiedIndex}
|
||||
handleCopy={this.handleCopy}
|
||||
urls={url.list}
|
||||
showModal={this.showModal}
|
||||
/>
|
||||
<TFoot>
|
||||
<TableOptions nosearch />
|
||||
</TFoot>
|
||||
</TableWrapper>
|
||||
<Modal show={this.state.showModal} handler={this.deleteUrl} close={this.closeModal}>
|
||||
Are you sure do you want to delete the short URL and its stats?
|
||||
</Modal>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Table.propTypes = {
|
||||
deleteShortUrl: PropTypes.func.isRequired,
|
||||
url: PropTypes.shape({
|
||||
list: PropTypes.array.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({ url }) => ({ url });
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
deleteShortUrl: bindActionCreators(deleteShortUrl, dispatch),
|
||||
getUrlsList: bindActionCreators(getUrlsList, dispatch),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Table);
|
67
client/components/Table/TableNav.js
Normal file
67
client/components/Table/TableNav.js
Normal file
@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Nav = styled.button`
|
||||
margin-left: 12px;
|
||||
padding: 5px 8px 3px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #eee;
|
||||
background-color: transparent;
|
||||
box-shadow: 0 0px 10px rgba(100, 100, 100, 0.1);
|
||||
transition: all 0.2s ease-out;
|
||||
|
||||
${({ active }) =>
|
||||
active &&
|
||||
css`
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
`};
|
||||
|
||||
:hover {
|
||||
${({ active }) =>
|
||||
active &&
|
||||
css`
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 25px rgba(50, 50, 50, 0.1);
|
||||
`};
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
padding: 4px 6px 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Icon = styled.img`
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
const TableNav = ({ handleNav, next, prev }) => (
|
||||
<Wrapper>
|
||||
<Nav active={prev} data-active={prev} data-type="prev" onClick={handleNav}>
|
||||
<Icon src="/images/nav-left.svg" />
|
||||
</Nav>
|
||||
<Nav active={next} data-active={next} data-type="next" onClick={handleNav}>
|
||||
<Icon src="/images/nav-right.svg" />
|
||||
</Nav>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
TableNav.propTypes = {
|
||||
handleNav: PropTypes.func.isRequired,
|
||||
next: PropTypes.bool.isRequired,
|
||||
prev: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default TableNav;
|
200
client/components/Table/TableOptions.js
Normal file
200
client/components/Table/TableOptions.js
Normal file
@ -0,0 +1,200 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import styled, { css } from 'styled-components';
|
||||
import TableNav from './TableNav';
|
||||
import TextInput from '../TextInput';
|
||||
import { getUrlsList } from '../../actions';
|
||||
|
||||
const Tr = styled.tr`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
thead & {
|
||||
border-bottom: 1px solid #ddd !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const Th = styled.th`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
${({ flex }) =>
|
||||
flex &&
|
||||
css`
|
||||
flex: ${`${flex} ${flex}`} 0;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Divider = styled.div`
|
||||
margin: 0 16px 0 24px;
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background-color: #ccc;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
margin: 0 4px 0 12px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 510px) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const ListCount = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Ul = styled.ul`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
margin: 0 0 0 12px;
|
||||
list-style: none;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 510px) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Button = styled.button`
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0px 10px rgba(100, 100, 100, 0.1);
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-out;
|
||||
box-sizing: border-box;
|
||||
|
||||
${({ active }) =>
|
||||
!active &&
|
||||
css`
|
||||
border: 1px solid #ddd;
|
||||
background-color: #f5f5f5;
|
||||
box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1);
|
||||
|
||||
:hover {
|
||||
border-color: 1px solid #ccc;
|
||||
background-color: white;
|
||||
}
|
||||
`};
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
font-size: 10px;
|
||||
}
|
||||
`;
|
||||
|
||||
class TableOptions extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
search: '',
|
||||
};
|
||||
this.submitSearch = this.submitSearch.bind(this);
|
||||
this.handleSearch = this.handleSearch.bind(this);
|
||||
this.handleCount = this.handleCount.bind(this);
|
||||
this.handleNav = this.handleNav.bind(this);
|
||||
}
|
||||
|
||||
submitSearch(e) {
|
||||
e.preventDefault();
|
||||
this.props.getUrlsList({ search: this.state.search });
|
||||
}
|
||||
|
||||
handleSearch(e) {
|
||||
this.setState({ search: e.currentTarget.value });
|
||||
}
|
||||
|
||||
handleCount(e) {
|
||||
const count = Number(e.target.textContent);
|
||||
this.props.getUrlsList({ count });
|
||||
}
|
||||
|
||||
handleNav(e) {
|
||||
const { active, type } = e.target.dataset;
|
||||
if (active === 'false') return null;
|
||||
const number = type === 'next' ? 1 : -1;
|
||||
return this.props.getUrlsList({ page: this.props.url.page + number });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { count, countAll, page } = this.props.url;
|
||||
return (
|
||||
<Tr>
|
||||
<Th>
|
||||
{!this.props.nosearch && (
|
||||
<form onSubmit={this.submitSearch}>
|
||||
<TextInput
|
||||
id="search"
|
||||
name="search"
|
||||
value={this.state.search}
|
||||
placeholder="Search..."
|
||||
onChange={this.handleSearch}
|
||||
tiny
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</Th>
|
||||
<Th>
|
||||
<ListCount>
|
||||
<Ul>
|
||||
<li>
|
||||
<Button active={count === 10} onClick={this.handleCount}>
|
||||
10
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button active={count === 25} onClick={this.handleCount}>
|
||||
25
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button active={count === 50} onClick={this.handleCount}>
|
||||
50
|
||||
</Button>
|
||||
</li>
|
||||
</Ul>
|
||||
</ListCount>
|
||||
<Divider />
|
||||
<TableNav handleNav={this.handleNav} next={page * count < countAll} prev={page > 1} />
|
||||
</Th>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TableOptions.propTypes = {
|
||||
getUrlsList: PropTypes.func.isRequired,
|
||||
nosearch: PropTypes.bool,
|
||||
url: PropTypes.shape({
|
||||
page: PropTypes.number.isRequired,
|
||||
count: PropTypes.number.isRequired,
|
||||
countAll: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
TableOptions.defaultProps = {
|
||||
nosearch: false,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({ url }) => ({ url });
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
getUrlsList: bindActionCreators(getUrlsList, dispatch),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(TableOptions);
|
1
client/components/Table/index.js
Normal file
1
client/components/Table/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Table';
|
122
client/components/TextInput/TextInput.js
Normal file
122
client/components/TextInput/TextInput.js
Normal file
@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { fadeIn } from '../../helpers/animations';
|
||||
|
||||
const LinkInput = styled.input`
|
||||
position: relative;
|
||||
width: auto;
|
||||
flex: 1 1 auto;
|
||||
height: 72px;
|
||||
padding: 0 84px 0 40px;
|
||||
font-size: 20px;
|
||||
letter-spacing: 0.05em;
|
||||
color: #444;
|
||||
box-sizing: border-box;
|
||||
background-color: white;
|
||||
box-shadow: 0 10px 35px rgba(50, 50, 50, 0.1);
|
||||
border-radius: 100px;
|
||||
border: none;
|
||||
border-bottom: 6px solid #f5f5f5;
|
||||
animation: ${fadeIn} 0.5s ease-out;
|
||||
transition: all 0.5s ease-out;
|
||||
|
||||
:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 20px 35px rgba(50, 50, 50, 0.2);
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.1em;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 488px) {
|
||||
height: 56px;
|
||||
padding: 0 48px 0 32px;
|
||||
font-size: 14px;
|
||||
border-bottom-width: 5px;
|
||||
::placeholder {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
${({ small }) =>
|
||||
small &&
|
||||
css`
|
||||
width: 240px;
|
||||
height: 54px;
|
||||
margin-right: 32px;
|
||||
padding: 0 24px 2px;
|
||||
font-size: 18px;
|
||||
border-bottom: 4px solid #f5f5f5;
|
||||
::placeholder {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 448px) {
|
||||
width: 200px;
|
||||
height: 40px;
|
||||
padding: 0 16px 2px;
|
||||
font-size: 13px;
|
||||
border-bottom-width: 3px;
|
||||
}
|
||||
`};
|
||||
|
||||
${({ tiny }) =>
|
||||
tiny &&
|
||||
css`
|
||||
flex: 0 0 auto;
|
||||
width: 280px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
padding: 0 16px 1px;
|
||||
font-size: 13px;
|
||||
border-bottom-width: 1px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 10px rgba(100, 100, 100, 0.1);
|
||||
|
||||
:focus {
|
||||
box-shadow: 0 10px 25px rgba(50, 50, 50, 0.1);
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
width: 240px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 510px) {
|
||||
width: 180px;
|
||||
height: 24px;
|
||||
padding: 0 8px 1px;
|
||||
font-size: 12px;
|
||||
border-bottom-width: 3px;
|
||||
}
|
||||
`};
|
||||
|
||||
${({ height }) =>
|
||||
height &&
|
||||
css`
|
||||
height: ${height}px;
|
||||
`};
|
||||
`;
|
||||
|
||||
const TextInput = props => <LinkInput {...props} />;
|
||||
|
||||
TextInput.propTypes = {
|
||||
small: PropTypes.bool,
|
||||
tiny: PropTypes.bool,
|
||||
};
|
||||
|
||||
TextInput.defaultProps = {
|
||||
small: false,
|
||||
tiny: false,
|
||||
};
|
||||
|
||||
export default TextInput;
|
1
client/components/TextInput/index.js
Normal file
1
client/components/TextInput/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './TextInput';
|
10
client/config.example.js
Normal file
10
client/config.example.js
Normal file
@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
/*
|
||||
reCaptcha site key
|
||||
Create one in https://www.google.com/recaptcha/intro/
|
||||
*/
|
||||
RECAPTCHA_SITE_KEY: '',
|
||||
|
||||
// Google analytics tracking ID
|
||||
GOOGLE_ANALYTICS_ID: '6Lc4TUAUAAAAAMRHnlEEt21UkPlOXKCXHaIapdTT',
|
||||
};
|
23
client/helpers/analytics.js
Normal file
23
client/helpers/analytics.js
Normal file
@ -0,0 +1,23 @@
|
||||
import ReactGA from 'react-ga';
|
||||
import { GOOGLE_ANALYTICS_ID } from '../config';
|
||||
|
||||
export const initGA = () => {
|
||||
ReactGA.initialize(GOOGLE_ANALYTICS_ID, { debug: true });
|
||||
};
|
||||
|
||||
export const logPageView = () => {
|
||||
ReactGA.set({ page: window.location.pathname });
|
||||
ReactGA.pageview(window.location.pathname);
|
||||
};
|
||||
|
||||
export const logEvent = (category = '', action = '') => {
|
||||
if (category && action) {
|
||||
ReactGA.event({ category, action });
|
||||
}
|
||||
};
|
||||
|
||||
export const logException = (description = '', fatal = false) => {
|
||||
if (description) {
|
||||
ReactGA.exception({ description, fatal });
|
||||
}
|
||||
};
|
19
client/helpers/animations.js
Normal file
19
client/helpers/animations.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { keyframes } from 'styled-components';
|
||||
|
||||
export const fadeIn = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export const spin = keyframes`
|
||||
from {
|
||||
transform: rotate(0);
|
||||
}
|
||||
to {
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
`;
|
17
client/helpers/recaptcha.js
Normal file
17
client/helpers/recaptcha.js
Normal file
@ -0,0 +1,17 @@
|
||||
export default function showRecaptcha() {
|
||||
const captcha = document.getElementById('g-recaptcha');
|
||||
if (!captcha) return null;
|
||||
window.recaptchaCallback = response => {
|
||||
const captchaInput = document.getElementById('g-recaptcha-input');
|
||||
captchaInput.value = response;
|
||||
};
|
||||
if (!window.grecaptcha) {
|
||||
return setTimeout(() => showRecaptcha(captcha), 200);
|
||||
}
|
||||
return setTimeout(() => {
|
||||
if ((window, captcha, !captcha.childNodes.length)) {
|
||||
window.grecaptcha.render(captcha);
|
||||
}
|
||||
return null;
|
||||
}, 1000);
|
||||
}
|
67
client/pages/_document.js
Normal file
67
client/pages/_document.js
Normal file
@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import Document, { Head, Main, NextScript } from 'next/document';
|
||||
import { ServerStyleSheet } from 'styled-components';
|
||||
|
||||
const style = {
|
||||
margin: 0,
|
||||
backgroundColor: '#f3f3f3',
|
||||
font: '16px/1.45 "Nunito", sans-serif',
|
||||
overflowX: 'hidden',
|
||||
color: 'black',
|
||||
};
|
||||
|
||||
class AppDocument extends Document {
|
||||
static getInitialProps({ renderPage }) {
|
||||
const sheet = new ServerStyleSheet();
|
||||
const page = renderPage(App => props => sheet.collectStyles(<App {...props} />));
|
||||
const styleTags = sheet.getStyleElement();
|
||||
return { ...page, styleTags };
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<Head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Kutt.it | Modern URL shortener.</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Kutt.it is a free and open source URL shortener with custom domains and stats."
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Nunito:300,400,700"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="icon" sizes="196x196" href="/images/favicon-196x196.png" />
|
||||
<link rel="icon" sizes="32x32" href="/images/favicon-32x32.png" />
|
||||
<link rel="icon" sizes="16x16" href="/images/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon-196x196.png" />
|
||||
<link rel="mask-icon" href="/images/icon.svg" color="blue" />
|
||||
|
||||
<meta property="fb:app_id" content="123456789" />
|
||||
<meta property="og:url" content="https://kutt.it" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Kutt.it" />
|
||||
<meta property="og:image" content="https://kutt.it/card.png" />
|
||||
<meta property="og:description" content="Free Modern URL Shortener" />
|
||||
<meta name="twitter:url" content="https://kutt.it" />
|
||||
<meta name="twitter:title" content="Kutt.it" />
|
||||
<meta name="twitter:description" content="Free Modern URL Shortener" />
|
||||
<meta name="twitter:image" content="https://kutt.it/card.png" />
|
||||
|
||||
{this.props.styleTags}
|
||||
|
||||
<script src="https://www.google.com/recaptcha/api.js?render=explicit" />
|
||||
<script src="/analytics.js" />
|
||||
</Head>
|
||||
<body style={style}>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AppDocument;
|
55
client/pages/index.js
Normal file
55
client/pages/index.js
Normal file
@ -0,0 +1,55 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import withRedux from 'next-redux-wrapper';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import initialState from '../store';
|
||||
import BodyWrapper from '../components/BodyWrapper';
|
||||
import Shortener from '../components/Shortener';
|
||||
import Features from '../components/Features';
|
||||
import Table from '../components/Table';
|
||||
import NeedToLogin from '../components/NeedToLogin';
|
||||
import Footer from '../components/Footer/Footer';
|
||||
import { authUser, getUrlsList } from '../actions';
|
||||
|
||||
class Homepage extends Component {
|
||||
static getInitialProps({ req, store }) {
|
||||
const token = req && req.cookies && req.cookies.token;
|
||||
if (token && store) store.dispatch(authUser(token));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.isAuthenticated) this.props.getUrlsList();
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
return this.props.isAuthenticated !== nextProps.isAuthenticated;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isAuthenticated } = this.props;
|
||||
const needToLogin = !isAuthenticated && <NeedToLogin />;
|
||||
const table = isAuthenticated && <Table />;
|
||||
return (
|
||||
<BodyWrapper>
|
||||
<Shortener />
|
||||
{needToLogin}
|
||||
{table}
|
||||
<Features />
|
||||
<Footer />
|
||||
</BodyWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Homepage.propTypes = {
|
||||
isAuthenticated: PropTypes.bool.isRequired,
|
||||
getUrlsList: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({ auth: { isAuthenticated } }) => ({ isAuthenticated });
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
getUrlsList: bindActionCreators(getUrlsList, dispatch),
|
||||
});
|
||||
|
||||
export default withRedux(initialState, mapStateToProps, mapDispatchToProps)(Homepage);
|
39
client/pages/login.js
Normal file
39
client/pages/login.js
Normal file
@ -0,0 +1,39 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import withRedux from 'next-redux-wrapper';
|
||||
import Router from 'next/router';
|
||||
import initialState from '../store';
|
||||
import BodyWrapper from '../components/BodyWrapper';
|
||||
import Login from '../components/Login';
|
||||
import { authUser } from '../actions';
|
||||
|
||||
class LoginPage extends Component {
|
||||
componentDidMount() {
|
||||
if (this.props.isAuthenticated) {
|
||||
Router.push('/');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
!this.props.isAuthenticated && (
|
||||
<BodyWrapper>
|
||||
<Login />
|
||||
</BodyWrapper>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LoginPage.getInitialProps = ({ req, store }) => {
|
||||
const token = req && req.cookies && req.cookies.token;
|
||||
if (token && store) store.dispatch(authUser(token));
|
||||
};
|
||||
|
||||
LoginPage.propTypes = {
|
||||
isAuthenticated: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({ auth: { isAuthenticated } }) => ({ isAuthenticated });
|
||||
|
||||
export default withRedux(initialState, mapStateToProps)(LoginPage);
|
23
client/pages/logout.js
Normal file
23
client/pages/logout.js
Normal file
@ -0,0 +1,23 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import withRedux from 'next-redux-wrapper';
|
||||
import initialState from '../store';
|
||||
import { logoutUser } from '../actions';
|
||||
|
||||
class LogoutPage extends Component {
|
||||
componentDidMount() {
|
||||
this.props.logoutUser();
|
||||
}
|
||||
render() {
|
||||
return <div />;
|
||||
}
|
||||
}
|
||||
|
||||
LogoutPage.propTypes = {
|
||||
logoutUser: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({ logoutUser: bindActionCreators(logoutUser, dispatch) });
|
||||
|
||||
export default withRedux(initialState, null, mapDispatchToProps)(LogoutPage);
|
130
client/pages/reset-password.js
Normal file
130
client/pages/reset-password.js
Normal file
@ -0,0 +1,130 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Router from 'next/router';
|
||||
import withRedux from 'next-redux-wrapper';
|
||||
import styled, { css } from 'styled-components';
|
||||
import cookie from 'js-cookie';
|
||||
import axios from 'axios';
|
||||
import initialState from '../store';
|
||||
import BodyWrapper from '../components/BodyWrapper';
|
||||
import TextInput from '../components/TextInput';
|
||||
import Button from '../components/Button';
|
||||
import { authUser } from '../actions';
|
||||
|
||||
const Form = styled.form`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
input {
|
||||
margin: 16px 0 32px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Message = styled.p`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 16px;
|
||||
font-size: 14px;
|
||||
color: green;
|
||||
|
||||
${({ error }) =>
|
||||
error &&
|
||||
css`
|
||||
color: red;
|
||||
`};
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
bottom: 32px;
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
class ResetPassword extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
error: '',
|
||||
loading: false,
|
||||
success: '',
|
||||
};
|
||||
this.handleReset = this.handleReset.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.query || cookie.get('token')) {
|
||||
cookie.set('token', this.props.query.token, { expires: 7 });
|
||||
Router.push('/settings');
|
||||
}
|
||||
}
|
||||
|
||||
handleReset(e) {
|
||||
if (this.state.loading) return null;
|
||||
e.preventDefault();
|
||||
const form = document.getElementById('reset-password-form');
|
||||
const { email: { value } } = form.elements;
|
||||
if (!value) {
|
||||
this.setState({ error: 'Please provide an Email address.' }, () => {
|
||||
setTimeout(() => {
|
||||
this.setState({ error: '' });
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
this.setState({ loading: true });
|
||||
return axios
|
||||
.post('/api/auth/resetpassword', { email: value })
|
||||
.then(() =>
|
||||
this.setState({ success: 'Reset password email has been sent.', loading: false }, () => {
|
||||
setTimeout(() => {
|
||||
this.setState({ success: '' });
|
||||
}, 2500);
|
||||
})
|
||||
)
|
||||
.catch(() =>
|
||||
this.setState({ error: "Couldn't reset password", loading: false }, () => {
|
||||
setTimeout(() => {
|
||||
this.setState({ error: '' });
|
||||
}, 1500);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error, loading, success } = this.state;
|
||||
return (
|
||||
<BodyWrapper>
|
||||
<Form id="reset-password-form" onSubmit={this.handleReset}>
|
||||
<TextInput type="email" name="email" id="email" placeholder="Email address" autoFocus />
|
||||
<Button onClick={this.handleReset} icon={loading ? 'loader' : ''} big>
|
||||
Reset password
|
||||
</Button>
|
||||
<Message error={!success && error}>
|
||||
{!success && error}
|
||||
{success}
|
||||
</Message>
|
||||
</Form>
|
||||
</BodyWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ResetPassword.getInitialProps = ({ store, query }) => {
|
||||
if (query && query.token) {
|
||||
store.dispatch(authUser(query.token));
|
||||
return { query };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
ResetPassword.propTypes = {
|
||||
query: PropTypes.shape({
|
||||
token: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
ResetPassword.defaultProps = {
|
||||
query: null,
|
||||
};
|
||||
|
||||
export default withRedux(initialState)(ResetPassword);
|
28
client/pages/settings.js
Normal file
28
client/pages/settings.js
Normal file
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import withRedux from 'next-redux-wrapper';
|
||||
import initialState from '../store';
|
||||
import BodyWrapper from '../components/BodyWrapper';
|
||||
import Footer from '../components/Footer';
|
||||
import { authUser } from '../actions';
|
||||
import Settings from '../components/Settings';
|
||||
|
||||
const SettingsPage = ({ isAuthenticated }) => (
|
||||
<BodyWrapper>
|
||||
{isAuthenticated ? <Settings /> : null}
|
||||
<Footer />
|
||||
</BodyWrapper>
|
||||
);
|
||||
|
||||
SettingsPage.getInitialProps = ({ req, store }) => {
|
||||
const token = req && req.cookies && req.cookies.token;
|
||||
if (token && store) store.dispatch(authUser(token));
|
||||
};
|
||||
|
||||
SettingsPage.propTypes = {
|
||||
isAuthenticated: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({ auth: { isAuthenticated } }) => ({ isAuthenticated });
|
||||
|
||||
export default withRedux(initialState, mapStateToProps)(SettingsPage);
|
29
client/pages/stats.js
Normal file
29
client/pages/stats.js
Normal file
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import withRedux from 'next-redux-wrapper';
|
||||
import initialState from '../store';
|
||||
import BodyWrapper from '../components/BodyWrapper';
|
||||
import Stats from '../components/Stats';
|
||||
import { authUser } from '../actions';
|
||||
|
||||
const StatsPage = ({ id }) => (
|
||||
<BodyWrapper>
|
||||
<Stats id={id} />
|
||||
</BodyWrapper>
|
||||
);
|
||||
|
||||
StatsPage.getInitialProps = ({ req, store, query }) => {
|
||||
const token = req && req.cookies && req.cookies.token;
|
||||
if (token && store) store.dispatch(authUser(token));
|
||||
return { id: query && query.id };
|
||||
};
|
||||
|
||||
StatsPage.propTypes = {
|
||||
id: PropTypes.string,
|
||||
};
|
||||
|
||||
StatsPage.defaultProps = {
|
||||
id: '',
|
||||
};
|
||||
|
||||
export default withRedux(initialState)(StatsPage);
|
62
client/pages/terms.js
Normal file
62
client/pages/terms.js
Normal file
@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import withRedux from 'next-redux-wrapper';
|
||||
import styled from 'styled-components';
|
||||
import initialState from '../store';
|
||||
import BodyWrapper from '../components/BodyWrapper';
|
||||
import { authUser } from '../actions';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
width: 600px;
|
||||
max-width: 97%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
`;
|
||||
|
||||
const SettingsPage = () => (
|
||||
<BodyWrapper>
|
||||
<Wrapper>
|
||||
<h3>Kutt Terms of Service</h3>
|
||||
<p>
|
||||
By accessing the website at <a href="https://kutt.it">https://kutt.it</a>, you are agreeing
|
||||
to be bound by these terms of service, all applicable laws and regulations, and agree that
|
||||
you are responsible for compliance with any applicable local laws. If you do not agree with
|
||||
any of these terms, you are prohibited from using or accessing this site. The materials
|
||||
contained in this website are protected by applicable copyright and trademark law.
|
||||
</p>
|
||||
<p>
|
||||
In no event shall Kutt or its suppliers be liable for any damages (including, without
|
||||
limitation, damages for loss of data or profit, or due to business interruption) arising out
|
||||
of the use or inability to use the materials on {"Kutt's"} website, even if Kutt or a Kutt
|
||||
authorized representative has been notified orally or in writing of the possibility of such
|
||||
damage. Because some jurisdictions do not allow limitations on implied warranties, or
|
||||
limitations of liability for consequential or incidental damages, these limitations may not
|
||||
apply to you.
|
||||
</p>
|
||||
<p>
|
||||
The materials appearing on Kutt website could include technical, typographical, or
|
||||
photographic errors. Kutt does not warrant that any of the materials on its website are
|
||||
accurate, complete or current. Kutt may make changes to the materials contained on its
|
||||
website at any time without notice. However Kutt does not make any commitment to update the
|
||||
materials.
|
||||
</p>
|
||||
<p>
|
||||
Kutt has not reviewed all of the sites linked to its website and is not responsible for the
|
||||
contents of any such linked site. The inclusion of any link does not imply endorsement by
|
||||
Kutt of the site. Use of any such linked website is at the {"user's"} own risk.
|
||||
</p>
|
||||
<p>
|
||||
Kutt may revise these terms of service for its website at any time without notice. By using
|
||||
this website you are agreeing to be bound by the then current version of these terms of
|
||||
service.
|
||||
</p>
|
||||
</Wrapper>
|
||||
</BodyWrapper>
|
||||
);
|
||||
|
||||
SettingsPage.getInitialProps = ({ req, store }) => {
|
||||
const token = req && req.cookies && req.cookies.token;
|
||||
if (token && store) store.dispatch(authUser(token));
|
||||
};
|
||||
|
||||
export default withRedux(initialState)(SettingsPage);
|
120
client/pages/url-password.js
Normal file
120
client/pages/url-password.js
Normal file
@ -0,0 +1,120 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import withRedux from 'next-redux-wrapper';
|
||||
import styled from 'styled-components';
|
||||
import axios from 'axios';
|
||||
import initialState from '../store';
|
||||
import BodyWrapper from '../components/BodyWrapper';
|
||||
import TextInput from '../components/TextInput';
|
||||
import Button from '../components/Button';
|
||||
|
||||
const Title = styled.h3`
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
text-align: center;
|
||||
|
||||
@media only screen and (max-width: 448px) {
|
||||
font-size: 18px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Form = styled.form`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Error = styled.p`
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -48px;
|
||||
font-size: 14px;
|
||||
color: red;
|
||||
|
||||
@media only screen and (max-width: 448px) {
|
||||
bottom: -40px;
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
class UrlPasswordPage extends Component {
|
||||
static getInitialProps({ query }) {
|
||||
return { query };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
error: '',
|
||||
loading: false,
|
||||
password: '',
|
||||
};
|
||||
this.updatePassword = this.updatePassword.bind(this);
|
||||
this.requestUrl = this.requestUrl.bind(this);
|
||||
}
|
||||
|
||||
shouldComponentUpdate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
updatePassword(e) {
|
||||
this.setState({
|
||||
password: e.currentTarget.value,
|
||||
});
|
||||
}
|
||||
|
||||
requestUrl(e) {
|
||||
e.preventDefault();
|
||||
const { password } = this.state;
|
||||
if (!password) {
|
||||
return this.setState({
|
||||
error: 'Password must not be empty',
|
||||
});
|
||||
}
|
||||
this.setState({ error: '' });
|
||||
this.setState({ loading: true });
|
||||
return axios
|
||||
.post('/api/url/requesturl', { id: this.props.query, password })
|
||||
.then(({ data }) => window.location.replace(data.target))
|
||||
.catch(({ response }) =>
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.data.error,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.query) {
|
||||
return (
|
||||
<BodyWrapper>
|
||||
<Title>404 | Not found.</Title>
|
||||
</BodyWrapper>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<BodyWrapper>
|
||||
<Title>Enter the password to access the URL.</Title>
|
||||
<Form onSubmit={this.requestUrl}>
|
||||
<TextInput placeholder="Password" onChange={this.updatePassword} small />
|
||||
<Button type="submit" icon={this.state.loading ? 'loader' : ''}>
|
||||
Go
|
||||
</Button>
|
||||
<Error>{this.state.error}</Error>
|
||||
</Form>
|
||||
</BodyWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
UrlPasswordPage.propTypes = {
|
||||
query: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
UrlPasswordPage.defaultProps = {
|
||||
query: null,
|
||||
};
|
||||
|
||||
export default withRedux(initialState)(UrlPasswordPage);
|
101
client/pages/verify.js
Normal file
101
client/pages/verify.js
Normal file
@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Router from 'next/router';
|
||||
import withRedux from 'next-redux-wrapper';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import styled from 'styled-components';
|
||||
import cookie from 'js-cookie';
|
||||
import initialState from '../store';
|
||||
import BodyWrapper from '../components/BodyWrapper';
|
||||
import { authRenew, authUser, showPageLoading } from '../actions';
|
||||
import Button from '../components/Button';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const MessageWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 32px 0;
|
||||
`;
|
||||
|
||||
const Message = styled.p`
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
font-size: 18px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Icon = styled.img`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 16px;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Verify = ({ showLoading, query }) => {
|
||||
if (query) {
|
||||
cookie.set('token', query.token, { expires: 7 });
|
||||
}
|
||||
|
||||
const goToHomepage = e => {
|
||||
e.preventDefault();
|
||||
showLoading();
|
||||
Router.push('/');
|
||||
};
|
||||
|
||||
const message = query ? (
|
||||
<Wrapper>
|
||||
<MessageWrapper>
|
||||
<Icon src="/images/check.svg" />
|
||||
<Message>Your account has been verified successfully!</Message>
|
||||
</MessageWrapper>
|
||||
<Button icon="arrow-left" onClick={goToHomepage}>
|
||||
Back to homepage
|
||||
</Button>
|
||||
</Wrapper>
|
||||
) : (
|
||||
<MessageWrapper>
|
||||
<Icon src="/images/x.svg" />
|
||||
<Message>Invalid verification.</Message>
|
||||
</MessageWrapper>
|
||||
);
|
||||
return <BodyWrapper norenew>{message}</BodyWrapper>;
|
||||
};
|
||||
|
||||
Verify.getInitialProps = ({ store, query }) => {
|
||||
if (query && query.token) {
|
||||
store.dispatch(authUser(query.token));
|
||||
store.dispatch(authRenew());
|
||||
return { query };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
Verify.propTypes = {
|
||||
query: PropTypes.shape({
|
||||
token: PropTypes.string,
|
||||
}),
|
||||
showLoading: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
Verify.defaultProps = {
|
||||
query: null,
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
showLoading: bindActionCreators(showPageLoading, dispatch),
|
||||
});
|
||||
|
||||
export default withRedux(initialState, null, mapDispatchToProps)(Verify);
|
191
client/reducers/index.js
Normal file
191
client/reducers/index.js
Normal file
@ -0,0 +1,191 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import * as types from '../actions/actionTypes';
|
||||
|
||||
const initialState = {
|
||||
list: [],
|
||||
isShortened: false,
|
||||
count: 10,
|
||||
countAll: 0,
|
||||
page: 1,
|
||||
search: '',
|
||||
};
|
||||
|
||||
const url = (state = initialState, action) => {
|
||||
const { count, page, search } = action.payload || {};
|
||||
const isSearch = typeof search !== 'undefined';
|
||||
switch (action.type) {
|
||||
case types.ADD_URL:
|
||||
return { ...state, isShortened: true, list: [action.payload, ...state.list] };
|
||||
case types.UPDATE_URL_LIST:
|
||||
return Object.assign({}, state, count && { count }, page && { page }, isSearch && { search });
|
||||
case types.LIST_URLS:
|
||||
return {
|
||||
...state,
|
||||
list: action.payload.list,
|
||||
countAll: action.payload.countAll,
|
||||
isShortened: false,
|
||||
};
|
||||
case types.DELETE_URL:
|
||||
return { ...state, list: state.list.filter(item => item.id !== action.payload) };
|
||||
case types.UNAUTH_USER:
|
||||
return initialState;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
/* All errors */
|
||||
const errorInitialState = {
|
||||
auth: '',
|
||||
domain: '',
|
||||
shortener: '',
|
||||
};
|
||||
|
||||
const error = (state = errorInitialState, action) => {
|
||||
switch (action.type) {
|
||||
case types.SHORTENER_ERROR:
|
||||
return { ...state, shortener: action.payload };
|
||||
case types.DOMAIN_ERROR:
|
||||
return { ...state, domain: action.payload };
|
||||
case types.SET_DOMAIN:
|
||||
return { ...state, domain: '' };
|
||||
case types.SHOW_DOMAIN_INPUT:
|
||||
return { ...state, domain: '' };
|
||||
case types.ADD_URL:
|
||||
return { ...state, shortener: '' };
|
||||
case types.UPDATE_URL:
|
||||
return { ...state, urlOptions: '' };
|
||||
case types.AUTH_ERROR:
|
||||
return { ...state, auth: action.payload };
|
||||
case types.AUTH_USER:
|
||||
return { ...state, auth: '' };
|
||||
case types.HIDE_PAGE_LOADING:
|
||||
return {
|
||||
...state,
|
||||
auth: '',
|
||||
shortener: '',
|
||||
urlOptions: '',
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
/* All loadings */
|
||||
const loadingInitialState = {
|
||||
api: false,
|
||||
domain: false,
|
||||
shortener: false,
|
||||
login: false,
|
||||
page: false,
|
||||
table: false,
|
||||
signup: false,
|
||||
};
|
||||
|
||||
const loading = (state = loadingInitialState, action) => {
|
||||
switch (action.type) {
|
||||
case types.SHOW_PAGE_LOADING:
|
||||
return { ...state, page: true };
|
||||
case types.HIDE_PAGE_LOADING:
|
||||
return {
|
||||
shortener: false,
|
||||
login: false,
|
||||
page: false,
|
||||
signup: false,
|
||||
};
|
||||
case types.TABLE_LOADING:
|
||||
return { ...state, table: true };
|
||||
case types.LOGIN_LOADING:
|
||||
return { ...state, login: true };
|
||||
case types.SIGNUP_LOADING:
|
||||
return { ...state, signup: true };
|
||||
case types.SHORTENER_LOADING:
|
||||
return { ...state, shortener: true };
|
||||
case types.ADD_URL:
|
||||
return { ...state, shortener: false };
|
||||
case types.SHORTENER_ERROR:
|
||||
return { ...state, shortener: false };
|
||||
case types.LIST_URLS:
|
||||
return { ...state, table: false };
|
||||
case types.DELETE_URL:
|
||||
return { ...state, table: false };
|
||||
case types.AUTH_ERROR:
|
||||
return { ...state, login: false, signup: false };
|
||||
case types.AUTH_USER:
|
||||
return { ...state, login: false, signup: false };
|
||||
case types.DOMAIN_LOADING:
|
||||
return { ...state, domain: true };
|
||||
case types.SET_DOMAIN:
|
||||
return { ...state, domain: false };
|
||||
case types.DOMAIN_ERROR:
|
||||
return { ...state, domain: false };
|
||||
case types.API_LOADING:
|
||||
return { ...state, api: true };
|
||||
case types.SET_APIKEY:
|
||||
return { ...state, api: false };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
/* User authentication */
|
||||
const authInitialState = {
|
||||
isAuthenticated: false,
|
||||
sentVerification: false,
|
||||
user: '',
|
||||
renew: false,
|
||||
};
|
||||
|
||||
const auth = (state = authInitialState, action) => {
|
||||
switch (action.type) {
|
||||
case types.AUTH_USER:
|
||||
return {
|
||||
...state,
|
||||
isAuthenticated: true,
|
||||
user: action.payload,
|
||||
sentVerification: false,
|
||||
};
|
||||
case types.AUTH_RENEW:
|
||||
return { ...state, renew: true };
|
||||
case types.UNAUTH_USER:
|
||||
return authInitialState;
|
||||
case types.SENT_VERIFICATION:
|
||||
return { ...state, sentVerification: true, user: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
/* Settings */
|
||||
const settingsInitialState = {
|
||||
apikey: '',
|
||||
customDomain: '',
|
||||
domainInput: true,
|
||||
};
|
||||
|
||||
const settings = (state = settingsInitialState, action) => {
|
||||
switch (action.type) {
|
||||
case types.SET_DOMAIN:
|
||||
return { ...state, customDomain: action.payload, domainInput: false };
|
||||
case types.SET_APIKEY:
|
||||
return { ...state, apikey: action.payload };
|
||||
case types.DELETE_DOMAIN:
|
||||
return { ...state, customDomain: '', domainInput: true };
|
||||
case types.SHOW_DOMAIN_INPUT:
|
||||
return { ...state, domainInput: true };
|
||||
case types.UNAUTH_USER:
|
||||
return settingsInitialState;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
auth,
|
||||
error,
|
||||
loading,
|
||||
settings,
|
||||
url,
|
||||
});
|
||||
|
||||
export default rootReducer;
|
9
client/store/index.js
Normal file
9
client/store/index.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { createStore, applyMiddleware } from 'redux';
|
||||
import { composeWithDevTools } from 'redux-devtools-extension';
|
||||
import thunk from 'redux-thunk';
|
||||
import rootReducer from '../reducers';
|
||||
|
||||
const store = initialState =>
|
||||
createStore(rootReducer, initialState, composeWithDevTools(applyMiddleware(thunk)));
|
||||
|
||||
export default store;
|
9
client/store/store.dev.js
Normal file
9
client/store/store.dev.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { createStore, applyMiddleware } from 'redux';
|
||||
import { composeWithDevTools } from 'redux-devtools-extension';
|
||||
import thunk from 'redux-thunk';
|
||||
import rootReducer from '../reducers';
|
||||
|
||||
const store = initialState =>
|
||||
createStore(rootReducer, initialState, composeWithDevTools(applyMiddleware(thunk)));
|
||||
|
||||
export default store;
|
7
client/store/store.prod.js
Normal file
7
client/store/store.prod.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { createStore, applyMiddleware } from 'redux';
|
||||
import thunk from 'redux-thunk';
|
||||
import rootReducer from '../reducers';
|
||||
|
||||
const store = initialState => createStore(rootReducer, initialState, applyMiddleware(thunk));
|
||||
|
||||
export default store;
|
7665
package-lock.json
generated
Normal file
7665
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
86
package.json
Normal file
86
package.json
Normal file
@ -0,0 +1,86 @@
|
||||
{
|
||||
"name": "kutt",
|
||||
"version": "1.0.0",
|
||||
"description": "Modern URL shortener.",
|
||||
"main": "./server/server.js",
|
||||
"scripts": {
|
||||
"dev": "node ./server/server.js",
|
||||
"build": "next build ./client",
|
||||
"start": "NODE_ENV=production node ./server/server.js",
|
||||
"lint": "./node_modules/.bin/eslint . --fix",
|
||||
"lint:nofix": "./node_modules/.bin/eslint ."
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run lint:nofix"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/TheDevs-Network/kutt.git"
|
||||
},
|
||||
"keywords": [
|
||||
"url-shortener"
|
||||
],
|
||||
"author": "Pouria Ezzati <ezzati.upt@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/TheDevs-Network/kutt/issues"
|
||||
},
|
||||
"homepage": "https://github.com/TheDevs-Network/kutt#readme",
|
||||
"dependencies": {
|
||||
"axios": "^0.17.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"body-parser": "^1.18.2",
|
||||
"cookie-parser": "^1.4.3",
|
||||
"date-fns": "^1.29.0",
|
||||
"email-validator": "^1.1.1",
|
||||
"express": "^4.16.2",
|
||||
"express-validator": "^4.3.0",
|
||||
"geoip-lite": "^1.2.1",
|
||||
"helmet": "^3.9.0",
|
||||
"js-cookie": "^2.2.0",
|
||||
"jsonwebtoken": "^8.1.0",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"lodash": "^4.17.4",
|
||||
"morgan": "^1.9.0",
|
||||
"nanoid": "^1.0.1",
|
||||
"neo4j-driver": "^1.5.2",
|
||||
"next": "^5.0.0",
|
||||
"next-redux-wrapper": "^1.3.5",
|
||||
"nodemailer": "^4.4.1",
|
||||
"passport": "^0.4.0",
|
||||
"passport-jwt": "^3.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"passport-localapikey": "0.0.3",
|
||||
"prop-types": "^15.6.0",
|
||||
"raven": "^2.4.0",
|
||||
"react": "^16.2.0",
|
||||
"react-copy-to-clipboard": "^5.0.1",
|
||||
"react-dom": "^16.2.0",
|
||||
"react-ga": "^2.4.1",
|
||||
"react-inlinesvg": "^0.7.5",
|
||||
"react-redux": "^5.0.6",
|
||||
"recharts": "^1.0.0-beta.10",
|
||||
"redux": "^3.7.2",
|
||||
"redux-devtools-extension": "^2.13.2",
|
||||
"redux-thunk": "^2.2.0",
|
||||
"styled-components": "^3.1.6",
|
||||
"url-regex": "^4.1.1",
|
||||
"useragent": "^2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-cli": "^6.26.0",
|
||||
"babel-eslint": "^8.0.2",
|
||||
"babel-plugin-styled-components": "^1.3.0",
|
||||
"eslint": "^4.12.0",
|
||||
"eslint-config-airbnb": "^16.1.0",
|
||||
"eslint-config-prettier": "^2.9.0",
|
||||
"eslint-plugin-import": "^2.8.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.0.2",
|
||||
"eslint-plugin-prettier": "^2.6.0",
|
||||
"eslint-plugin-react": "^7.5.1",
|
||||
"husky": "^0.15.0-rc.6",
|
||||
"prettier": "^1.10.2"
|
||||
}
|
||||
}
|
30
server/config.example.js
Normal file
30
server/config.example.js
Normal file
@ -0,0 +1,30 @@
|
||||
module.exports = {
|
||||
PORT: 3000,
|
||||
|
||||
/* The domain that this website is on */
|
||||
DEFAULT_DOMAIN: 'kutt.it',
|
||||
|
||||
/* Neo4j database credential details */
|
||||
DB_URI: 'bolt://localhost',
|
||||
DB_USERNAME: '',
|
||||
DB_PASSWORD: '',
|
||||
|
||||
/* A passphrase to encrypt JWT. Use a long and secure key. */
|
||||
JWT_SECRET: 'securekey',
|
||||
|
||||
/*
|
||||
reCaptcha secret key
|
||||
Create one in https://www.google.com/recaptcha/intro/
|
||||
*/
|
||||
RECAPTCHA_SECRET_KEY: '',
|
||||
|
||||
/*
|
||||
Your email host details to use to send verification emails.
|
||||
More info on http://nodemailer.com/
|
||||
*/
|
||||
MAIL_HOST: '',
|
||||
MAIL_PORT: 587,
|
||||
MAIL_SECURE: false,
|
||||
MAIL_USER: '',
|
||||
MAIL_PASSWORD: '',
|
||||
};
|
179
server/controllers/authController.js
Normal file
179
server/controllers/authController.js
Normal file
@ -0,0 +1,179 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const passport = require('passport');
|
||||
const JWT = require('jsonwebtoken');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const transporter = require('../mail/mail');
|
||||
const { resetMailText, verifyMailText } = require('../mail/text');
|
||||
const {
|
||||
createUser,
|
||||
changePassword,
|
||||
generateApiKey,
|
||||
getUser,
|
||||
verifyUser,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
} = require('../db/user');
|
||||
|
||||
/* Read email template */
|
||||
const resetEmailTemplatePath = path.join(__dirname, '../mail/template-reset.html');
|
||||
const verifyEmailTemplatePath = path.join(__dirname, '../mail/template-verify.html');
|
||||
const resetEmailTemplate = fs.readFileSync(resetEmailTemplatePath, { encoding: 'utf-8' });
|
||||
const verifyEmailTemplate = fs.readFileSync(verifyEmailTemplatePath, { encoding: 'utf-8' });
|
||||
|
||||
/* Function to generate JWT */
|
||||
const signToken = user =>
|
||||
JWT.sign(
|
||||
{
|
||||
iss: 'ApiAuth',
|
||||
sub: user.email,
|
||||
iat: new Date().getTime(),
|
||||
exp: new Date().setDate(new Date().getDate() + 7),
|
||||
},
|
||||
config.JWT_SECRET
|
||||
);
|
||||
|
||||
/* Passport.js authentication controller */
|
||||
const authenticate = (type, error, isStrict = true) =>
|
||||
function auth(req, res, next) {
|
||||
if (req.user) return next();
|
||||
return passport.authenticate(type, (err, user) => {
|
||||
if (err) return res.status(400);
|
||||
if (!user && isStrict) return res.status(401).json({ error });
|
||||
req.user = user;
|
||||
return next();
|
||||
})(req, res, next);
|
||||
};
|
||||
|
||||
exports.authLocal = authenticate('local', 'Login email and/or password are wrong.');
|
||||
exports.authJwt = authenticate('jwt', 'Unauthorized.');
|
||||
exports.authJwtLoose = authenticate('jwt', 'Unauthorized.', false);
|
||||
exports.authApikey = authenticate('localapikey', 'API key is not correct.', false);
|
||||
|
||||
/* reCaptcha controller */
|
||||
exports.recaptcha = async (req, res, next) => {
|
||||
if (!req.user) {
|
||||
const isReCaptchaValid = await axios({
|
||||
method: 'post',
|
||||
url: 'https://www.google.com/recaptcha/api/siteverify',
|
||||
headers: {
|
||||
'Content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
params: {
|
||||
secret: config.RECAPTCHA_SECRET_KEY,
|
||||
response: req.body.reCaptchaToken,
|
||||
remoteip: req.ip,
|
||||
},
|
||||
});
|
||||
if (!isReCaptchaValid.data.success) {
|
||||
return res.status(401).json({ error: 'reCAPTCHA is not valid. Try again.' });
|
||||
}
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
exports.signup = async (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
if (password.length > 64) {
|
||||
return res.status(400).json({ error: 'Maximum password length is 64.' });
|
||||
}
|
||||
if (email.length > 64) {
|
||||
return res.status(400).json({ error: 'Maximum email length is 64.' });
|
||||
}
|
||||
const user = await getUser({ email });
|
||||
if (user && user.verified) return res.status(403).json({ error: 'Email is already in use.' });
|
||||
const newUser = await createUser({ email, password });
|
||||
const mail = await transporter.sendMail({
|
||||
from: config.MAIL_USER,
|
||||
to: newUser.email,
|
||||
subject: 'Verify your account',
|
||||
text: verifyMailText
|
||||
.replace('{{verification}}', newUser.verificationToken)
|
||||
.replace('{{domain}}', config.DEFAULT_DOMAIN),
|
||||
html: verifyEmailTemplate
|
||||
.replace('{{verification}}', newUser.verificationToken)
|
||||
.replace('{{domain}}', config.DEFAULT_DOMAIN),
|
||||
});
|
||||
if (mail.accepted.length) {
|
||||
return res.status(201).json({ email, message: 'Verification email has been sent.' });
|
||||
}
|
||||
return res.status(400).json({ error: "Couldn't send verification email. Try again." });
|
||||
};
|
||||
|
||||
exports.login = ({ user }, res) => {
|
||||
const token = signToken(user);
|
||||
return res.status(200).json({ token });
|
||||
};
|
||||
|
||||
exports.renew = ({ user }, res) => {
|
||||
const token = signToken(user);
|
||||
return res.status(200).json({ token });
|
||||
};
|
||||
|
||||
exports.verify = async (req, res, next) => {
|
||||
const { verificationToken = '' } = req.params;
|
||||
const user = await verifyUser({ verificationToken });
|
||||
if (user) {
|
||||
const token = signToken(user);
|
||||
req.user = { token };
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
exports.changePassword = async ({ body: { password }, user }, res) => {
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 chars long.' });
|
||||
}
|
||||
if (password.length > 64) {
|
||||
return res.status(400).json({ error: 'Maximum password length is 64.' });
|
||||
}
|
||||
const changedUser = await changePassword({ email: user.email, password });
|
||||
if (changedUser) {
|
||||
return res.status(200).json({ message: 'Your password has been changed successfully.' });
|
||||
}
|
||||
return res.status(400).json({ error: "Couldn't change the password. Try again later" });
|
||||
};
|
||||
|
||||
exports.generateApiKey = async ({ user }, res) => {
|
||||
const { apikey } = await generateApiKey({ email: user.email });
|
||||
if (apikey) {
|
||||
return res.status(201).json({ apikey });
|
||||
}
|
||||
return res.status(400).json({ error: 'Sorry, an error occured. Please try again later.' });
|
||||
};
|
||||
|
||||
exports.userSettings = ({ user }, res) =>
|
||||
res.status(200).json({ apikey: user.apikey || '', customDomain: user.domain || '' });
|
||||
|
||||
exports.requestPasswordReset = async ({ body: { email } }, res) => {
|
||||
const user = await requestPasswordReset({ email });
|
||||
if (!user) {
|
||||
return res.status(400).json({ error: "Couldn't reset password." });
|
||||
}
|
||||
const mail = await transporter.sendMail({
|
||||
from: config.MAIL_USER,
|
||||
to: user.email,
|
||||
subject: 'Reset your password',
|
||||
text: resetMailText
|
||||
.replace('{{resetpassword}}', user.resetPasswordToken)
|
||||
.replace('{{domain}}', config.DEFAULT_DOMAIN),
|
||||
html: resetEmailTemplate
|
||||
.replace('{{resetpassword}}', user.resetPasswordToken)
|
||||
.replace('{{domain}}', config.DEFAULT_DOMAIN),
|
||||
});
|
||||
if (mail.accepted.length) {
|
||||
return res.status(200).json({ email, message: 'Reset password email has been sent.' });
|
||||
}
|
||||
return res.status(400).json({ error: "Couldn't reset password." });
|
||||
};
|
||||
|
||||
exports.resetPassword = async (req, res, next) => {
|
||||
const { resetPasswordToken = '' } = req.params;
|
||||
const user = await resetPassword({ resetPasswordToken });
|
||||
if (user) {
|
||||
const token = signToken(user);
|
||||
req.user = { token };
|
||||
}
|
||||
return next();
|
||||
};
|
172
server/controllers/urlController.js
Normal file
172
server/controllers/urlController.js
Normal file
@ -0,0 +1,172 @@
|
||||
const urlRegex = require('url-regex');
|
||||
const URL = require('url');
|
||||
const useragent = require('useragent');
|
||||
const geoip = require('geoip-lite');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const {
|
||||
createShortUrl,
|
||||
createVisit,
|
||||
findUrl,
|
||||
getStats,
|
||||
getUrls,
|
||||
getCustomDomain,
|
||||
setCustomDomain,
|
||||
deleteCustomDomain,
|
||||
deleteUrl,
|
||||
} = require('../db/url');
|
||||
const config = require('../config');
|
||||
|
||||
const preservedUrls = [
|
||||
'login',
|
||||
'logout',
|
||||
'signup',
|
||||
'reset-password',
|
||||
'resetpassword',
|
||||
'url-password',
|
||||
'settings',
|
||||
'stats',
|
||||
'verify',
|
||||
'api',
|
||||
'404',
|
||||
'static',
|
||||
'images',
|
||||
];
|
||||
|
||||
exports.preservedUrls = preservedUrls;
|
||||
|
||||
exports.urlShortener = async ({ body, user }, res) => {
|
||||
if (!body.target) return res.status(400).json({ error: 'No target has been provided.' });
|
||||
if (body.target.length > 1024) {
|
||||
return res.status(400).json({ error: 'Maximum URL length is 1024.' });
|
||||
}
|
||||
const isValidUrl = urlRegex({ exact: true, strict: false }).test(body.target);
|
||||
if (!isValidUrl) return res.status(400).json({ error: 'URL is not valid.' });
|
||||
const target = URL.parse(body.target).protocol ? body.target : `http://${body.target}`;
|
||||
if (body.password && body.password.length > 64) {
|
||||
return res.status(400).json({ error: 'Maximum password length is 64.' });
|
||||
}
|
||||
if (user && body.customurl) {
|
||||
if (!/^[a-zA-Z1-9-_]+$/g.test(body.customurl.trim())) {
|
||||
return res.status(400).json({ error: 'Custom URL is not valid.' });
|
||||
}
|
||||
if (preservedUrls.some(url => url === body.customurl)) {
|
||||
return res.status(400).json({ error: "You can't use this custom URL name." });
|
||||
}
|
||||
if (body.customurl.length > 64) {
|
||||
return res.status(400).json({ error: 'Maximum custom URL length is 64.' });
|
||||
}
|
||||
const urls = await findUrl({ id: body.customurl || '' });
|
||||
if (urls.length) {
|
||||
const urlWithNoDomain = !user.domain && urls.some(url => !url.domain);
|
||||
const urlWithDmoain = user.domain && urls.some(url => url.domain === user.domain);
|
||||
if (urlWithNoDomain || urlWithDmoain) {
|
||||
return res.status(400).json({ error: 'Custom URL is already in use.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
const url = await createShortUrl({ ...body, target, user });
|
||||
return res.json(url);
|
||||
};
|
||||
|
||||
const browsersList = ['IE', 'Firefox', 'Chrome', 'Opera', 'Safari', 'Edge'];
|
||||
const osList = ['Windows', 'Mac Os X', 'Linux', 'Chrom OS', 'Android', 'iOS'];
|
||||
const filterInBrowser = agent => item =>
|
||||
agent.family.toLowerCase().includes(item.toLocaleLowerCase());
|
||||
const filterInOs = agent => item =>
|
||||
agent.os.family.toLowerCase().includes(item.toLocaleLowerCase());
|
||||
|
||||
exports.goToUrl = async (req, res, next) => {
|
||||
const { host } = req.headers;
|
||||
const id = req.params.id || req.body.id;
|
||||
const domain = host !== config.DEFAULT_DOMAIN && host;
|
||||
const agent = useragent.parse(req.headers['user-agent']);
|
||||
const [browser = 'Other'] = browsersList.filter(filterInBrowser(agent));
|
||||
const [os = 'Other'] = osList.filter(filterInOs(agent));
|
||||
const referrer = req.header('Referer') && URL.parse(req.header('Referer')).hostname;
|
||||
const location = geoip.lookup(req.ip);
|
||||
const country = location && location.country;
|
||||
const urls = await findUrl({ id, domain });
|
||||
if (!urls && !urls.length) return next();
|
||||
const [url] = urls;
|
||||
if (url.password && !req.body.password) {
|
||||
req.protectedUrl = id;
|
||||
return next();
|
||||
}
|
||||
if (url.password) {
|
||||
const isMatch = await bcrypt.compare(req.body.password, url.password);
|
||||
if (!isMatch) {
|
||||
return res.status(401).json({ error: 'Password is not correct' });
|
||||
}
|
||||
if (url.user) {
|
||||
await createVisit({
|
||||
browser,
|
||||
country: country || 'Unknown',
|
||||
domain,
|
||||
id: url.id,
|
||||
os,
|
||||
referrer: referrer || 'Direct',
|
||||
});
|
||||
}
|
||||
return res.status(200).json({ target: url.target });
|
||||
}
|
||||
if (url.user) {
|
||||
await createVisit({
|
||||
browser,
|
||||
country,
|
||||
domain,
|
||||
id: url.id,
|
||||
os,
|
||||
referrer,
|
||||
});
|
||||
}
|
||||
return res.redirect(url.target);
|
||||
};
|
||||
|
||||
exports.getUrls = async ({ body, user }, res) => {
|
||||
const urlsList = await getUrls({ options: body, user });
|
||||
return res.json(urlsList);
|
||||
};
|
||||
|
||||
exports.setCustomDomain = async ({ body: { customDomain }, user }, res) => {
|
||||
if (customDomain.length > 40) {
|
||||
return res.status(400).json({ error: 'Maximum custom domain length is 40.' });
|
||||
}
|
||||
if (customDomain === config.DEFAULT_DOMAIN) {
|
||||
return res.status(400).json({ error: "You can't use default domain." });
|
||||
}
|
||||
const isValidDomain = urlRegex({ exact: true, strict: false }).test(customDomain);
|
||||
if (!isValidDomain) return res.status(400).json({ error: 'Domain is not valid.' });
|
||||
const isOwned = await getCustomDomain({ customDomain });
|
||||
if (isOwned && isOwned.email !== user.email) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Domain is already taken. Contact us for multiple users.' });
|
||||
}
|
||||
const userCustomDomain = await setCustomDomain({ user, customDomain });
|
||||
if (userCustomDomain) return res.status(201).json({ customDomain: userCustomDomain.name });
|
||||
return res.status(400).json({ error: "Couldn't set custom domain." });
|
||||
};
|
||||
|
||||
exports.deleteCustomDomain = async ({ user }, res) => {
|
||||
const response = await deleteCustomDomain({ user });
|
||||
if (response) return res.status(200).json({ message: 'Domain deleted successfully' });
|
||||
return res.status(400).json({ error: "Couldn't delete custom domain." });
|
||||
};
|
||||
|
||||
exports.deleteUrl = async ({ body: { id, domain }, user }, res) => {
|
||||
if (!id) return res.status(400).json({ error: 'No id has been provided.' });
|
||||
const customDomain = domain !== config.DEFAULT_DOMAIN && domain;
|
||||
const urls = await findUrl({ id, domain: customDomain });
|
||||
if (!urls && !urls.length) return res.status(400).json({ error: "Couldn't find the short URL." });
|
||||
const response = await deleteUrl({ id, domain: customDomain, user });
|
||||
if (response) return res.status(200).json({ message: 'Sort URL deleted successfully' });
|
||||
return res.status(400).json({ error: "Couldn't delete short URL." });
|
||||
};
|
||||
|
||||
exports.getStats = async ({ body: { id, domain }, user }, res) => {
|
||||
if (!id) return res.status(400).json({ error: 'No id has been provided.' });
|
||||
const customDomain = domain !== config.DEFAULT_DOMAIN && domain;
|
||||
const stats = await getStats({ id, domain: customDomain, user });
|
||||
if (!stats) return res.status(400).json({ error: 'Could not get the short URL stats.' });
|
||||
return res.status(200).json(stats);
|
||||
};
|
27
server/controllers/validateBodyController.js
Normal file
27
server/controllers/validateBodyController.js
Normal file
@ -0,0 +1,27 @@
|
||||
const { body } = require('express-validator/check');
|
||||
const { validationResult } = require('express-validator/check');
|
||||
|
||||
exports.validationCriterias = [
|
||||
body('email')
|
||||
.exists()
|
||||
.withMessage('Email must be provided.')
|
||||
.isEmail()
|
||||
.withMessage('Email is not valid.')
|
||||
.trim()
|
||||
.normalizeEmail(),
|
||||
body('password', 'Password must be at least 8 chars long.')
|
||||
.exists()
|
||||
.withMessage('Password must be provided.')
|
||||
.isLength({ min: 8 }),
|
||||
];
|
||||
|
||||
exports.validateBody = (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
const errorsObj = errors.mapped();
|
||||
const emailError = errorsObj.email && errorsObj.email.msg;
|
||||
const passwordError = errorsObj.password && errorsObj.password.msg;
|
||||
return res.status(400).json({ error: emailError || passwordError });
|
||||
}
|
||||
return next();
|
||||
};
|
9
server/db/neo4j.js
Normal file
9
server/db/neo4j.js
Normal file
@ -0,0 +1,9 @@
|
||||
const neo4j = require('neo4j-driver').v1;
|
||||
const config = require('../config');
|
||||
|
||||
const driver = neo4j.driver(
|
||||
config.DB_URI,
|
||||
neo4j.auth.basic(config.DB_USERNAME, config.DB_PASSWORD)
|
||||
);
|
||||
|
||||
module.exports = driver;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user