Merge pull request #8 from acelaya/develop

Develop
This commit is contained in:
Alejandro Celaya 2018-08-05 12:13:30 +02:00 committed by GitHub
commit 4e4321c96d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
86 changed files with 10323 additions and 124 deletions

2
.gitignore vendored
View file

@ -20,3 +20,5 @@ package-lock.json
npm-debug.log*
yarn-debug.log*
yarn-error.log*
docker-compose.override.yml

19
.travis.yml Normal file
View file

@ -0,0 +1,19 @@
language: node_js
node_js:
- "stable"
cache:
yarn: true
directories:
- node_modules
install:
- yarn install
script:
# - yarn inspect
- yarn test
- yarn build # Make sure the app can be built without errors
sudo: false

12
Dockerfile Normal file
View file

@ -0,0 +1,12 @@
FROM node:10.4.1-alpine
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
# Install yarn
RUN apk add --no-cache --virtual yarn
# Make home dir writable by anyone
RUN chmod 777 /home
CMD cd /home/shlink/www && \
yarn install && \
yarn start

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 shlinkio
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.

View file

@ -1,5 +1,5 @@
# shlink-web-client
[![Build Status](https://travis-ci.org/shlinkio/shlink-web-client.svg?branch=develop)](https://travis-ci.org/shlinkio/shlink-web-client)
[![Build Status](https://travis-ci.org/shlinkio/shlink-web-client.svg?branch=master)](https://travis-ci.org/shlinkio/shlink-web-client)
A React-based client application for [Shlink](https://shlink.io)

4
config/setupEnzyme.js Normal file
View file

@ -0,0 +1,4 @@
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });

View file

@ -23,6 +23,27 @@ const publicUrl = '';
// Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl);
const postCssLoader = {
loader: require.resolve('postcss-loader'),
options: {
// Necessary for external CSS imports to work
// https://github.com/facebookincubator/create-react-app/issues/2677
ident: 'postcss',
plugins: () => [
require('postcss-flexbugs-fixes'),
autoprefixer({
browsers: [
'>1%',
'last 4 versions',
'Firefox ESR',
'not ie < 9', // React doesn't support IE8 anyway
],
flexbox: 'no-2009',
}),
],
},
};
// This is the development configuration.
// It is focused on developer experience and fast rebuilds.
// The production configuration is different and lives in a separate file.
@ -167,33 +188,14 @@ module.exports = {
importLoaders: 1,
},
},
{
loader: require.resolve('postcss-loader'),
options: {
// Necessary for external CSS imports to work
// https://github.com/facebookincubator/create-react-app/issues/2677
ident: 'postcss',
plugins: () => [
require('postcss-flexbugs-fixes'),
autoprefixer({
browsers: [
'>1%',
'last 4 versions',
'Firefox ESR',
'not ie < 9', // React doesn't support IE8 anyway
],
flexbox: 'no-2009',
}),
],
},
},
postCssLoader,
],
},
{
test: /\.scss$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: ['css-loader', 'sass-loader']
use: ['css-loader', 'sass-loader', postCssLoader]
})
},
// "file" loader makes sure those assets get served by WebpackDevServer.
@ -251,7 +253,7 @@ module.exports = {
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
// You can remove this if you don't use Moment.js:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
new ExtractTextPlugin('main.[hash:8].css'),
new ExtractTextPlugin('main.css'),
],
// Some libraries import Node modules but don't use them in the browser.
// Tell Webpack to provide empty mocks for them so importing them works.

2
dist/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

View file

@ -0,0 +1,8 @@
version: '3'
services:
shlink_web_client_node:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro

13
docker-compose.yml Normal file
View file

@ -0,0 +1,13 @@
version: '3'
services:
shlink_web_client_node:
container_name: shlink_web_client_node
build:
context: .
dockerfile: ./Dockerfile
volumes:
- ./:/home/shlink/www
ports:
- "3000:3000"
- "56745:56745"

2
indocker Executable file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env bash
docker exec -it shlink_web_client_node /bin/sh -c "cd /home/shlink/www && $*"

View file

@ -1,8 +1,41 @@
{
"name": "shlink-web-client-react",
"description": "A React-based web client for shlink",
"version": "0.1.0",
"private": true,
"private": false,
"scripts": {
"start": "node scripts/start.js",
"build": "node scripts/build.js",
"test": "node scripts/test.js --env=jsdom"
},
"dependencies": {
"@fortawesome/fontawesome": "^1.1.8",
"@fortawesome/fontawesome-free-regular": "^5.0.13",
"@fortawesome/fontawesome-free-solid": "^5.0.13",
"@fortawesome/react-fontawesome": "0.0.19",
"axios": "^0.18.0",
"bootstrap": "^4.1.1",
"chart.js": "^2.7.2",
"moment": "^2.22.2",
"promise": "8.0.1",
"qs": "^6.5.2",
"ramda": "^0.25.0",
"react": "^16.3.2",
"react-chartjs-2": "^2.7.4",
"react-copy-to-clipboard": "^5.0.1",
"react-datepicker": "^1.5.0",
"react-dom": "^16.3.2",
"react-moment": "^0.7.6",
"react-redux": "^5.0.7",
"react-router-dom": "^4.2.2",
"react-tag-autocomplete": "^5.5.1",
"reactstrap": "^6.0.1",
"redux": "^4.0.0",
"redux-thunk": "^2.3.0",
"uuid": "^3.3.2"
},
"devDependencies": {
"adm-zip": "^0.4.11",
"autoprefixer": "7.1.6",
"babel-core": "6.26.0",
"babel-eslint": "7.2.3",
@ -15,6 +48,8 @@
"css-loader": "0.28.7",
"dotenv": "4.0.0",
"dotenv-expand": "4.2.0",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"eslint": "4.10.0",
"eslint-config-react-app": "^2.1.0",
"eslint-loader": "1.9.0",
@ -22,19 +57,19 @@
"eslint-plugin-import": "2.8.0",
"eslint-plugin-jsx-a11y": "5.1.1",
"eslint-plugin-react": "7.4.0",
"extract-text-webpack-plugin": "^3.0.2",
"file-loader": "1.1.5",
"fs-extra": "3.0.1",
"html-webpack-plugin": "2.29.0",
"jest": "20.0.4",
"node-sass": "^4.9.0",
"object-assign": "4.1.1",
"postcss-flexbugs-fixes": "3.2.0",
"postcss-loader": "2.0.8",
"promise": "8.0.1",
"raf": "3.4.0",
"react": "^16.3.2",
"react-dev-utils": "^5.0.1",
"react-dom": "^16.3.2",
"resolve": "1.6.0",
"sass-loader": "^7.0.1",
"style-loader": "0.19.0",
"sw-precache-webpack-plugin": "0.11.4",
"url-loader": "0.6.2",
@ -43,26 +78,16 @@
"webpack-manifest-plugin": "1.3.2",
"whatwg-fetch": "2.0.3"
},
"scripts": {
"start": "node scripts/start.js",
"build": "node scripts/build.js",
"test": "node scripts/test.js --env=jsdom"
},
"devDependencies": {
"extract-text-webpack-plugin": "^3.0.2",
"node-sass": "^4.9.0",
"sass-loader": "^7.0.1"
},
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx,mjs}"
],
"setupFiles": [
"<rootDir>/config/polyfills.js"
"<rootDir>/config/polyfills.js",
"<rootDir>/config/setupEnzyme.js"
],
"testMatch": [
"<rootDir>/src/**/__tests__/**/*.{js,jsx,mjs}",
"<rootDir>/src/**/?(*.)(spec|test).{js,jsx,mjs}"
"<rootDir>/test/**/*.test.{js,jsx,mjs}"
],
"testEnvironment": "node",
"testURL": "http://localhost",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/icons/shlink-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
public/icons/shlink-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 B

BIN
public/icons/shlink-24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

BIN
public/icons/shlink-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
public/icons/shlink-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -2,12 +2,17 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#4696e5">
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="apple-touch-icon" href="%PUBLIC_URL%/icons/shlink-128.png">
<link rel="apple-touch-icon" sizes="64x64" href="%PUBLIC_URL%/icons/shlink-64.png">
<link rel="apple-touch-icon" sizes="32x32" href="%PUBLIC_URL%/icons/shlink-32.png">
<link rel="apple-touch-icon" sizes="24x24" href="%PUBLIC_URL%/icons/shlink-24.png">
<link rel="apple-touch-icon" sizes="16x16" href="%PUBLIC_URL%/icons/shlink-16.png">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<!--
@ -19,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<title>Shlink — The URL shortener</title>
</head>
<body>
<noscript>

View file

@ -1,15 +1,35 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"short_name": "Shlink",
"name": "Shlink web client",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
"src": "./icons/shlink-128.png",
"type": "image/png",
"sizes": "128x128"
},
{
"src": "./icons/shlink-64.png",
"type": "image/png",
"sizes": "64x64"
},
{
"src": "./icons/shlink-32.png",
"type": "image/png",
"sizes": "32x32"
},
{
"src": "./icons/shlink-24.png",
"type": "image/png",
"sizes": "24x24"
},
{
"src": "./icons/shlink-16.png",
"type": "image/png",
"sizes": "16x16"
}
],
"start_url": "./index.html",
"start_url": "/",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
"theme_color": "#4696e5",
"background_color": "#4696e5"
}

View file

@ -25,6 +25,7 @@ const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
const printBuildError = require('react-dev-utils/printBuildError');
const AdmZip = require('adm-zip');
const measureFileSizesBeforeBuild =
FileSizeReporter.measureFileSizesBeforeBuild;
@ -98,7 +99,8 @@ measureFileSizesBeforeBuild(paths.appBuild)
printBuildError(err);
process.exit(1);
}
);
)
.then(zipDist);
// Create the production build and print the deployment instructions.
function build(previousFileSizes) {
@ -148,3 +150,28 @@ function copyPublicFolder() {
filter: file => file !== paths.appHtml,
});
}
function zipDist() {
// If no version was provided, do nothing
if (process.argv.length < 3) {
return;
}
const version = process.argv[2];
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
const zip = new AdmZip();
try {
if (fs.existsSync(versionFileName)) {
fs.unlink(versionFileName);
}
zip.addLocalFolder('./build', `shlink-web-client_${version}_dist`);
zip.writeZip(versionFileName);
console.log(chalk.green('Dist file properly generated'));
} catch (e) {
console.log(chalk.red('An error occurred while generating dist file'));
console.log(e);
}
}

View file

@ -15,13 +15,8 @@ process.on('unhandledRejection', err => {
// Ensure environment variables are read.
require('../config/env');
// Make tests to be matched inside tests folder
const jest = require('jest');
let argv = process.argv.slice(2);
// Watch unless on CI or in coverage mode
if (!process.env.CI && argv.indexOf('--coverage') < 0) {
argv.push('--watch');
}
jest.run(argv);

View file

@ -1,19 +1,25 @@
import React from 'react';
import logo from './logo.svg';
import { Route, Switch } from 'react-router-dom';
import './App.scss';
import Home from './common/Home';
import MainHeader from './common/MainHeader';
import MenuLayout from './common/MenuLayout';
import CreateServer from './servers/CreateServer';
export default class App extends React.Component {
render() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo"/>
<h1 className="App-title">Welcome to React</h1>
</header>
<p className="App-intro">
To get started, edit <code>src/App.js</code> and save to reload.
</p>
</div>
);
}
render() {
return (
<div className="container-fluid">
<MainHeader/>
<div className="app">
<Switch>
<Route exact path="/server/create" component={CreateServer} />
<Route exact path="/" component={Home} />
<Route path="/server/:serverId" component={MenuLayout} />
</Switch>
</div>
</div>
);
}
}

View file

@ -1,32 +1,5 @@
.App {
text-align: center;
}
@import './utils/base';
.App-logo {
animation: App-logo-spin infinite 20s linear;
height: 80px;
}
.App-header {
background-color: #222;
height: 150px;
padding: 20px;
color: white;
}
.App-title {
font-size: 1.5em;
}
.App-intro {
font-size: large;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
.app {
padding-top: $headerHeight;
}

View file

@ -1,9 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});

View file

@ -0,0 +1,88 @@
import axios from 'axios';
import qs from 'qs';
import { isEmpty, isNil, reject } from 'ramda';
const API_VERSION = '1';
export class ShlinkApiClient {
constructor(axios) {
this.axios = axios;
this._baseUrl = '';
this._apiKey = '';
this._token = '';
}
/**
* Sets the base URL to be used on any request
* @param {String} baseUrl
* @param {String} apiKey
*/
setConfig = ({ url, apiKey }) => {
this._baseUrl = `${url}/rest/v${API_VERSION}`;
this._apiKey = apiKey;
};
listShortUrls = (options = {}) =>
this._performRequest('/short-codes', 'GET', options)
.then(resp => resp.data.shortUrls)
.catch(e => this._handleAuthError(e, this.listShortUrls, [options]));
createShortUrl = options => {
const filteredOptions = reject(value => isEmpty(value) || isNil(value), options);
return this._performRequest('/short-codes', 'POST', {}, filteredOptions)
.then(resp => resp.data)
.catch(e => this._handleAuthError(e, this.createShortUrl, [filteredOptions]));
};
getShortUrlVisits = (shortCode, dates) =>
this._performRequest(`/short-codes/${shortCode}/visits`, 'GET', dates)
.then(resp => resp.data.visits.data)
.catch(e => this._handleAuthError(e, this.getShortUrlVisits, [shortCode, dates]));
getShortUrl = shortCode =>
this._performRequest(`/short-codes/${shortCode}`, 'GET')
.then(resp => resp.data)
.catch(e => this._handleAuthError(e, this.getShortUrl, [shortCode]));
_performRequest = async (url, method = 'GET', params = {}, data = {}) => {
if (isEmpty(this._token)) {
this._token = await this._authenticate();
}
return await this.axios({
method,
url: `${this._baseUrl}${url}`,
headers: { 'Authorization': `Bearer ${this._token}` },
params,
data,
paramsSerializer: params => qs.stringify(params, { arrayFormat: 'brackets' })
}).then(resp => {
// Save new token
const { authorization = '' } = resp.headers;
this._token = authorization.substr('Bearer '.length);
return resp;
});
};
_authenticate = async () => {
const resp = await this.axios({
method: 'POST',
url: `${this._baseUrl}/authenticate`,
data: { apiKey: this._apiKey }
});
return resp.data.token;
};
_handleAuthError = (e, method, args) => {
// If auth failed, reset token to force it to be regenerated, and perform a new request
if (e.response.status === 401) {
this._token = '';
return method(...args);
}
// Otherwise, let caller handle the rejection
return Promise.reject(e);
};
}
export default new ShlinkApiClient(axios);

46
src/common/AsideMenu.js Normal file
View file

@ -0,0 +1,46 @@
import listIcon from '@fortawesome/fontawesome-free-solid/faBars';
import createIcon from '@fortawesome/fontawesome-free-solid/faPlus';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react';
import { NavLink } from 'react-router-dom';
import DeleteServerButton from '../servers/DeleteServerButton';
import './AsideMenu.scss';
export default function AsideMenu({ selectedServer, history }) {
const serverId = selectedServer ? selectedServer.id : '';
const isListShortUrlsActive = (match, { pathname }) => {
// FIXME. Should use the 'match' params, but they are not being properly resolved. Investigate
const serverIdFromPathname = pathname.split('/')[2];
return serverIdFromPathname === serverId && pathname.indexOf('list-short-urls') !== -1;
};
return (
<aside className="aside-menu col-lg-2 col-md-3">
<nav className="nav flex-column aside-menu__nav">
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/list-short-urls/1`}
isActive={isListShortUrlsActive}
>
<FontAwesomeIcon icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span>
</NavLink>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/create-short-url`}
>
<FontAwesomeIcon icon={createIcon} />
<span className="aside-menu__item-text">Create short URL</span>
</NavLink>
<DeleteServerButton
className="aside-menu__item aside-menu__item--danger"
history={history}
server={selectedServer}
/>
</nav>
</aside>
);
}

63
src/common/AsideMenu.scss Normal file
View file

@ -0,0 +1,63 @@
@import '../utils/base';
.aside-menu {
background-color: #f7f7f7;
padding-top: 10px;
padding-bottom: 10px;
@media (min-width: $mdMin) {
position: fixed !important;
top: $headerHeight;
bottom: 0;
left: 0;
z-index: 1000;
display: block;
padding: 30px 15px 15px;
overflow-x: hidden;
overflow-y: auto;
border-right: 1px solid #eee;
}
}
.aside-menu__nav {
@media(min-width: $smMin) {
height: 100%;
}
}
.aside-menu__item {
padding: 10px 20px;
margin: 0 -15px;
text-decoration: none !important;
cursor: pointer;
}
.aside-menu__item:hover {
background-color: $lightHoverColor;
}
.aside-menu__item--selected {
color: #fff;
background-color: $mainColor;
}
.aside-menu__item--selected:hover {
color: #fff;
background-color: $mainColor;
}
.aside-menu__item--divider {
border-bottom: 1px solid #eee;
margin: 20px 0;
}
.aside-menu__item--danger {
color: $dangerColor;
margin-top: auto;
}
.aside-menu__item--danger:hover {
color: #fff;
background-color: $dangerColor;
}
.aside-menu__item-text {
margin-left: 8px;
}

31
src/common/DateInput.js Normal file
View file

@ -0,0 +1,31 @@
import calendarIcon from '@fortawesome/fontawesome-free-regular/faCalendarAlt';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react';
import DatePicker from 'react-datepicker';
import './DateInput.scss';
export default class DateInput extends React.Component {
constructor(props) {
super(props);
this.inputRef = React.createRef();
}
render() {
return (
<div className="date-input-container">
<DatePicker
{...this.props}
className={`date-input-container__input form-control ${this.props.className || ''}`}
dateFormat="YYYY-MM-DD"
readOnly
ref={this.inputRef}
/>
<FontAwesomeIcon
icon={calendarIcon}
className="date-input-container__icon"
onClick={() => this.inputRef.current.input.focus()}
/>
</div>
);
}
}

16
src/common/DateInput.scss Normal file
View file

@ -0,0 +1,16 @@
@import '../utils/mixins/vertical-align';
.date-input-container {
position: relative;
}
.date-input-container__input {
padding-right: 35px !important;
background-color: #fff !important;
}
.date-input-container__icon {
@include vertical-align();
right: 15px;
cursor: pointer;
}

21
src/common/Home.js Normal file
View file

@ -0,0 +1,21 @@
import React from 'react';
import { connect } from 'react-redux';
import './Home.scss';
import { resetSelectedServer } from '../servers/reducers/selectedServer';
export class Home extends React.Component {
componentDidMount() {
this.props.resetSelectedServer();
}
render() {
return (
<div className="home-container">
<h1 className="home-container__title">Welcome to Shlink</h1>
<h5 className="home-container__intro">Please, select a server.</h5>
</div>
);
}
}
export default connect(null, { resetSelectedServer })(Home);

14
src/common/Home.scss Normal file
View file

@ -0,0 +1,14 @@
@import '../utils/base';
.home-container {
text-align: center;
height: calc(100vh - #{$headerHeight});
display: flex;
align-items: center;
justify-content: center;
flex-flow: column;
}
.home-container__title {
font-size: 36px;
}

46
src/common/MainHeader.js Normal file
View file

@ -0,0 +1,46 @@
import plusIcon from '@fortawesome/fontawesome-free-solid/faPlus';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react';
import { Link, withRouter } from 'react-router-dom'
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
import ServersDropdown from '../servers/ServersDropdown';
import './MainHeader.scss';
import shlinkLogo from './shlink-logo-white.png';
export class MainHeader extends React.Component {
state = { isOpen: false };
toggle = () => {
this.setState(({ isOpen }) => ({
isOpen: !isOpen
}));
};
componentDidUpdate(prevProps) {
if (this.props.location !== prevProps.location) {
this.setState({ isOpen: false });
}
}
render() {
return (
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
<NavbarBrand tag={Link} to="/">
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo"/> Shlink
</NavbarBrand>
<NavbarToggler onClick={this.toggle} />
<Collapse navbar isOpen={this.state.isOpen}>
<Nav navbar className="ml-auto">
<NavItem>
<NavLink tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon}/>&nbsp; Add server
</NavLink>
</NavItem>
<ServersDropdown />
</Nav>
</Collapse>
</Navbar>
);
}
}
export default withRouter(MainHeader);

View file

@ -0,0 +1,15 @@
@import '../utils/base';
.main-header.main-header {
background-color: $mainColor !important;
color: white;
.navbar-brand {
color: inherit !important;
}
}
.main-header__brand-logo {
width: 26px;
margin-right: 5px;
}

46
src/common/MenuLayout.js Normal file
View file

@ -0,0 +1,46 @@
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { connect } from 'react-redux';
import { selectServer } from '../servers/reducers/selectedServer';
import CreateShortUrl from '../short-urls/CreateShortUrl';
import ShortUrls from '../short-urls/ShortUrls';
import ShortUrlsVisits from '../short-urls/ShortUrlVisits';
import AsideMenu from './AsideMenu';
import { pick } from 'ramda';
export class MenuLayout extends React.Component {
// FIXME Shouldn't use componentWillMount, but this code has to be run before children components are rendered
componentWillMount() {
const { serverId } = this.props.match.params;
this.props.selectServer(serverId);
}
render() {
return (
<div className="row">
<AsideMenu {...this.props} />
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3">
<Switch>
<Route
exact
path="/server/:serverId/list-short-urls/:page"
component={ShortUrls}
/>
<Route
exact
path="/server/:serverId/create-short-url"
component={CreateShortUrl}
/>
<Route
exact
path="/server/:serverId/short-code/:shortCode/visits"
component={ShortUrlsVisits}
/>
</Switch>
</div>
</div>
);
}
}
export default connect(pick(['selectedServer', 'shortUrlsListParams']), { selectServer })(MenuLayout);

22
src/common/ScrollToTop.js Normal file
View file

@ -0,0 +1,22 @@
import React from 'react';
import { withRouter } from 'react-router-dom'
export class ScrollToTop extends React.Component {
componentDidUpdate(prevProps) {
const { location, window } = this.props;
if (location !== prevProps.location) {
window.scrollTo(0, 0)
}
}
render() {
return this.props.children;
}
}
ScrollToTop.defaultProps = {
window
};
export default withRouter(ScrollToTop);

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View file

@ -1,8 +1,32 @@
import 'bootstrap/dist/css/bootstrap.min.css';
import React from 'react';
import ReactDOM from 'react-dom';
import './index.scss';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import { applyMiddleware, compose, createStore } from 'redux';
import ReduxThunk from 'redux-thunk';
import App from './App';
import './index.scss';
import ScrollToTop from './common/ScrollToTop'
import reducers from './reducers';
import registerServiceWorker from './registerServiceWorker';
ReactDOM.render(<App />, document.getElementById('root'));
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
: compose;
const store = createStore(reducers, composeEnhancers(
applyMiddleware(ReduxThunk)
));
ReactDOM.render(
<Provider store={store}>
<BrowserRouter>
<ScrollToTop>
<App />
</ScrollToTop>
</BrowserRouter>
</Provider>,
document.getElementById('root')
);
registerServiceWorker();

View file

@ -1,5 +1,28 @@
body {
margin: 0;
padding: 0;
font-family: sans-serif;
@import './utils/base';
* {
outline: none !important;
}
.nowrap {
white-space: nowrap;
}
.bg-main {
background-color: $mainColor !important;
}
.dropdown-item {
cursor: pointer;
}
.dropdown-item.active {
@extend .bg-main;
}
.short-urls-container {
padding: 20px 0;
@media (min-width: $mdMin) {
padding: 30px 30px 30px 20px;
}
}

View file

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

17
src/reducers/index.js Normal file
View file

@ -0,0 +1,17 @@
import { combineReducers } from 'redux';
import serversReducer from '../servers/reducers/server';
import selectedServerReducer from '../servers/reducers/selectedServer';
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
import shortUrlCreationResultReducer from '../short-urls/reducers/shortUrlCreationResult';
import shortUrlVisitsReducer from '../short-urls/reducers/shortUrlVisits';
export default combineReducers({
servers: serversReducer,
selectedServer: selectedServerReducer,
shortUrlsList: shortUrlsListReducer,
shortUrlsListParams: shortUrlsListParamsReducer,
shortUrlCreationResult: shortUrlCreationResultReducer,
shortUrlVisits: shortUrlVisitsReducer
});

View file

@ -0,0 +1,60 @@
import { assoc, pick } from 'ramda';
import React from 'react';
import { connect } from 'react-redux';
import { createServer } from './reducers/server';
import { resetSelectedServer } from './reducers/selectedServer';
import { v4 as uuid } from 'uuid';
import './CreateServer.scss';
export class CreateServer extends React.Component {
state = {
name: '',
url: '',
apiKey: '',
};
componentDidMount() {
this.props.resetSelectedServer();
}
render() {
const submit = e => {
e.preventDefault();
const server = assoc('id', uuid(), this.state);
this.props.createServer(server);
this.props.history.push(`/server/${server.id}/list-short-urls/1`)
};
const renderInputGroup = (id, placeholder, type = 'text') =>
<div className="form-group row">
<label htmlFor={id} className="col-lg-1 col-md-2 col-form-label create-server__label">{placeholder}:</label>
<div className="col-lg-11 col-md-10">
<input
type={type}
className="form-control"
id={id}
placeholder={placeholder}
value={this.state[id]}
onChange={e => this.setState({ [id]: e.target.value })}
required
/>
</div>
</div>;
return (
<div className="create-server">
<form onSubmit={submit}>
{renderInputGroup('name', 'Name')}
{renderInputGroup('url', 'URL', 'url')}
{renderInputGroup('apiKey', 'API key')}
<div className="text-right">
<button className="btn btn-primary btn-outline-primary">Create server</button>
</div>
</form>
</div>
);
}
}
export default connect(pick(['selectedServer']), {createServer, resetSelectedServer })(CreateServer);

View file

@ -0,0 +1,14 @@
@import '../utils/base';
.create-server {
padding: 40px 20px;
}
.create-server__label {
font-weight: bold;
cursor: pointer;
@media (min-width: $mdMin) {
text-align: right;
}
}

View file

@ -0,0 +1,34 @@
import deleteIcon from '@fortawesome/fontawesome-free-solid/faMinusCircle';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react';
import DeleteServerModal from './DeleteServerModal';
export default class DeleteServerButton extends React.Component {
state = { isModalOpen: false };
render() {
const { history, server } = this.props;
return [
(
<span
className={this.props.className}
onClick={() => this.setState({ isModalOpen: true })}
key="deleteServerBtn"
>
<FontAwesomeIcon icon={deleteIcon} />
<span className="aside-menu__item-text">Delete this server</span>
</span>
),
(
<DeleteServerModal
isOpen={this.state.isModalOpen}
toggle={() => this.setState({ isModalOpen: !this.state.isModalOpen })}
history={history}
server={server}
key="deleteServerModal"
/>
)
];
}
}

View file

@ -0,0 +1,28 @@
import React from 'react';
import { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { deleteServer } from './reducers/server';
export const DeleteServerModal = ({ server, deleteServer, toggle, history, isOpen }) => {
const closeModal = () => {
deleteServer(server);
toggle();
history.push('/');
};
return (
<Modal isOpen={isOpen} toggle={toggle} centered={true}>
<ModalHeader toggle={toggle}><span className="text-danger">Delete server</span></ModalHeader>
<ModalBody>
<p>Are you sure you want to delete server <b>{server ? server.name : ''}</b>?</p>
<p>No data will be deleted, only the access to that server will be removed from this host. You can create it again at any moment.</p>
</ModalBody>
<ModalFooter>
<button className="btn btn-link" onClick={toggle}>Cancel</button>
<button className="btn btn-danger" onClick={() => closeModal()}>Delete</button>
</ModalFooter>
</Modal>
);
};
export default connect(null, { deleteServer })(DeleteServerModal);

View file

@ -0,0 +1,46 @@
import { isEmpty, pick } from 'ramda';
import React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import { listServers } from './reducers/server';
import { selectServer } from '../servers/reducers/selectedServer';
export class ServersDropdown extends React.Component {
renderServers = () => {
const { servers, selectedServer, selectServer } = this.props;
if (isEmpty(servers)) {
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>
}
return Object.values(servers).map(({ name, id }) => (
<span key={id}>
<DropdownItem
tag={Link}
to={`/server/${id}/list-short-urls/1`}
active={selectedServer && selectedServer.id === id}
onClick={() => selectServer(id)} // FIXME This should be implicit
>
{name}
</DropdownItem>
</span>
));
};
componentDidMount() {
this.props.listServers();
}
render() {
return (
<UncontrolledDropdown nav>
<DropdownToggle nav caret>Servers</DropdownToggle>
<DropdownMenu>{this.renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);
}
}
export default connect(pick(['servers', 'selectedServer']), { listServers, selectServer })(ServersDropdown);

View file

@ -0,0 +1,33 @@
import ShlinkApiClient from '../../api/ShlinkApiClient';
import ServersService from '../../servers/services/ServersService';
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams'
const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
const defaultState = null;
export default function reducer(state = defaultState, action) {
switch (action.type) {
case SELECT_SERVER:
return action.selectedServer;
case RESET_SELECTED_SERVER:
return defaultState;
default:
return state;
}
}
export const resetSelectedServer = () => ({ type: RESET_SELECTED_SERVER });
export const selectServer = serverId => dispatch => {
dispatch(resetShortUrlParams());
const selectedServer = ServersService.findServerById(serverId);
ShlinkApiClient.setConfig(selectedServer);
dispatch({
type: SELECT_SERVER,
selectedServer
})
};

View file

@ -0,0 +1,35 @@
import ServersService from '../services/ServersService';
const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS';
const CREATE_SERVER = 'shlink/servers/CREATE_SERVER';
const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
export default function reducer(state = {}, action) {
switch (action.type) {
case FETCH_SERVERS:
case DELETE_SERVER:
return action.servers;
case CREATE_SERVER:
const server = action.server;
return { ...state, [server.id]: server };
default:
return state;
}
}
export const listServers = () => {
return {
type: FETCH_SERVERS,
servers: ServersService.listServers(),
};
};
export const createServer = server => {
ServersService.createServer(server);
return listServers();
};
export const deleteServer = server => {
ServersService.deleteServer(server);
return listServers();
};

View file

@ -0,0 +1,32 @@
import Storage from '../../utils/Storage';
import { dissoc } from 'ramda';
const SERVERS_STORAGE_KEY = 'servers';
export class ServersService {
constructor(storage) {
this.storage = storage;
}
listServers = () => {
return this.storage.get(SERVERS_STORAGE_KEY) || {};
};
findServerById = serverId => {
const servers = this.listServers();
return servers[serverId];
};
createServer = server => {
const servers = this.listServers();
servers[server.id] = server;
this.storage.set(SERVERS_STORAGE_KEY, servers);
};
deleteServer = server => {
const servers = dissoc(server.id, this.listServers());
this.storage.set(SERVERS_STORAGE_KEY, servers);
};
}
export default new ServersService(Storage);

View file

@ -0,0 +1,137 @@
import downIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleDown';
import upIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleUp';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { assoc, dissoc, isNil, pick, pipe, pluck, replace } from 'ramda';
import React from 'react';
import { connect } from 'react-redux';
import ReactTags from 'react-tag-autocomplete';
import { Collapse } from 'reactstrap';
import '../../node_modules/react-datepicker/dist/react-datepicker.css';
import DateInput from '../common/DateInput';
import './CreateShortUrl.scss';
import CreateShortUrlResult from './helpers/CreateShortUrlResult';
import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreationResult';
export class CreateShortUrl extends React.Component {
state = {
longUrl: '',
tags: [],
customSlug: undefined,
validSince: undefined,
validUntil: undefined,
maxVisits: undefined,
moreOptionsVisible: false
};
render() {
const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props;
const addTag = tag => this.setState({
tags: [].concat(this.state.tags, assoc('name', replace(/ /g, '-', tag.name), tag))
});
const removeTag = i => {
const tags = this.state.tags.slice(0);
tags.splice(i, 1);
this.setState({ tags });
};
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) =>
<input
className="form-control"
type={type}
placeholder={placeholder}
value={this.state[id]}
onChange={e => this.setState({ [id]: e.target.value })}
{...props}
/>;
const createDateInput = (id, placeholder, props = {}) =>
<DateInput
selected={this.state[id]}
placeholderText={placeholder}
onChange={date => this.setState({ [id]: date })}
{...props}
/>;
const formatDate = date => isNil(date) ? date : date.format();
const save = e => {
e.preventDefault();
createShortUrl(pipe(
dissoc('moreOptionsVisible'), // Remove moreOptionsVisible property
assoc('tags', pluck('name', this.state.tags)), // Map tags array to use only their names
assoc('validSince', formatDate(this.state.validSince)),
assoc('validUntil', formatDate(this.state.validUntil))
)(this.state));
};
return (
<div className="short-urls-container">
<form onSubmit={save}>
<div className="form-group">
<input
className="form-control form-control-lg"
type="url"
placeholder="Insert the URL to be shortened"
required
value={this.state.longUrl}
onChange={e => this.setState({ longUrl: e.target.value })}
/>
</div>
<Collapse isOpen={this.state.moreOptionsVisible}>
<div className="form-group">
<ReactTags
tags={this.state.tags}
handleAddition={addTag}
handleDelete={removeTag}
allowNew={true}
placeholder="Add tags you want to apply to the URL"
/>
</div>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
{renderOptionalInput('customSlug', 'Custom slug')}
</div>
<div className="form-group">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
{createDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
</div>
<div className="form-group">
{createDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
</div>
</div>
</div>
</Collapse>
<div>
<button
type="button"
className="btn btn-outline-secondary create-short-url__btn"
onClick={() => this.setState({ moreOptionsVisible: !this.state.moreOptionsVisible })}
>
<FontAwesomeIcon icon={this.state.moreOptionsVisible ? upIcon : downIcon} />
&nbsp;
{this.state.moreOptionsVisible ? 'Less' : 'More'} options
</button>
<button
className="btn btn-outline-primary create-short-url__btn float-right"
disabled={shortUrlCreationResult.loading}
>
{shortUrlCreationResult.loading ? 'Creating...' : 'Create'}
</button>
</div>
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
</form>
</div>
);
}
}
export default connect(pick(['shortUrlCreationResult']), {
createShortUrl,
resetCreateShortUrl
})(CreateShortUrl);

View file

@ -0,0 +1,24 @@
@import '../../node_modules/react-tag-autocomplete/example/styles.css';
@import '../utils/mixins/box-shadow';
@import '../utils/mixins/border-radius';
.create-short-url__btn:not(:first-child) {
margin-left: 5px;
}
.react-tags {
@include border-radius(.25rem);
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out, -webkit-box-shadow .15s ease-in-out;
}
.react-tags.is-focused {
color: #495057;
background-color: #fff;
border-color: #80bdff;
outline: 0;
@include box-shadow(0 0 0 0.2rem rgba(0,123,255,.25));
}
.react-datepicker__input-container,
.react-datepicker-wrapper {
display: block !important;
}

View file

@ -0,0 +1,52 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
export default class Paginator extends React.Component {
render() {
const { paginator = {}, serverId } = this.props;
const { currentPage, pagesCount = 0 } = paginator;
if (pagesCount <= 1) {
return null;
}
const renderPages = () => {
const pages = [];
for (let i = 1; i <= pagesCount; i++) {
pages.push(
<PaginationItem key={i} active={currentPage === i}>
<PaginationLink
tag={Link}
to={`/server/${serverId}/list-short-urls/${i}`}
>
{i}
</PaginationLink>
</PaginationItem>
);
}
return pages;
};
return (
<Pagination listClassName="flex-wrap">
<PaginationItem disabled={currentPage === 1}>
<PaginationLink
previous
tag={Link}
to={`/server/${serverId}/list-short-urls/${currentPage - 1}`}
/>
</PaginationItem>
{renderPages()}
<PaginationItem disabled={currentPage >= pagesCount}>
<PaginationLink
next
tag={Link}
to={`/server/${serverId}/list-short-urls/${currentPage + 1}`}
/>
</PaginationItem>
</Pagination>
);
}
}

View file

@ -0,0 +1,81 @@
import searchIcon from '@fortawesome/fontawesome-free-solid/faSearch';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react';
import { connect } from 'react-redux';
import Tag from '../utils/Tag';
import { listShortUrls } from './reducers/shortUrlsList';
import './SearchBar.scss';
import { pick, isEmpty } from 'ramda';
export class SearchBar extends React.Component {
state = {
showClearBtn: false,
searchTerm: '',
};
timer = null;
render() {
const { listShortUrls, shortUrlsListParams } = this.props;
const selectedTags = shortUrlsListParams.tags || [];
return (
<div className="serach-bar-container">
<div className="search-bar">
<input
type="text"
className="form-control form-control-lg search-bar__input"
placeholder="Search..."
onChange={e => this.searchTermChanged(e.target.value)}
value={this.state.searchTerm}
/>
<FontAwesomeIcon icon={searchIcon} className="search-bar__icon" />
<div
className="close search-bar__close"
hidden={! this.state.showClearBtn}
onClick={() => this.searchTermChanged('')}
id="search-bar__close"
>
&times;
</div>
</div>
{!isEmpty(selectedTags) && (
<h4 className="search-bar__selected-tag mt-2">
<small>Filtering by tags:</small>
&nbsp;
{selectedTags.map(tag => <Tag
text={tag}
clearable
onClose={() => listShortUrls(
{
...shortUrlsListParams,
tags: selectedTags.filter(selectedTag => selectedTag !== tag)
}
)}
/>)}
</h4>
)}
</div>
);
}
searchTermChanged(searchTerm) {
this.setState({
showClearBtn: searchTerm !== '',
searchTerm: searchTerm,
});
const resetTimer = () => {
clearTimeout(this.timer);
this.timer = null;
};
resetTimer();
this.timer = setTimeout(() => {
this.props.listShortUrls({ ...this.props.shortUrlsListParams, searchTerm });
resetTimer();
}, 500);
}
}
export default connect(pick(['shortUrlsListParams']), { listShortUrls })(SearchBar);

View file

@ -0,0 +1,21 @@
@import '../utils/mixins/vertical-align';
.search-bar {
position: relative;
}
.search-bar__input.search-bar__input {
padding-left: 40px;
padding-right: 40px;
}
.search-bar__icon {
@include vertical-align();
left: 15px;
color: #707581;
}
.search-bar__close {
@include vertical-align();
right: 15px;
}

View file

@ -0,0 +1,178 @@
import preloader from '@fortawesome/fontawesome-free-solid/faCircleNotch';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { isEmpty, mapObjIndexed, pick } from 'ramda';
import React from 'react';
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import Moment from 'react-moment';
import { connect } from 'react-redux';
import { Card, CardBody, CardHeader, UncontrolledTooltip } from 'reactstrap';
import DateInput from '../common/DateInput';
import VisitsParser from '../visits/services/VisitsParser';
import { getShortUrlVisits } from './reducers/shortUrlVisits';
import './ShortUrlVisits.scss';
const MutedMessage = ({ children }) =>
<div className="col-md-10 offset-md-1">
<Card className="bg-light mt-4" body>
<h3 className="text-center text-muted mb-0">
{children}
</h3>
</Card>
</div>;
export class ShortUrlsVisits extends React.Component {
state = { startDate: undefined, endDate: undefined };
loadVisits = () => {
const { match: { params } } = this.props;
this.props.getShortUrlVisits(params.shortCode, mapObjIndexed(
value => value && value.format ? value.format('YYYY-MM-DD') : value,
this.state
))
};
componentDidMount() {
this.loadVisits();
}
render() {
const {
match: { params },
selectedServer,
visitsParser,
shortUrlVisits: { visits, loading, error, shortUrl }
} = this.props;
const serverUrl = selectedServer ? selectedServer.url : '';
const shortLink = `${serverUrl}/${params.shortCode}`;
const generateGraphData = (stats, label, isBarChart) => ({
labels: Object.keys(stats),
datasets: [
{
label,
data: Object.values(stats),
backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [
'#97BBCD',
'#DCDCDC',
'#F7464A',
'#46BFBD',
'#FDB45C',
'#949FB1',
'#4D5360'
],
borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white',
borderWidth: 2
}
]
});
const renderGraphCard = (title, stats, isBarChart, label) =>
<div className="col-md-6">
<Card className="mt-4">
<CardHeader>{title}</CardHeader>
<CardBody>
{!isBarChart && <Doughnut data={generateGraphData(stats, label || title, isBarChart)} options={{
legend: {
position: 'right'
}
}} />}
{isBarChart && <HorizontalBar data={generateGraphData(stats, label || title, isBarChart)} options={{
legend: {
display: false
}
}} />}
</CardBody>
</Card>
</div>;
const renderContent = () => {
if (loading) {
return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> Loading...</MutedMessage>;
}
if (error) {
return (
<Card className="mt-4" body inverse color="danger">
An error occurred while loading visits :(
</Card>
);
}
if (isEmpty(visits)) {
return <MutedMessage>There have been no visits matching current filter :(</MutedMessage>;
}
return (
<div className="row">
{renderGraphCard('Operating systems', visitsParser.processOsStats(visits), false)}
{renderGraphCard('Browsers', visitsParser.processBrowserStats(visits), false)}
{renderGraphCard('Countries', visitsParser.processCountriesStats(visits), true, 'Visits')}
{renderGraphCard('Referrers', visitsParser.processReferrersStats(visits), true, 'Visits')}
</div>
);
};
const renderCreated = () =>
<span>
<b id="created"><Moment fromNow>{shortUrl.dateCreated}</Moment></b>
<UncontrolledTooltip placement="bottom" target="created">
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
</UncontrolledTooltip>
</span>;
return (
<div className="short-urls-container">
<header>
<Card className="bg-light">
<CardBody>
<h2>
{
shortUrl.visitsCount &&
<span className="badge badge-primary float-right">Visits: {shortUrl.visitsCount}</span>
}
Visit stats for <a target="_blank" href={shortLink}>{shortLink}</a>
</h2>
<hr />
{shortUrl.dateCreated && <div>
Created:
&nbsp;
{loading && <small>Loading...</small>}
{!loading && renderCreated()}
</div>}
<div>
Long URL:
&nbsp;
{loading && <small>Loading...</small>}
{!loading && <a target="_blank" href={shortUrl.longUrl}>{shortUrl.longUrl}</a>}
</div>
</CardBody>
</Card>
</header>
<section>
<form onSubmit={e => e.preventDefault()} className="form-inline mt-4 float-md-right">
<label>Period</label>
<DateInput
selected={this.state.startDate}
placeholderText="Since"
onChange={date => this.setState({ startDate: date }, () => this.loadVisits())}
className="short-url-visits__date-input"
/>
<DateInput
selected={this.state.endDate}
placeholderText="Until"
onChange={date => this.setState({ endDate: date }, () => this.loadVisits())}
className="short-url-visits__date-input"
/>
</form>
<div className="clearfix" />
</section>
<section>
{renderContent()}
</section>
</div>
);
}
}
ShortUrlsVisits.defaultProps = {
visitsParser: VisitsParser
};
export default connect(pick(['selectedServer', 'shortUrlVisits']), { getShortUrlVisits })(ShortUrlsVisits);

View file

@ -0,0 +1,3 @@
.short-url-visits__date-input {
margin-left: 10px;
}

View file

@ -0,0 +1,22 @@
import React from 'react';
import { connect } from 'react-redux';
import Paginator from './Paginator';
import SearchBar from './SearchBar';
import ShortUrlsList from './ShortUrlsList';
import { assoc } from 'ramda';
export function ShortUrls(props) {
const { match: { params } } = props;
// Using a key on a component makes react to create a new instance every time the key changes
const urlsListKey = `${params.serverId}_${params.page}`;
return (
<div className="short-urls-container">
<div className="form-group"><SearchBar /></div>
<ShortUrlsList {...props} shortUrlsList={props.shortUrlsList.data || []} key={urlsListKey} />
<Paginator paginator={props.shortUrlsList.pagination} serverId={props.match.params.serverId} />
</div>
);
}
export default connect(state => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList))(ShortUrls);

View file

@ -0,0 +1,122 @@
import caretDownIcon from '@fortawesome/fontawesome-free-solid/faCaretDown';
import caretUpIcon from '@fortawesome/fontawesome-free-solid/faCaretUp';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { isEmpty, pick } from 'ramda';
import React from 'react';
import { connect } from 'react-redux';
import { ShortUrlsRow } from './helpers/ShortUrlsRow';
import { listShortUrls } from './reducers/shortUrlsList';
import './ShortUrlsList.scss';
export class ShortUrlsList extends React.Component {
refreshList = extraParams => {
const { listShortUrls, shortUrlsListParams } = this.props;
listShortUrls({
...shortUrlsListParams,
...extraParams
});
};
constructor(props) {
super(props);
const orderBy = props.shortUrlsListParams.orderBy;
this.state = {
orderField: orderBy ? Object.keys(orderBy)[0] : 'dateCreated',
orderDir: orderBy ? Object.values(orderBy)[0] : 'ASC',
}
}
componentDidMount() {
const { match: { params } } = this.props;
this.refreshList({ page: params.page });
}
render() {
const orderBy = field => {
const newOrderDir = this.state.orderField !== field ? 'ASC' : (this.state.orderDir === 'DESC' ? 'ASC' : 'DESC');
this.setState({ orderField: field, orderDir: newOrderDir });
this.refreshList({ orderBy: { [field]: newOrderDir } })
};
const renderOrderIcon = field => {
if (this.state.orderField !== field) {
return null;
}
return (
<FontAwesomeIcon
icon={this.state.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
className="short-urls-list__header-icon"
/>
);
};
return (
<table className="table table-striped table-hover">
<thead className="short-urls-list__header">
<tr>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={() => orderBy('dateCreated')}
>
{renderOrderIcon('dateCreated')}
Created at
</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={() => orderBy('shortCode')}
>
{renderOrderIcon('shortCode')}
Short URL
</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={() => orderBy('originalUrl')}
>
{renderOrderIcon('originalUrl')}
Long URL
</th>
<th className="short-urls-list__header-cell">Tags</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={() => orderBy('visits')}
>
<span className="nowrap">{renderOrderIcon('visits')} Visits</span>
</th>
<th className="short-urls-list__header-cell">&nbsp;</th>
</tr>
</thead>
<tbody>
{this.renderShortUrls()}
</tbody>
</table>
);
}
renderShortUrls() {
const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props;
if (error) {
return <tr><td colSpan="6" className="text-center table-danger">Something went wrong while loading short URLs :(</td></tr>;
}
if (loading) {
return <tr><td colSpan="6" className="text-center">Loading...</td></tr>;
}
if (! loading && isEmpty(shortUrlsList)) {
return <tr><td colSpan="6" className="text-center">No results found</td></tr>;
}
return shortUrlsList.map(shortUrl => (
<ShortUrlsRow
shortUrl={shortUrl}
selectedServer={selectedServer}
key={shortUrl.shortCode}
refreshList={this.refreshList}
shortUrlsListParams={shortUrlsListParams}
/>
));
}
}
export default connect(pick(['selectedServer', 'shortUrlsListParams']), { listShortUrls })(ShortUrlsList);

View file

@ -0,0 +1,15 @@
@import '../utils/base';
.short-urls-list__header {
@media (max-width: $smMax) {
display: none;
}
}
.short-urls-list__header--with-action {
cursor: pointer;
}
.short-urls-list__header-icon {
margin-right: 5px;
}

View file

@ -0,0 +1,54 @@
import copyIcon from '@fortawesome/fontawesome-free-regular/faCopy';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { isNil } from 'ramda';
import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import './CreateShortUrlResult.scss'
import { Card, CardBody, Tooltip } from 'reactstrap';
export default class CreateShortUrlResult extends React.Component {
state = { showCopyTooltip: false };
componentDidMount() {
this.props.resetCreateShortUrl();
}
render() {
const { error, result } = this.props;
if (error) {
return (
<Card body color="danger" inverse className="bg-danger mt-3">
An error occurred while creating the URL :(
</Card>
);
}
if (isNil(result)) {
return null;
}
const { shortUrl } = result;
const onCopy = () => {
this.setState({ showCopyTooltip: true });
setTimeout(() => this.setState({ showCopyTooltip: false }), 2000);
};
return (
<Card inverse className="bg-main mt-3">
<CardBody>
<b>Great!</b> The short URL is <b>{shortUrl}</b>
<CopyToClipboard text={shortUrl} onCopy={onCopy}>
<button className="btn btn-light btn-sm create-short-url-result__copy-btn" id="copyBtn" type="button">
<FontAwesomeIcon icon={copyIcon}/> Copy
</button>
</CopyToClipboard>
<Tooltip placement="left" isOpen={this.state.showCopyTooltip} target="copyBtn">
Copied!
</Tooltip>
</CardBody>
</Card>
);
}
};

View file

@ -0,0 +1,4 @@
.create-short-url-result__copy-btn {
margin-left: 10px;
vertical-align: inherit;
}

View file

@ -0,0 +1,17 @@
import React from 'react'
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import './PreviewModal.scss';
export default function PreviewModal ({ url, toggle, isOpen }) {
return (
<Modal isOpen={isOpen} toggle={toggle} size="lg">
<ModalHeader toggle={toggle}>Preview for <a target="_blank" href={url}>{url}</a></ModalHeader>
<ModalBody>
<div className="text-center">
<p className="preview-modal__loader">Loading...</p>
<img src={`${url}/preview`} className="preview-modal__img" alt="Preview" />
</div>
</ModalBody>
</Modal>
);
}

View file

@ -0,0 +1,13 @@
@import '../../utils/mixins/horizontal-align';
.preview-modal__img {
max-width: 100%;
position: relative;
z-index: 2;
}
.preview-modal__loader {
@include horizontal-align();
z-index: 1;
top: 1rem;
}

View file

@ -0,0 +1,16 @@
import React from 'react'
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import './QrCodeModal.scss';
export default function QrCodeModal ({ url, toggle, isOpen }) {
return (
<Modal isOpen={isOpen} toggle={toggle} centered={true}>
<ModalHeader toggle={toggle}>QR code for <a target="_blank" href={url}>{url}</a></ModalHeader>
<ModalBody>
<div className="text-center">
<img src={`${url}/qr-code`} className="qr-code-modal__img" alt="QR code" />
</div>
</ModalBody>
</Modal>
);
}

View file

@ -0,0 +1,3 @@
.qr-code-modal__img {
max-width: 100%;
}

View file

@ -0,0 +1,60 @@
import { isEmpty } from 'ramda';
import React from 'react';
import Moment from 'react-moment';
import Tag from '../../utils/Tag';
import './ShortUrlsRow.scss';
import { ShortUrlsRowMenu } from './ShortUrlsRowMenu';
export class ShortUrlsRow extends React.Component {
state = { copiedToClipboard: false };
renderTags(tags) {
if (isEmpty(tags)) {
return <i className="nowrap"><small>No tags</small></i>;
}
const { refreshList, shortUrlsListParams } = this.props;
const selectedTags = shortUrlsListParams.tags || [];
return tags.map(
tag => <Tag key={tag} text={tag} onClick={() => refreshList({tags: [ ...selectedTags, tag ] })} />
);
}
render() {
const { shortUrl, selectedServer } = this.props;
const completeShortUrl = !selectedServer ? shortUrl.shortCode : `${selectedServer.url}/${shortUrl.shortCode}`;
return (
<tr className="short-urls-row">
<td className="nowrap short-urls-row__cell" data-th="Created at: ">
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
</td>
<td className="short-urls-row__cell" data-th="Short URL: ">
<a href={completeShortUrl} target="_blank">{completeShortUrl}</a>
</td>
<td className="short-urls-row__cell short-urls-row__cell--break" data-th="Long URL: ">
<a href={shortUrl.originalUrl} target="_blank">{shortUrl.originalUrl}</a>
</td>
<td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</td>
<td className="short-urls-row__cell text-md-right" data-th="Visits: ">{shortUrl.visitsCount}</td>
<td className="short-urls-row__cell short-urls-row__cell--relative">
<small
className="badge badge-warning short-urls-row__copy-hint"
hidden={!this.state.copiedToClipboard}
>
Copied short URL!
</small>
<ShortUrlsRowMenu
shortUrl={completeShortUrl}
selectedServer={selectedServer}
shortCode={shortUrl.shortCode}
onCopyToClipboard={() => {
this.setState({ copiedToClipboard: true });
setTimeout(() => this.setState({ copiedToClipboard: false }), 2000);
}}
/>
</td>
</tr>
);
}
}

View file

@ -0,0 +1,52 @@
@import '../../utils/base';
@import '../../utils/mixins/vertical-align';
.short-urls-row {
@media (max-width: $smMax) {
display: block;
margin-bottom: 10px;
border-bottom: 1px solid $lightGrey;
position: relative;
}
}
.short-urls-row__cell.short-urls-row__cell {
vertical-align: middle !important;
@media (max-width: $smMax) {
display: block;
width: 100%;
position: relative;
padding: .5rem;
font-size: .9rem;
&:before {
content: attr(data-th);
font-weight: bold;
}
&:last-child {
position: absolute;
top: 3px;
right: .5rem;
width: auto;
padding: 0;
}
}
}
.short-urls-row__cell--break {
word-break: break-all;
}
.short-urls-row__cell--relative {
position: relative;
}
.short-urls-row__copy-hint {
@include vertical-align();
right: 100%;
@media (max-width: $smMax) {
right: calc(100% + 10px);
}
}

View file

@ -0,0 +1,66 @@
import copyIcon from '@fortawesome/fontawesome-free-regular/faCopy';
import pictureIcon from '@fortawesome/fontawesome-free-regular/faImage';
import pieChartIcon from '@fortawesome/fontawesome-free-solid/faChartPie';
import menuIcon from '@fortawesome/fontawesome-free-solid/faEllipsisV';
import qrIcon from '@fortawesome/fontawesome-free-solid/faQrcode';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Link } from 'react-router-dom';
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import PreviewModal from './PreviewModal';
import QrCodeModal from './QrCodeModal';
import './ShortUrlsRowMenu.scss';
export class ShortUrlsRowMenu extends React.Component {
state = { isOpen: false, isQrModalOpen: false, isPreviewOpen: false };
toggle = () => this.setState({ isOpen: !this.state.isOpen });
render() {
const { shortUrl, onCopyToClipboard, selectedServer, shortCode } = this.props;
const serverId = selectedServer ? selectedServer.id : '';
const toggleQrCode = () => this.setState({isQrModalOpen: !this.state.isQrModalOpen});
const togglePreview = () => this.setState({isPreviewOpen: !this.state.isPreviewOpen});
return (
<ButtonDropdown toggle={this.toggle} isOpen={this.state.isOpen} direction="left">
<DropdownToggle size="sm" caret className="short-urls-row-menu__dropdown-toggle btn-outline-secondary">
&nbsp;<FontAwesomeIcon icon={menuIcon}/>&nbsp;
</DropdownToggle>
<DropdownMenu>
<DropdownItem tag={Link} to={`/server/${serverId}/short-code/${shortCode}/visits`}>
<FontAwesomeIcon icon={pieChartIcon}/> &nbsp;Visit Stats
</DropdownItem>
<DropdownItem divider/>
<DropdownItem onClick={togglePreview}>
<FontAwesomeIcon icon={pictureIcon}/> &nbsp;Preview
</DropdownItem>
<PreviewModal
url={shortUrl}
isOpen={this.state.isPreviewOpen}
toggle={togglePreview}
/>
<DropdownItem onClick={toggleQrCode}>
<FontAwesomeIcon icon={qrIcon}/> &nbsp;QR code
</DropdownItem>
<QrCodeModal
url={shortUrl}
isOpen={this.state.isQrModalOpen}
toggle={toggleQrCode}
/>
<DropdownItem divider/>
<CopyToClipboard text={shortUrl} onCopy={onCopyToClipboard}>
<DropdownItem>
<FontAwesomeIcon icon={copyIcon}/> &nbsp;Copy to clipboard
</DropdownItem>
</CopyToClipboard>
</DropdownMenu>
</ButtonDropdown>
);
}
}

View file

@ -0,0 +1,6 @@
.short-urls-row-menu__dropdown-toggle:before {
display: none !important;
}
.short-urls-row-menu__dropdown-toggle--hidden {
visibility: hidden;
}

View file

@ -0,0 +1,51 @@
import ShlinkApiClient from '../../api/ShlinkApiClient';
const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR';
const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL';
const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL';
const defaultState = {
result: null,
saving: false,
error: false,
};
export default function reducer(state = defaultState, action) {
switch (action.type) {
case CREATE_SHORT_URL_START:
return {
...state,
saving: true,
};
case CREATE_SHORT_URL_ERROR:
return {
...state,
saving: false,
error: true,
};
case CREATE_SHORT_URL:
return {
result: action.result,
saving: false,
error: false,
};
case RESET_CREATE_SHORT_URL:
return defaultState;
default:
return state;
}
}
export const createShortUrl = data => async dispatch => {
dispatch({ type: CREATE_SHORT_URL_START });
try {
const result = await ShlinkApiClient.createShortUrl(data);
dispatch({ type: CREATE_SHORT_URL, result });
} catch (e) {
dispatch({ type: CREATE_SHORT_URL_ERROR });
}
};
export const resetCreateShortUrl = () => ({ type: RESET_CREATE_SHORT_URL });

View file

@ -0,0 +1,48 @@
import ShlinkApiClient from '../../api/ShlinkApiClient';
const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START';
const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_ERROR';
const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS';
const initialState = {
shortUrl: {},
visits: [],
loading: false,
error: false
};
export default function dispatch (state = initialState, action) {
switch (action.type) {
case GET_SHORT_URL_VISITS_START:
return {
...state,
loading: true
};
case GET_SHORT_URL_VISITS_ERROR:
return {
...state,
loading: false,
error: true
};
case GET_SHORT_URL_VISITS:
return {
shortUrl: action.shortUrl,
visits: action.visits,
loading: false,
error: false
};
default:
return state;
}
}
export const getShortUrlVisits = (shortCode, dates) => dispatch => {
dispatch({ type: GET_SHORT_URL_VISITS_START });
Promise.all([
ShlinkApiClient.getShortUrlVisits(shortCode, dates),
ShlinkApiClient.getShortUrl(shortCode)
])
.then(([visits, shortUrl]) => dispatch({ visits, shortUrl, type: GET_SHORT_URL_VISITS }))
.catch(() => dispatch({ type: GET_SHORT_URL_VISITS_ERROR }));
};

View file

@ -0,0 +1,42 @@
import ShlinkApiClient from '../../api/ShlinkApiClient';
const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR';
export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS';
const initialState = {
shortUrls: {},
loading: true,
};
export default function reducer(state = initialState, action) {
switch (action.type) {
case LIST_SHORT_URLS_START:
return { ...state, loading: true, error: false };
case LIST_SHORT_URLS:
return {
loading: false,
error: false,
shortUrls: action.shortUrls
};
case LIST_SHORT_URLS_ERROR:
return {
loading: false,
error: true,
shortUrls: []
};
default:
return state;
}
}
export const listShortUrls = (params = {}) => async dispatch => {
dispatch({ type: LIST_SHORT_URLS_START });
try {
const shortUrls = await ShlinkApiClient.listShortUrls(params);
dispatch({ type: LIST_SHORT_URLS, shortUrls, params });
} catch (e) {
dispatch({ type: LIST_SHORT_URLS_ERROR, params });
}
};

View file

@ -0,0 +1,18 @@
import { LIST_SHORT_URLS } from './shortUrlsList';
const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS';
const defaultState = { page: '1' };
export default function reducer(state = defaultState, action) {
switch (action.type) {
case LIST_SHORT_URLS:
return { ...state, ...action.params };
case RESET_SHORT_URL_PARAMS:
return defaultState;
default:
return state;
}
}
export const resetShortUrlParams = () => ({ type: RESET_SHORT_URL_PARAMS });

View file

@ -0,0 +1,32 @@
import Storage from './Storage';
const buildRandomColor = () => {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++ ) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
};
export class ColorGenerator {
constructor(storage) {
this.storage = storage;
this.colors = this.storage.get('colors') || {};
}
getColorForKey = key => {
let color = this.colors[key];
if (color) {
return color;
}
// If a color has not been set yet, generate a random one and save it
color = buildRandomColor();
this.colors[key] = color;
this.storage.set('colors', this.colors);
return color;
};
}
export default new ColorGenerator(Storage);

18
src/utils/Storage.js Normal file
View file

@ -0,0 +1,18 @@
const PREFIX = 'shlink';
const buildPath = path => `${PREFIX}.${path}`;
export class Storage {
constructor(localStorage) {
this.localStorage = localStorage;
}
get = key => {
const item = this.localStorage.getItem(buildPath(key));
return item ? JSON.parse(item) : undefined;
};
set = (key, value) => this.localStorage.setItem(buildPath(key), JSON.stringify(value));
}
const storage = typeof localStorage !== 'undefined' ? localStorage : {};
export default new Storage(storage);

28
src/utils/Tag.js Normal file
View file

@ -0,0 +1,28 @@
import React from 'react';
import ColorGenerator from '../utils/ColorGenerator';
import './Tag.scss';
export default function Tag (
{
colorGenerator,
text,
clearable,
onClick = () => ({}),
onClose = () => ({})
}
) {
return (
<span
className="badge tag"
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable ? 'auto' : 'pointer' }}
onClick={onClick}
>
{text}
{clearable && <span className="close tag__close-selected-tag" onClick={onClose}>&times;</span>}
</span>
);
}
Tag.defaultProps = {
colorGenerator: ColorGenerator
};

21
src/utils/Tag.scss Normal file
View file

@ -0,0 +1,21 @@
.tag {
color: #fff;
cursor: pointer;
}
.tag:not(:last-child) {
margin-right: 3px;
}
.tag__close-selected-tag.tag__close-selected-tag {
font-size: inherit;
color: inherit;
opacity: 1;
cursor: pointer;
margin-left: 5px;
}
.tag__close-selected-tag.tag__close-selected-tag:hover {
color: inherit;
opacity: 1;
}

19
src/utils/base.scss Normal file
View file

@ -0,0 +1,19 @@
// Breakpoints
$xsMax: 575px;
$smMin: 576px;
$smMax: 767px;
$mdMin: 768px;
$mdMax: 991px;
$lgMin: 992px;
$lgMax: 1199px;
$xlgMin: 1200px;
// Colors
$mainColor: #4696e5;
$lightHoverColor: #eee;
$lightGrey: #ddd;
$dangerColor: #dc3545;
// Misc
$headerHeight: 57px;

View file

@ -0,0 +1,4 @@
@mixin border-radius($radius) {
border-radius: $radius;
-webkit-border-radius: $radius;
}

View file

@ -0,0 +1,4 @@
@mixin box-shadow($shadow) {
-webkit-box-shadow: $shadow;
box-shadow: $shadow;
}

View file

@ -0,0 +1,5 @@
@mixin horizontal-align {
position: absolute;
left: 50%;
transform: translateX(-50%);
}

View file

@ -0,0 +1,5 @@
@mixin vertical-align {
position: absolute;
top: 50%;
transform: translateY(-50%);
}

View file

@ -0,0 +1,101 @@
import { forEach, isNil, isEmpty } from 'ramda';
const osFromUserAgent = userAgent => {
const lowerUserAgent = userAgent.toLowerCase();
switch (true) {
case (lowerUserAgent.indexOf('linux') >= 0):
return 'Linux';
case (lowerUserAgent.indexOf('windows') >= 0):
return 'Windows';
case (lowerUserAgent.indexOf('mac') >= 0):
return 'MacOS';
case (lowerUserAgent.indexOf('mobi') >= 0):
return 'Mobile';
default:
return 'Others';
}
};
const browserFromUserAgent = userAgent => {
const lowerUserAgent = userAgent.toLowerCase();
switch (true) {
case (lowerUserAgent.indexOf('firefox') >= 0):
return 'Firefox';
case (lowerUserAgent.indexOf('chrome') >= 0):
return 'Chrome';
case (lowerUserAgent.indexOf('safari') >= 0):
return 'Safari';
case (lowerUserAgent.indexOf('opera') >= 0):
return 'Opera';
case (lowerUserAgent.indexOf('msie') >= 0):
return 'Internet Explorer';
default:
return 'Others';
}
};
const extractDomain = url => {
const domain = url.indexOf('://') > -1 ? url.split('/')[2] : url.split('/')[0];
return domain.split(':')[0];
};
// FIXME Refactor these foreach statements which mutate a stats object
export class VisitsParser {
processOsStats = visits => {
const stats = {};
forEach(visit => {
const userAgent = visit.userAgent;
const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent);
stats[os] = typeof stats[os] === 'undefined' ? 1 : stats[os] + 1;
}, visits);
return stats;
};
processBrowserStats = visits => {
const stats = {};
forEach(visit => {
const userAgent = visit.userAgent;
const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent);
stats[browser] = typeof stats[browser] === 'undefined' ? 1 : stats[browser] + 1;
}, visits);
return stats;
};
processReferrersStats = visits => {
const stats = {};
forEach(visit => {
const notHasDomain = isNil(visit.referer) || isEmpty(visit.referer);
const domain = notHasDomain ? 'Unknown' : extractDomain(visit.referer);
stats[domain] = typeof stats[domain] === 'undefined' ? 1 : stats[domain] + 1;
}, visits);
return stats;
};
processCountriesStats = visits => {
const stats = {};
forEach(({ visitLocation }) => {
const notHasCountry = isNil(visitLocation)
|| isNil(visitLocation.countryName)
|| isEmpty(visitLocation.countryName);
const country = notHasCountry ? 'Unknown' : visitLocation.countryName;
stats[country] = typeof stats[country] === 'undefined' ? 1 : stats[country] + 1;
}, visits);
return stats;
};
}
export default new VisitsParser();

View file

@ -0,0 +1,26 @@
import { ShlinkApiClient } from '../../src/api/ShlinkApiClient'
describe('ShlinkApiClient', () => {
const createApiClient = extraData => {
const axiosMock = () =>
Promise.resolve({
data: { token: 'foo', ...extraData },
headers: { authorization: 'Bearer abc123' }
});
return new ShlinkApiClient(axiosMock);
};
describe('listShortUrls', () => {
it('properly returns short URLs list', async () => {
const expectedList = ['foo', 'bar'];
const apiClient = createApiClient({
shortUrls: expectedList
});
const actualList = await apiClient.listShortUrls();
expect(expectedList).toEqual(actualList);
});
});
});

View file

@ -0,0 +1,22 @@
import { identity } from 'ramda';
import React from 'react';
import { ServersDropdown } from '../../src/servers/ServersDropdown';
import { shallow } from 'enzyme';
describe('<ServersDropdown />', () => {
let wrapped;
const servers = [{ name: 'foo', id: 1 }, { name: 'bar', id: 2 }, { name: 'baz', id: 3 }];
beforeEach(() => {
wrapped = shallow(<ServersDropdown servers={servers} listServers={identity} />);
});
afterEach(() => wrapped.unmount());
it('contains the list of servers', () => {
expect(wrapped.find('DropdownItem').length).toEqual(servers.length);
});
it('contains a toggle with proper title', () => {
expect(wrapped.find('DropdownToggle').length).toEqual(1);
});
});

7935
yarn.lock Normal file

File diff suppressed because it is too large Load diff