This commit is contained in:
Pouria Ezzati 2018-02-13 16:04:29 +03:30
commit 6af694826d
134 changed files with 14933 additions and 0 deletions

4
.babelrc Normal file
View File

@ -0,0 +1,4 @@
{
"presets": ["next/babel", "env"],
"plugins": [["styled-components", { "ssr": true, "displayName": true, "preprocess": false }]]
}

1
.env Normal file
View File

@ -0,0 +1 @@
TEST=test

3
.eslintignore Normal file
View File

@ -0,0 +1,3 @@
.next/
flow-typed/
node_modules/

34
.eslintrc Normal file
View 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
View File

@ -0,0 +1,5 @@
.vscode/
client/.next/
node_modules/
client/config.js
server/config.js

12
.travis.yml Normal file
View 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
View 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
View 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)
[![Build Status](https://travis-ci.org/thedevs-network/kutt.svg?branch=develop)](https://travis-ci.org/thedevs-network/kutt)
[![Contributions](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](https://github.com/thedevs-network/kutt/#contributing)
[![GitHub license](https://img.shields.io/github/license/thedevs-network/kutt.svg)](https://github.com/thedevs-network/kutt/blob/develop/LICENSE)
[![Twitter](https://img.shields.io/twitter/url/https/github.com/thedevs-network/kutt/.svg?style=social)](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.

View 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
View 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());
});
};

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

View File

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

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

View File

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

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

View File

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

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

View File

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

View 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 &amp; open source" icon="heart">
Completely open source and free. You can host it on your own server.
</FeaturesItem>
</Wrapper>
</Section>
);
export default Features;

View File

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

View File

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

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

View File

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

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

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

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

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

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

View File

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

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

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

View File

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

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

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

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

View File

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

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

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

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

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

View File

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

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

View File

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

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

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

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

View File

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

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

View File

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

10
client/config.example.js Normal file
View 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',
};

View 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 });
}
};

View 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);
}
`;

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

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

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

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

View 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

File diff suppressed because it is too large Load Diff

86
package.json Normal file
View 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
View 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: '',
};

View 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();
};

View 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);
};

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