2
.gitignore
vendored
|
@ -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
|
@ -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
|
@ -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
|
@ -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.
|
|
@ -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
|
@ -0,0 +1,4 @@
|
|||
import Enzyme from 'enzyme';
|
||||
import Adapter from 'enzyme-adapter-react-16';
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
|
@ -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
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
8
docker-compose.override.yml.dist
Normal 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
|
@ -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
|
@ -0,0 +1,2 @@
|
|||
#!/usr/bin/env bash
|
||||
docker exec -it shlink_web_client_node /bin/sh -c "cd /home/shlink/www && $*"
|
59
package.json
|
@ -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",
|
||||
|
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 17 KiB |
BIN
public/icons/shlink-128.png
Normal file
After Width: | Height: | Size: 6.7 KiB |
BIN
public/icons/shlink-16.png
Normal file
After Width: | Height: | Size: 690 B |
BIN
public/icons/shlink-24.png
Normal file
After Width: | Height: | Size: 1 KiB |
BIN
public/icons/shlink-32.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
public/icons/shlink-64.png
Normal file
After Width: | Height: | Size: 3.2 KiB |
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
34
src/App.js
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
33
src/App.scss
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
88
src/api/ShlinkApiClient.js
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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}/> Add server
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<ServersDropdown />
|
||||
</Nav>
|
||||
</Collapse>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(MainHeader);
|
15
src/common/MainHeader.scss
Normal 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
|
@ -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
|
@ -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);
|
BIN
src/common/shlink-logo-white.png
Normal file
After Width: | Height: | Size: 8.7 KiB |
28
src/index.js
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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
|
||||
});
|
60
src/servers/CreateServer.js
Normal 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);
|
14
src/servers/CreateServer.scss
Normal 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;
|
||||
}
|
||||
}
|
34
src/servers/DeleteServerButton.js
Normal 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"
|
||||
/>
|
||||
)
|
||||
];
|
||||
}
|
||||
}
|
28
src/servers/DeleteServerModal.js
Normal 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);
|
46
src/servers/ServersDropdown.js
Normal 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);
|
33
src/servers/reducers/selectedServer.js
Normal 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
|
||||
})
|
||||
};
|
35
src/servers/reducers/server.js
Normal 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();
|
||||
};
|
32
src/servers/services/ServersService.js
Normal 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);
|
137
src/short-urls/CreateShortUrl.js
Normal 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} />
|
||||
|
||||
{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);
|
24
src/short-urls/CreateShortUrl.scss
Normal 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;
|
||||
}
|
52
src/short-urls/Paginator.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
81
src/short-urls/SearchBar.js
Normal 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"
|
||||
>
|
||||
×
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEmpty(selectedTags) && (
|
||||
<h4 className="search-bar__selected-tag mt-2">
|
||||
<small>Filtering by tags:</small>
|
||||
|
||||
{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);
|
21
src/short-urls/SearchBar.scss
Normal 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;
|
||||
}
|
178
src/short-urls/ShortUrlVisits.js
Normal 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:
|
||||
|
||||
{loading && <small>Loading...</small>}
|
||||
{!loading && renderCreated()}
|
||||
</div>}
|
||||
<div>
|
||||
Long URL:
|
||||
|
||||
{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);
|
3
src/short-urls/ShortUrlVisits.scss
Normal file
|
@ -0,0 +1,3 @@
|
|||
.short-url-visits__date-input {
|
||||
margin-left: 10px;
|
||||
}
|
22
src/short-urls/ShortUrls.js
Normal 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);
|
122
src/short-urls/ShortUrlsList.js
Normal 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"> </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);
|
15
src/short-urls/ShortUrlsList.scss
Normal 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;
|
||||
}
|
54
src/short-urls/helpers/CreateShortUrlResult.js
Normal 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>
|
||||
);
|
||||
}
|
||||
};
|
4
src/short-urls/helpers/CreateShortUrlResult.scss
Normal file
|
@ -0,0 +1,4 @@
|
|||
.create-short-url-result__copy-btn {
|
||||
margin-left: 10px;
|
||||
vertical-align: inherit;
|
||||
}
|
17
src/short-urls/helpers/PreviewModal.js
Normal 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>
|
||||
);
|
||||
}
|
13
src/short-urls/helpers/PreviewModal.scss
Normal 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;
|
||||
}
|
16
src/short-urls/helpers/QrCodeModal.js
Normal 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>
|
||||
);
|
||||
}
|
3
src/short-urls/helpers/QrCodeModal.scss
Normal file
|
@ -0,0 +1,3 @@
|
|||
.qr-code-modal__img {
|
||||
max-width: 100%;
|
||||
}
|
60
src/short-urls/helpers/ShortUrlsRow.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
52
src/short-urls/helpers/ShortUrlsRow.scss
Normal 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);
|
||||
}
|
||||
}
|
66
src/short-urls/helpers/ShortUrlsRowMenu.js
Normal 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">
|
||||
<FontAwesomeIcon icon={menuIcon}/>
|
||||
</DropdownToggle>
|
||||
<DropdownMenu>
|
||||
<DropdownItem tag={Link} to={`/server/${serverId}/short-code/${shortCode}/visits`}>
|
||||
<FontAwesomeIcon icon={pieChartIcon}/> Visit Stats
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem divider/>
|
||||
|
||||
<DropdownItem onClick={togglePreview}>
|
||||
<FontAwesomeIcon icon={pictureIcon}/> Preview
|
||||
</DropdownItem>
|
||||
<PreviewModal
|
||||
url={shortUrl}
|
||||
isOpen={this.state.isPreviewOpen}
|
||||
toggle={togglePreview}
|
||||
/>
|
||||
|
||||
<DropdownItem onClick={toggleQrCode}>
|
||||
<FontAwesomeIcon icon={qrIcon}/> QR code
|
||||
</DropdownItem>
|
||||
<QrCodeModal
|
||||
url={shortUrl}
|
||||
isOpen={this.state.isQrModalOpen}
|
||||
toggle={toggleQrCode}
|
||||
/>
|
||||
|
||||
<DropdownItem divider/>
|
||||
|
||||
<CopyToClipboard text={shortUrl} onCopy={onCopyToClipboard}>
|
||||
<DropdownItem>
|
||||
<FontAwesomeIcon icon={copyIcon}/> Copy to clipboard
|
||||
</DropdownItem>
|
||||
</CopyToClipboard>
|
||||
</DropdownMenu>
|
||||
</ButtonDropdown>
|
||||
);
|
||||
}
|
||||
}
|
6
src/short-urls/helpers/ShortUrlsRowMenu.scss
Normal file
|
@ -0,0 +1,6 @@
|
|||
.short-urls-row-menu__dropdown-toggle:before {
|
||||
display: none !important;
|
||||
}
|
||||
.short-urls-row-menu__dropdown-toggle--hidden {
|
||||
visibility: hidden;
|
||||
}
|
51
src/short-urls/reducers/shortUrlCreationResult.js
Normal 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 });
|
48
src/short-urls/reducers/shortUrlVisits.js
Normal 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 }));
|
||||
};
|
42
src/short-urls/reducers/shortUrlsList.js
Normal 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 });
|
||||
}
|
||||
};
|
18
src/short-urls/reducers/shortUrlsListParams.js
Normal 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 });
|
32
src/utils/ColorGenerator.js
Normal 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
|
@ -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
|
@ -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}>×</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Tag.defaultProps = {
|
||||
colorGenerator: ColorGenerator
|
||||
};
|
21
src/utils/Tag.scss
Normal 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
|
@ -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;
|
4
src/utils/mixins/border-radius.scss
Normal file
|
@ -0,0 +1,4 @@
|
|||
@mixin border-radius($radius) {
|
||||
border-radius: $radius;
|
||||
-webkit-border-radius: $radius;
|
||||
}
|
4
src/utils/mixins/box-shadow.scss
Normal file
|
@ -0,0 +1,4 @@
|
|||
@mixin box-shadow($shadow) {
|
||||
-webkit-box-shadow: $shadow;
|
||||
box-shadow: $shadow;
|
||||
}
|
5
src/utils/mixins/horizontal-align.scss
Normal file
|
@ -0,0 +1,5 @@
|
|||
@mixin horizontal-align {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
5
src/utils/mixins/vertical-align.scss
Normal file
|
@ -0,0 +1,5 @@
|
|||
@mixin vertical-align {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
101
src/visits/services/VisitsParser.js
Normal 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();
|
26
test/api/ShlinkApiClient.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
22
test/servers/ServersDropdown.test.js
Normal 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);
|
||||
});
|
||||
});
|