mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-04-20 05:26:20 +00:00
Updated styles in javascript to fulfill adidas rules
This commit is contained in:
26
src/App.js
26
src/App.js
@@ -6,20 +6,18 @@ 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="container-fluid app-container">
|
||||
<MainHeader/>
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="container-fluid app-container">
|
||||
<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 className="app">
|
||||
<Switch>
|
||||
<Route exact path="/server/create" component={CreateServer} />
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="/server/:serverId" component={MenuLayout} />
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import qs from 'qs';
|
||||
import { isEmpty, isNil, reject } from 'ramda';
|
||||
|
||||
const API_VERSION = '1';
|
||||
const STATUS_UNAUTHORIZED = 401;
|
||||
|
||||
export class ShlinkApiClient {
|
||||
constructor(axios) {
|
||||
@@ -14,8 +15,6 @@ export class ShlinkApiClient {
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
@@ -24,45 +23,46 @@ export class ShlinkApiClient {
|
||||
|
||||
listShortUrls = (options = {}) =>
|
||||
this._performRequest('/short-codes', 'GET', options)
|
||||
.then(resp => resp.data.shortUrls)
|
||||
.catch(e => this._handleAuthError(e, this.listShortUrls, [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);
|
||||
|
||||
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]));
|
||||
.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]));
|
||||
.then((resp) => resp.data.visits.data)
|
||||
.catch((e) => this._handleAuthError(e, this.getShortUrlVisits, [ shortCode, dates ]));
|
||||
|
||||
getShortUrl = shortCode =>
|
||||
getShortUrl = (shortCode) =>
|
||||
this._performRequest(`/short-codes/${shortCode}`, 'GET')
|
||||
.then(resp => resp.data)
|
||||
.catch(e => this._handleAuthError(e, this.getShortUrl, [shortCode]));
|
||||
.then((resp) => resp.data)
|
||||
.catch((e) => this._handleAuthError(e, this.getShortUrl, [ shortCode ]));
|
||||
|
||||
updateShortUrlTags = (shortCode, tags) =>
|
||||
this._performRequest(`/short-codes/${shortCode}/tags`, 'PUT', {}, { tags })
|
||||
.then(resp => resp.data.tags)
|
||||
.catch(e => this._handleAuthError(e, this.updateShortUrlTags, [shortCode, tags]));
|
||||
.then((resp) => resp.data.tags)
|
||||
.catch((e) => this._handleAuthError(e, this.updateShortUrlTags, [ shortCode, tags ]));
|
||||
|
||||
listTags = () =>
|
||||
this._performRequest('/tags', 'GET')
|
||||
.then(resp => resp.data.tags.data)
|
||||
.catch(e => this._handleAuthError(e, this.listTags, []));
|
||||
.then((resp) => resp.data.tags.data)
|
||||
.catch((e) => this._handleAuthError(e, this.listTags, []));
|
||||
|
||||
deleteTags = tags =>
|
||||
deleteTags = (tags) =>
|
||||
this._performRequest('/tags', 'DELETE', { tags })
|
||||
.then(() => ({ tags }))
|
||||
.catch(e => this._handleAuthError(e, this.deleteTags, [tags]));
|
||||
.catch((e) => this._handleAuthError(e, this.deleteTags, [ tags ]));
|
||||
|
||||
editTag = (oldName, newName) =>
|
||||
this._performRequest('/tags', 'PUT', {}, { oldName, newName })
|
||||
.then(() => ({ oldName, newName }))
|
||||
.catch(e => this._handleAuthError(e, this.editTag, [oldName, newName]));
|
||||
.catch((e) => this._handleAuthError(e, this.editTag, [ oldName, newName ]));
|
||||
|
||||
_performRequest = async (url, method = 'GET', query = {}, body = {}) => {
|
||||
if (isEmpty(this._token)) {
|
||||
@@ -72,14 +72,16 @@ export class ShlinkApiClient {
|
||||
return await this.axios({
|
||||
method,
|
||||
url: `${this._baseUrl}${url}`,
|
||||
headers: { 'Authorization': `Bearer ${this._token}` },
|
||||
headers: { Authorization: `Bearer ${this._token}` },
|
||||
params: query,
|
||||
data: body,
|
||||
paramsSerializer: params => qs.stringify(params, { arrayFormat: 'brackets' })
|
||||
}).then(resp => {
|
||||
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
|
||||
}).then((resp) => {
|
||||
// Save new token
|
||||
const { authorization = '' } = resp.headers;
|
||||
|
||||
this._token = authorization.substr('Bearer '.length);
|
||||
|
||||
return resp;
|
||||
});
|
||||
};
|
||||
@@ -88,15 +90,17 @@ export class ShlinkApiClient {
|
||||
const resp = await this.axios({
|
||||
method: 'POST',
|
||||
url: `${this._baseUrl}/authenticate`,
|
||||
data: { apiKey: this._apiKey }
|
||||
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) {
|
||||
if (e.response.status === STATUS_UNAUTHORIZED) {
|
||||
this._token = '';
|
||||
|
||||
return method(...args);
|
||||
}
|
||||
|
||||
@@ -105,4 +109,6 @@ export class ShlinkApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
export default new ShlinkApiClient(axios);
|
||||
const shlinkApiClient = new ShlinkApiClient(axios);
|
||||
|
||||
export default shlinkApiClient;
|
||||
|
||||
@@ -4,11 +4,11 @@ import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
|
||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import DeleteServerButton from '../servers/DeleteServerButton';
|
||||
import './AsideMenu.scss';
|
||||
import PropTypes from 'prop-types';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const defaultProps = {
|
||||
className: '',
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import chevronIcon from '@fortawesome/fontawesome-free-solid/faChevronRight'
|
||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome'
|
||||
import { isEmpty, pick, values } from 'ramda'
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ListGroup, ListGroupItem } from 'reactstrap'
|
||||
import { resetSelectedServer } from '../servers/reducers/selectedServer'
|
||||
import './Home.scss'
|
||||
import chevronIcon from '@fortawesome/fontawesome-free-solid/faChevronRight';
|
||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||
import { isEmpty, pick, values } from 'ramda';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ListGroup, ListGroupItem } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { resetSelectedServer } from '../servers/reducers/selectedServer';
|
||||
import './Home.scss';
|
||||
|
||||
export class Home extends React.Component {
|
||||
const propTypes = {
|
||||
resetSelectedServer: PropTypes.func,
|
||||
servers: PropTypes.object,
|
||||
};
|
||||
|
||||
export class HomeComponent extends React.Component {
|
||||
componentDidMount() {
|
||||
this.props.resetSelectedServer();
|
||||
}
|
||||
@@ -45,4 +51,8 @@ export class Home extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(pick(['servers']), { resetSelectedServer })(Home);
|
||||
HomeComponent.propTypes = propTypes;
|
||||
|
||||
const Home = connect(pick([ 'servers' ]), { resetSelectedServer })(HomeComponent);
|
||||
|
||||
export default Home;
|
||||
|
||||
@@ -2,18 +2,23 @@ import plusIcon from '@fortawesome/fontawesome-free-solid/faPlus';
|
||||
import arrowIcon from '@fortawesome/fontawesome-free-solid/faChevronDown';
|
||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import { Link, withRouter } from 'react-router-dom'
|
||||
import { Link, withRouter } from 'react-router-dom';
|
||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||
import classnames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import ServersDropdown from '../servers/ServersDropdown';
|
||||
import './MainHeader.scss';
|
||||
import shlinkLogo from './shlink-logo-white.png';
|
||||
import classnames from 'classnames';
|
||||
|
||||
export class MainHeader extends React.Component {
|
||||
const propTypes = {
|
||||
location: PropTypes.object,
|
||||
};
|
||||
|
||||
export class MainHeaderComponent extends React.Component {
|
||||
state = { isOpen: false };
|
||||
toggle = () => {
|
||||
handleToggle = () => {
|
||||
this.setState(({ isOpen }) => ({
|
||||
isOpen: !isOpen
|
||||
isOpen: !isOpen,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -33,10 +38,10 @@ export class MainHeader extends React.Component {
|
||||
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
|
||||
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
|
||||
</NavbarBrand>
|
||||
|
||||
<NavbarToggler onClick={this.toggle}>
|
||||
<NavbarToggler onClick={this.handleToggle}>
|
||||
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
||||
</NavbarToggler>
|
||||
|
||||
@@ -48,7 +53,7 @@ export class MainHeader extends React.Component {
|
||||
to={createServerPath}
|
||||
active={location.pathname === createServerPath}
|
||||
>
|
||||
<FontAwesomeIcon icon={plusIcon}/> Add server
|
||||
<FontAwesomeIcon icon={plusIcon} /> Add server
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<ServersDropdown />
|
||||
@@ -59,4 +64,8 @@ export class MainHeader extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(MainHeader);
|
||||
MainHeaderComponent.propTypes = propTypes;
|
||||
|
||||
const MainHeader = withRouter(MainHeaderComponent);
|
||||
|
||||
export default MainHeader;
|
||||
|
||||
@@ -2,26 +2,38 @@ import React from 'react';
|
||||
import { Route, Switch, withRouter } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
import { compose } from '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';
|
||||
import Swipeable from 'react-swipeable';
|
||||
import burgerIcon from '@fortawesome/fontawesome-free-solid/faBars';
|
||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||
import classnames from 'classnames';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import ShortUrlsVisits from '../short-urls/ShortUrlVisits';
|
||||
import { selectServer } from '../servers/reducers/selectedServer';
|
||||
import CreateShortUrl from '../short-urls/CreateShortUrl';
|
||||
import ShortUrls from '../short-urls/ShortUrls';
|
||||
import './MenuLayout.scss';
|
||||
import TagsList from '../tags/TagsList';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import AsideMenu from './AsideMenu';
|
||||
|
||||
export class MenuLayout extends React.Component {
|
||||
const propTypes = {
|
||||
match: PropTypes.object,
|
||||
selectServer: PropTypes.func,
|
||||
location: PropTypes.object,
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
export class MenuLayoutComponent extends React.Component {
|
||||
state = { showSideBar: false };
|
||||
|
||||
// FIXME Shouldn't use componentWillMount, but this code has to be run before children components are rendered
|
||||
/* eslint react/no-deprecated: "off" */
|
||||
componentWillMount() {
|
||||
const { serverId } = this.props.match.params;
|
||||
this.props.selectServer(serverId);
|
||||
const { match, selectServer } = this.props;
|
||||
const { params: { serverId } } = match;
|
||||
|
||||
selectServer(serverId);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@@ -44,14 +56,14 @@ export class MenuLayout extends React.Component {
|
||||
<FontAwesomeIcon
|
||||
icon={burgerIcon}
|
||||
className={burgerClasses}
|
||||
onClick={() => this.setState({ showSideBar: !this.state.showSideBar })}
|
||||
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
|
||||
/>
|
||||
|
||||
<Swipeable
|
||||
delta={40}
|
||||
className="menu-layout__swipeable"
|
||||
onSwipedLeft={() => this.setState({ showSideBar: false })}
|
||||
onSwipedRight={() => this.setState({ showSideBar: true })}
|
||||
className="menu-layout__swipeable"
|
||||
>
|
||||
<div className="row menu-layout__swipeable-inner">
|
||||
<AsideMenu
|
||||
@@ -93,7 +105,11 @@ export class MenuLayout extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default compose(
|
||||
connect(pick(['selectedServer', 'shortUrlsListParams']), { selectServer }),
|
||||
MenuLayoutComponent.propTypes = propTypes;
|
||||
|
||||
const MenuLayout = compose(
|
||||
connect(pick([ 'selectedServer', 'shortUrlsListParams' ]), { selectServer }),
|
||||
withRouter
|
||||
)(MenuLayout);
|
||||
)(MenuLayoutComponent);
|
||||
|
||||
export default MenuLayout;
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import React from 'react';
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export class ScrollToTop extends React.Component {
|
||||
const propTypes = {
|
||||
location: PropTypes.object,
|
||||
window: PropTypes.shape({
|
||||
scrollTo: PropTypes.func,
|
||||
}),
|
||||
children: PropTypes.node,
|
||||
};
|
||||
const defaultProps = {
|
||||
window,
|
||||
};
|
||||
|
||||
export class ScrollToTopComponent extends React.Component {
|
||||
componentDidUpdate(prevProps) {
|
||||
const { location, window } = this.props;
|
||||
|
||||
if (location !== prevProps.location) {
|
||||
window.scrollTo(0, 0)
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +27,9 @@ export class ScrollToTop extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
ScrollToTop.defaultProps = {
|
||||
window
|
||||
};
|
||||
ScrollToTopComponent.defaultProps = defaultProps;
|
||||
ScrollToTopComponent.propTypes = propTypes;
|
||||
|
||||
export default withRouter(ScrollToTop);
|
||||
const ScrollToTop = withRouter(ScrollToTopComponent);
|
||||
|
||||
export default ScrollToTop;
|
||||
|
||||
@@ -5,15 +5,13 @@ import { Provider } from 'react-redux';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { applyMiddleware, compose, createStore } from 'redux';
|
||||
import ReduxThunk from 'redux-thunk';
|
||||
|
||||
import '../node_modules/react-datepicker/dist/react-datepicker.css';
|
||||
import './common/react-tagsinput.scss';
|
||||
|
||||
import App from './App';
|
||||
import './index.scss';
|
||||
import ScrollToTop from './common/ScrollToTop'
|
||||
import ScrollToTop from './common/ScrollToTop';
|
||||
import reducers from './reducers';
|
||||
import registerServiceWorker from './registerServiceWorker';
|
||||
import '../node_modules/react-datepicker/dist/react-datepicker.css';
|
||||
import './common/react-tagsinput.scss';
|
||||
|
||||
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
||||
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { combineReducers } from 'redux';
|
||||
|
||||
import serversReducer from '../servers/reducers/server';
|
||||
import selectedServerReducer from '../servers/reducers/selectedServer';
|
||||
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
|
||||
|
||||
@@ -8,10 +8,14 @@
|
||||
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
|
||||
// This link also includes instructions on opting out of this behavior.
|
||||
|
||||
/* eslint no-console: "off" */
|
||||
|
||||
const isLocalhost = Boolean(
|
||||
window.location.hostname === 'localhost' ||
|
||||
|
||||
// [::1] is the IPv6 localhost address.
|
||||
window.location.hostname === '[::1]' ||
|
||||
|
||||
// 127.0.0.1/8 is considered localhost for IPv4.
|
||||
window.location.hostname.match(
|
||||
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||
@@ -22,6 +26,7 @@ export default function register() {
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
|
||||
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
@@ -55,9 +60,10 @@ export default function register() {
|
||||
function registerValidSW(swUrl) {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then(registration => {
|
||||
.then((registration) => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
@@ -76,7 +82,7 @@ function registerValidSW(swUrl) {
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error('Error during service worker registration:', error);
|
||||
});
|
||||
}
|
||||
@@ -84,14 +90,16 @@ function registerValidSW(swUrl) {
|
||||
function checkValidServiceWorker(swUrl) {
|
||||
// Check if the service worker can be found. If it can't reload the page.
|
||||
fetch(swUrl)
|
||||
.then(response => {
|
||||
.then((response) => {
|
||||
const NOT_FOUND_STATUS = 404;
|
||||
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
if (
|
||||
response.status === 404 ||
|
||||
response.status === NOT_FOUND_STATUS ||
|
||||
response.headers.get('content-type').indexOf('javascript') === -1
|
||||
) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
registration.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
@@ -110,7 +118,7 @@ function checkValidServiceWorker(swUrl) {
|
||||
|
||||
export function unregister() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
registration.unregister();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { assoc, dissoc, pick, pipe } 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 PropTypes from 'prop-types';
|
||||
import { resetSelectedServer } from './reducers/selectedServer';
|
||||
import { createServer } from './reducers/server';
|
||||
import './CreateServer.scss';
|
||||
import ImportServersBtn from './helpers/ImportServersBtn';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||
const propTypes = {
|
||||
createServer: PropTypes.func,
|
||||
history: PropTypes.shape({
|
||||
@@ -16,7 +17,7 @@ const propTypes = {
|
||||
resetSelectedServer: PropTypes.func,
|
||||
};
|
||||
|
||||
export class CreateServer extends React.Component {
|
||||
export class CreateServerComponent extends React.Component {
|
||||
state = {
|
||||
name: '',
|
||||
url: '',
|
||||
@@ -24,7 +25,7 @@ export class CreateServer extends React.Component {
|
||||
serversImported: false,
|
||||
};
|
||||
|
||||
submit = e => {
|
||||
handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { createServer, history: { push } } = this.props;
|
||||
@@ -34,7 +35,7 @@ export class CreateServer extends React.Component {
|
||||
)(this.state);
|
||||
|
||||
createServer(server);
|
||||
push(`/server/${server.id}/list-short-urls/1`)
|
||||
push(`/server/${server.id}/list-short-urls/1`);
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
@@ -42,7 +43,7 @@ export class CreateServer extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const renderInputGroup = (id, placeholder, type = 'text') =>
|
||||
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}:
|
||||
@@ -54,24 +55,27 @@ export class CreateServer extends React.Component {
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
value={this.state[id]}
|
||||
onChange={e => this.setState({ [id]: e.target.value })}
|
||||
required
|
||||
onChange={(e) => this.setState({ [id]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>;
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="create-server">
|
||||
<form onSubmit={this.submit}>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
{renderInputGroup('name', 'Name')}
|
||||
{renderInputGroup('url', 'URL', 'url')}
|
||||
{renderInputGroup('apiKey', 'API key')}
|
||||
|
||||
<div className="text-right">
|
||||
<ImportServersBtn onImport={() => {
|
||||
this.setState({ serversImported: true });
|
||||
setTimeout(() => this.setState({ serversImported: false }), 4000);
|
||||
}} />
|
||||
<ImportServersBtn
|
||||
onImport={() => {
|
||||
this.setState({ serversImported: true });
|
||||
setTimeout(() => this.setState({ serversImported: false }), SHOW_IMPORT_MSG_TIME);
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-outline-primary">Create server</button>
|
||||
</div>
|
||||
|
||||
@@ -90,9 +94,11 @@ export class CreateServer extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
CreateServer.propTypes = propTypes;
|
||||
CreateServerComponent.propTypes = propTypes;
|
||||
|
||||
export default connect(
|
||||
pick(['selectedServer']),
|
||||
{createServer, resetSelectedServer }
|
||||
)(CreateServer);
|
||||
const CreateServer = connect(
|
||||
pick([ 'selectedServer' ]),
|
||||
{ createServer, resetSelectedServer }
|
||||
)(CreateServerComponent);
|
||||
|
||||
export default CreateServer;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import deleteIcon from '@fortawesome/fontawesome-free-solid/faMinusCircle';
|
||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import DeleteServerModal from './DeleteServerModal';
|
||||
import { serverType } from './prop-types';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const propTypes = {
|
||||
server: serverType,
|
||||
@@ -20,8 +20,8 @@ export default class DeleteServerButton extends React.Component {
|
||||
<React.Fragment>
|
||||
<span
|
||||
className={className}
|
||||
onClick={() => this.setState({ isModalOpen: true })}
|
||||
key="deleteServerBtn"
|
||||
onClick={() => this.setState({ isModalOpen: true })}
|
||||
>
|
||||
<FontAwesomeIcon icon={deleteIcon} />
|
||||
<span className="aside-menu__item-text">Delete this server</span>
|
||||
@@ -29,7 +29,7 @@ export default class DeleteServerButton extends React.Component {
|
||||
|
||||
<DeleteServerModal
|
||||
isOpen={this.state.isModalOpen}
|
||||
toggle={() => this.setState({ isModalOpen: !this.state.isModalOpen })}
|
||||
toggle={() => this.setState(({ isModalOpen }) => ({ isModalOpen: !isModalOpen }))}
|
||||
server={server}
|
||||
key="deleteServerModal"
|
||||
/>
|
||||
|
||||
@@ -11,9 +11,13 @@ const propTypes = {
|
||||
toggle: PropTypes.func.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
server: serverType,
|
||||
deleteServer: PropTypes.func,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}),
|
||||
};
|
||||
|
||||
export const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) => {
|
||||
export const DeleteServerModalComponent = ({ server, toggle, isOpen, deleteServer, history }) => {
|
||||
const closeModal = () => {
|
||||
deleteServer(server);
|
||||
toggle();
|
||||
@@ -38,9 +42,11 @@ export const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, histor
|
||||
);
|
||||
};
|
||||
|
||||
DeleteServerModal.propTypes = propTypes;
|
||||
DeleteServerModalComponent.propTypes = propTypes;
|
||||
|
||||
export default compose(
|
||||
const DeleteServerModal = compose(
|
||||
withRouter,
|
||||
connect(null, { deleteServer })
|
||||
)(DeleteServerModal);
|
||||
)(DeleteServerModalComponent);
|
||||
|
||||
export default DeleteServerModal;
|
||||
|
||||
@@ -3,21 +3,31 @@ 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 PropTypes from 'prop-types';
|
||||
import { selectServer } from '../servers/reducers/selectedServer';
|
||||
import serversExporter from '../servers/services/ServersExporter';
|
||||
import { listServers } from './reducers/server';
|
||||
import { serverType } from './prop-types';
|
||||
|
||||
const defaultProps = {
|
||||
serversExporter,
|
||||
};
|
||||
const propTypes = {
|
||||
servers: PropTypes.object,
|
||||
serversExporter: PropTypes.shape({
|
||||
exportServers: PropTypes.func,
|
||||
}),
|
||||
selectedServer: serverType,
|
||||
selectServer: PropTypes.func,
|
||||
listServers: PropTypes.func,
|
||||
};
|
||||
|
||||
export class ServersDropdown extends React.Component {
|
||||
export class ServersDropdownComponent extends React.Component {
|
||||
renderServers = () => {
|
||||
const { servers, selectedServer, selectServer, serversExporter } = this.props;
|
||||
|
||||
if (isEmpty(servers)) {
|
||||
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>
|
||||
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -28,15 +38,17 @@ export class ServersDropdown extends React.Component {
|
||||
tag={Link}
|
||||
to={`/server/${id}/list-short-urls/1`}
|
||||
active={selectedServer && selectedServer.id === id}
|
||||
onClick={() => selectServer(id)} // FIXME This should be implicit
|
||||
|
||||
// FIXME This should be implicit
|
||||
onClick={() => selectServer(id)}
|
||||
>
|
||||
{name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
<DropdownItem
|
||||
onClick={serversExporter.exportServers}
|
||||
className="servers-dropdown__export-item"
|
||||
onClick={() => serversExporter.exportServers()}
|
||||
>
|
||||
Export servers
|
||||
</DropdownItem>
|
||||
@@ -58,9 +70,12 @@ export class ServersDropdown extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
ServersDropdown.defaultProps = defaultProps;
|
||||
ServersDropdownComponent.defaultProps = defaultProps;
|
||||
ServersDropdownComponent.propTypes = propTypes;
|
||||
|
||||
export default connect(
|
||||
pick(['servers', 'selectedServer']),
|
||||
const ServersDropdown = connect(
|
||||
pick([ 'servers', 'selectedServer' ]),
|
||||
{ listServers, selectServer }
|
||||
)(ServersDropdown);
|
||||
)(ServersDropdownComponent);
|
||||
|
||||
export default ServersDropdown;
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import React from 'react';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import serversImporter, { serversImporterType } from '../services/ServersImporter';
|
||||
import { createServers } from '../reducers/server';
|
||||
import { assoc } from 'ramda';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import PropTypes from 'prop-types';
|
||||
import { createServers } from '../reducers/server';
|
||||
import serversImporter, { serversImporterType } from '../services/ServersImporter';
|
||||
|
||||
const defaultProps = {
|
||||
serversImporter,
|
||||
onImport: () => {},
|
||||
onImport: () => ({}),
|
||||
};
|
||||
const propTypes = {
|
||||
onImport: PropTypes.func,
|
||||
serversImporter: serversImporterType,
|
||||
createServers: PropTypes.func,
|
||||
fileRef: PropTypes.oneOfType([PropTypes.object, PropTypes.node]),
|
||||
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
|
||||
};
|
||||
|
||||
export class ImportServersBtn extends React.Component {
|
||||
export class ImportServersBtnComponent extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.fileRef = props.fileRef || React.createRef();
|
||||
@@ -26,9 +26,9 @@ export class ImportServersBtn extends React.Component {
|
||||
|
||||
render() {
|
||||
const { serversImporter: { importServersFromFile }, onImport, createServers } = this.props;
|
||||
const onChange = e =>
|
||||
const onChange = (e) =>
|
||||
importServersFromFile(e.target.files[0])
|
||||
.then(servers => servers.map(server => assoc('id', uuid(), server)))
|
||||
.then((servers) => servers.map((server) => assoc('id', uuid(), server)))
|
||||
.then(createServers)
|
||||
.then(onImport);
|
||||
|
||||
@@ -37,28 +37,30 @@ export class ImportServersBtn extends React.Component {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary mr-2"
|
||||
onClick={() => this.fileRef.current.click()}
|
||||
id="importBtn"
|
||||
onClick={() => this.fileRef.current.click()}
|
||||
>
|
||||
Import from file
|
||||
</button>
|
||||
<UncontrolledTooltip placement="top" target="importBtn">
|
||||
You can create servers by importing a CSV file with columns "name", "apiKey" and "url"
|
||||
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>
|
||||
</UncontrolledTooltip>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
onChange={onChange}
|
||||
accept="text/csv"
|
||||
className="create-server__csv-select"
|
||||
ref={this.fileRef}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportServersBtn.defaultProps = defaultProps;
|
||||
ImportServersBtn.propTypes = propTypes;
|
||||
ImportServersBtnComponent.defaultProps = defaultProps;
|
||||
ImportServersBtnComponent.propTypes = propTypes;
|
||||
|
||||
export default connect(null, { createServers })(ImportServersBtn);
|
||||
const ImportServersBtn = connect(null, { createServers })(ImportServersBtnComponent);
|
||||
|
||||
export default ImportServersBtn;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import ShlinkApiClient from '../../api/ShlinkApiClient';
|
||||
import serversService from '../../servers/services/ServersService';
|
||||
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams'
|
||||
import { curry } from 'ramda';
|
||||
|
||||
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
||||
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
|
||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
||||
import serversService from '../../servers/services/ServersService';
|
||||
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||
|
||||
const defaultState = null;
|
||||
|
||||
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
||||
|
||||
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
|
||||
|
||||
export default function reducer(state = defaultState, action) {
|
||||
switch (action.type) {
|
||||
case SELECT_SERVER:
|
||||
@@ -21,15 +22,17 @@ export default function reducer(state = defaultState, action) {
|
||||
|
||||
export const resetSelectedServer = () => ({ type: RESET_SELECTED_SERVER });
|
||||
|
||||
export const _selectServer = (ShlinkApiClient, serversService, serverId) => dispatch => {
|
||||
export const _selectServer = (shlinkApiClient, serversService, serverId) => (dispatch) => {
|
||||
dispatch(resetShortUrlParams());
|
||||
|
||||
const selectedServer = serversService.findServerById(serverId);
|
||||
ShlinkApiClient.setConfig(selectedServer);
|
||||
|
||||
shlinkApiClient.setConfig(selectedServer);
|
||||
|
||||
dispatch({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer
|
||||
})
|
||||
selectedServer,
|
||||
});
|
||||
};
|
||||
export const selectServer = curry(_selectServer)(ShlinkApiClient, serversService);
|
||||
|
||||
export const selectServer = curry(_selectServer)(shlinkApiClient, serversService);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import serversService from '../services/ServersService';
|
||||
import { curry } from 'ramda';
|
||||
import serversService from '../services/ServersService';
|
||||
|
||||
export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS';
|
||||
|
||||
@@ -12,26 +12,33 @@ export default function reducer(state = {}, action) {
|
||||
}
|
||||
}
|
||||
|
||||
export const _listServers = serversService => ({
|
||||
export const _listServers = (serversService) => ({
|
||||
type: FETCH_SERVERS,
|
||||
servers: serversService.listServers(),
|
||||
});
|
||||
|
||||
export const listServers = () => _listServers(serversService);
|
||||
|
||||
export const _createServer = (serversService, server) => {
|
||||
serversService.createServer(server);
|
||||
|
||||
return _listServers(serversService);
|
||||
};
|
||||
|
||||
export const createServer = curry(_createServer)(serversService);
|
||||
|
||||
export const _deleteServer = (serversService, server) => {
|
||||
serversService.deleteServer(server);
|
||||
|
||||
return _listServers(serversService);
|
||||
};
|
||||
|
||||
export const deleteServer = curry(_deleteServer)(serversService);
|
||||
|
||||
export const _createServers = (serversService, servers) => {
|
||||
serversService.createServers(servers);
|
||||
|
||||
return _listServers(serversService);
|
||||
};
|
||||
|
||||
export const createServers = curry(_createServers)(serversService);
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import serversService from './ServersService';
|
||||
import { dissoc, head, keys, values } from 'ramda';
|
||||
import csvjson from 'csvjson';
|
||||
import serversService from './ServersService';
|
||||
|
||||
const saveCsv = (window, csv) => {
|
||||
const { navigator, document } = window;
|
||||
const filename = 'shlink-servers.csv';
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
|
||||
|
||||
// IE10 and IE11
|
||||
if (navigator.msSaveBlob) {
|
||||
navigator.msSaveBlob(blob, filename);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Modern browsers
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', filename);
|
||||
link.style.visibility = 'hidden';
|
||||
@@ -36,15 +38,18 @@ export class ServersExporter {
|
||||
|
||||
try {
|
||||
const csv = this.csvjson.toCSV(servers, {
|
||||
headers: keys(head(servers)).join(',')
|
||||
headers: keys(head(servers)).join(','),
|
||||
});
|
||||
|
||||
saveCsv(this.window, csv);
|
||||
} catch (e) {
|
||||
// FIXME Handle error
|
||||
/* eslint no-console: "off" */
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const serverExporter = new ServersExporter(serversService, global.window, csvjson);
|
||||
|
||||
export default serverExporter;
|
||||
|
||||
@@ -16,8 +16,9 @@ export class ServersImporter {
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
return new Promise(resolve => {
|
||||
reader.addEventListener('loadend', e => {
|
||||
|
||||
return new Promise((resolve) => {
|
||||
reader.addEventListener('loadend', (e) => {
|
||||
const content = e.target.result;
|
||||
const servers = this.csvjson.toObject(content);
|
||||
|
||||
@@ -29,4 +30,5 @@ export class ServersImporter {
|
||||
}
|
||||
|
||||
const serversImporter = new ServersImporter(csvjson);
|
||||
|
||||
export default serversImporter;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Storage from '../../utils/Storage';
|
||||
import { assoc, dissoc, reduce } from 'ramda';
|
||||
import storage from '../../utils/Storage';
|
||||
|
||||
const SERVERS_STORAGE_KEY = 'servers';
|
||||
|
||||
@@ -10,25 +10,27 @@ export class ServersService {
|
||||
|
||||
listServers = () => this.storage.get(SERVERS_STORAGE_KEY) || {};
|
||||
|
||||
findServerById = serverId => this.listServers()[serverId];
|
||||
findServerById = (serverId) => this.listServers()[serverId];
|
||||
|
||||
createServer = server => this.createServers([server]);
|
||||
createServer = (server) => this.createServers([ server ]);
|
||||
|
||||
createServers = servers => {
|
||||
createServers = (servers) => {
|
||||
const allServers = reduce(
|
||||
(serversObj, server) => assoc(server.id, server, serversObj),
|
||||
this.listServers(),
|
||||
servers
|
||||
);
|
||||
|
||||
this.storage.set(SERVERS_STORAGE_KEY, allServers);
|
||||
};
|
||||
|
||||
deleteServer = server =>
|
||||
deleteServer = (server) =>
|
||||
this.storage.set(
|
||||
SERVERS_STORAGE_KEY,
|
||||
dissoc(server.id, this.listServers())
|
||||
);
|
||||
}
|
||||
|
||||
const serversService = new ServersService(Storage);
|
||||
const serversService = new ServersService(storage);
|
||||
|
||||
export default serversService;
|
||||
|
||||
@@ -6,11 +6,11 @@ import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Collapse } from 'reactstrap';
|
||||
import DateInput from '../common/DateInput';
|
||||
import TagsSelector from '../utils/TagsSelector';
|
||||
import CreateShortUrlResult from './helpers/CreateShortUrlResult';
|
||||
import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreationResult';
|
||||
import TagsSelector from '../utils/TagsSelector';
|
||||
|
||||
export class CreateShortUrl extends React.Component {
|
||||
export class CreateShortUrlComponent extends React.Component {
|
||||
state = {
|
||||
longUrl: '',
|
||||
tags: [],
|
||||
@@ -18,35 +18,37 @@ export class CreateShortUrl extends React.Component {
|
||||
validSince: undefined,
|
||||
validUntil: undefined,
|
||||
maxVisits: undefined,
|
||||
moreOptionsVisible: false
|
||||
moreOptionsVisible: false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props;
|
||||
|
||||
const changeTags = tags => this.setState({ tags: tags.map(pipe(trim, replace(/ /g, '-'))) });
|
||||
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) =>
|
||||
const changeTags = (tags) => this.setState({ tags: tags.map(pipe(trim, replace(/ /g, '-'))) });
|
||||
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 })}
|
||||
onChange={(e) => this.setState({ [id]: e.target.value })}
|
||||
{...props}
|
||||
/>;
|
||||
const createDateInput = (id, placeholder, props = {}) =>
|
||||
/>
|
||||
);
|
||||
const createDateInput = (id, placeholder, props = {}) => (
|
||||
<DateInput
|
||||
selected={this.state[id]}
|
||||
placeholderText={placeholder}
|
||||
onChange={date => this.setState({ [id]: date })}
|
||||
isClearable
|
||||
onChange={(date) => this.setState({ [id]: date })}
|
||||
{...props}
|
||||
/>;
|
||||
const formatDate = date => isNil(date) ? date : date.format();
|
||||
const save = e => {
|
||||
/>
|
||||
);
|
||||
const formatDate = (date) => isNil(date) ? date : date.format();
|
||||
const save = (e) => {
|
||||
e.preventDefault();
|
||||
createShortUrl(pipe(
|
||||
dissoc('moreOptionsVisible'), // Remove moreOptionsVisible property
|
||||
dissoc('moreOptionsVisible'),
|
||||
assoc('validSince', formatDate(this.state.validSince)),
|
||||
assoc('validUntil', formatDate(this.state.validUntil))
|
||||
)(this.state));
|
||||
@@ -62,7 +64,7 @@ export class CreateShortUrl extends React.Component {
|
||||
placeholder="Insert the URL to be shortened"
|
||||
required
|
||||
value={this.state.longUrl}
|
||||
onChange={e => this.setState({ longUrl: e.target.value })}
|
||||
onChange={(e) => this.setState({ longUrl: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -95,7 +97,7 @@ export class CreateShortUrl extends React.Component {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary create-short-url__btn"
|
||||
onClick={() => this.setState({ moreOptionsVisible: !this.state.moreOptionsVisible })}
|
||||
onClick={() => this.setState(({ moreOptionsVisible }) => ({ moreOptionsVisible: !moreOptionsVisible }))}
|
||||
>
|
||||
<FontAwesomeIcon icon={this.state.moreOptionsVisible ? upIcon : downIcon} />
|
||||
|
||||
@@ -116,7 +118,9 @@ export class CreateShortUrl extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(pick(['shortUrlCreationResult']), {
|
||||
const CreateShortUrl = connect(pick([ 'shortUrlCreationResult' ]), {
|
||||
createShortUrl,
|
||||
resetCreateShortUrl
|
||||
})(CreateShortUrl);
|
||||
resetCreateShortUrl,
|
||||
})(CreateShortUrlComponent);
|
||||
|
||||
export default CreateShortUrl;
|
||||
|
||||
@@ -1,52 +1,61 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default class Paginator extends React.Component {
|
||||
render() {
|
||||
const { paginator = {}, serverId } = this.props;
|
||||
const { currentPage, pagesCount = 0 } = paginator;
|
||||
if (pagesCount <= 1) {
|
||||
return null;
|
||||
const propTypes = {
|
||||
serverId: PropTypes.string.isRequired,
|
||||
paginator: PropTypes.shape({
|
||||
currentPage: PropTypes.number,
|
||||
pagesCount: PropTypes.number,
|
||||
}),
|
||||
};
|
||||
|
||||
export default function Paginator({ paginator = {}, serverId }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const renderPages = () => {
|
||||
const pages = [];
|
||||
return 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Paginator.propTypes = propTypes;
|
||||
|
||||
@@ -2,26 +2,34 @@ import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
|
||||
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 { isEmpty, pick } from 'ramda';
|
||||
import PropTypes from 'prop-types';
|
||||
import Tag from '../utils/Tag';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { listShortUrls } from './reducers/shortUrlsList';
|
||||
import './SearchBar.scss';
|
||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||
|
||||
export function SearchBar({ listShortUrls, shortUrlsListParams }) {
|
||||
const propTypes = {
|
||||
listShortUrls: PropTypes.func,
|
||||
shortUrlsListParams: shortUrlsListParamsType,
|
||||
};
|
||||
|
||||
export function SearchBarComponent({ listShortUrls, shortUrlsListParams }) {
|
||||
const selectedTags = shortUrlsListParams.tags || [];
|
||||
|
||||
return (
|
||||
<div className="serach-bar-container">
|
||||
<SearchField onChange={
|
||||
searchTerm => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
||||
}/>
|
||||
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
||||
}
|
||||
/>
|
||||
|
||||
{!isEmpty(selectedTags) && (
|
||||
<h4 className="search-bar__selected-tag mt-2">
|
||||
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon"/>
|
||||
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
||||
|
||||
{selectedTags.map(tag => (
|
||||
{selectedTags.map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
text={tag}
|
||||
@@ -29,7 +37,7 @@ export function SearchBar({ listShortUrls, shortUrlsListParams }) {
|
||||
onClose={() => listShortUrls(
|
||||
{
|
||||
...shortUrlsListParams,
|
||||
tags: selectedTags.filter(selectedTag => selectedTag !== tag)
|
||||
tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
|
||||
}
|
||||
)}
|
||||
/>
|
||||
@@ -40,4 +48,8 @@ export function SearchBar({ listShortUrls, shortUrlsListParams }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(pick(['shortUrlsListParams']), { listShortUrls })(SearchBar);
|
||||
SearchBarComponent.propTypes = propTypes;
|
||||
|
||||
const SearchBar = connect(pick([ 'shortUrlsListParams' ]), { listShortUrls })(SearchBarComponent);
|
||||
|
||||
export default SearchBar;
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
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 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 PropTypes from 'prop-types';
|
||||
import DateInput from '../common/DateInput';
|
||||
import {
|
||||
processOsStats,
|
||||
processBrowserStats,
|
||||
processCountriesStats,
|
||||
processReferrersStats,
|
||||
} from '../visits/services/VisitsParser'
|
||||
import { getShortUrlVisits } from './reducers/shortUrlVisits'
|
||||
import './ShortUrlVisits.scss'
|
||||
} from '../visits/services/VisitsParser';
|
||||
import MutedMessage from '../utils/MuttedMessage';
|
||||
import ExternalLink from '../utils/ExternalLink';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits';
|
||||
import './ShortUrlVisits.scss';
|
||||
|
||||
const propTypes = {
|
||||
processOsStats: PropTypes.func,
|
||||
processBrowserStats: PropTypes.func,
|
||||
processCountriesStats: PropTypes.func,
|
||||
processReferrersStats: PropTypes.func,
|
||||
match: PropTypes.object,
|
||||
getShortUrlVisits: PropTypes.func,
|
||||
selectedServer: serverType,
|
||||
shortUrlVisits: shortUrlVisitsType,
|
||||
};
|
||||
const defaultProps = {
|
||||
processOsStats,
|
||||
processBrowserStats,
|
||||
@@ -24,14 +37,15 @@ const defaultProps = {
|
||||
processReferrersStats,
|
||||
};
|
||||
|
||||
export class ShortUrlsVisits extends React.Component {
|
||||
export class ShortUrlsVisitsComponent 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,
|
||||
const { match: { params }, getShortUrlVisits } = this.props;
|
||||
|
||||
getShortUrlVisits(params.shortCode, mapObjIndexed(
|
||||
(value) => value && value.format ? value.format('YYYY-MM-DD') : value,
|
||||
this.state
|
||||
))
|
||||
));
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
@@ -46,7 +60,7 @@ export class ShortUrlsVisits extends React.Component {
|
||||
processBrowserStats,
|
||||
processCountriesStats,
|
||||
processReferrersStats,
|
||||
shortUrlVisits: { visits, loading, error, shortUrl }
|
||||
shortUrlVisits: { visits, loading, error, shortUrl },
|
||||
} = this.props;
|
||||
const serverUrl = selectedServer ? selectedServer.url : '';
|
||||
const shortLink = `${serverUrl}/${params.shortCode}`;
|
||||
@@ -63,31 +77,42 @@ export class ShortUrlsVisits extends React.Component {
|
||||
'#46BFBD',
|
||||
'#FDB45C',
|
||||
'#949FB1',
|
||||
'#4D5360'
|
||||
'#4D5360',
|
||||
],
|
||||
borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white',
|
||||
borderWidth: 2
|
||||
}
|
||||
]
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
const renderGraphCard = (title, stats, isBarChart, label) =>
|
||||
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
|
||||
}
|
||||
}} />}
|
||||
{!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>;
|
||||
</div>
|
||||
);
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> Loading...</MutedMessage>;
|
||||
@@ -115,13 +140,14 @@ export class ShortUrlsVisits extends React.Component {
|
||||
);
|
||||
};
|
||||
|
||||
const renderCreated = () =>
|
||||
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>;
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="shlink-container">
|
||||
@@ -133,20 +159,22 @@ export class ShortUrlsVisits extends React.Component {
|
||||
shortUrl.visitsCount &&
|
||||
<span className="badge badge-main float-right">Visits: {shortUrl.visitsCount}</span>
|
||||
}
|
||||
Visit stats for <a target="_blank" href={shortLink}>{shortLink}</a>
|
||||
Visit stats for <ExternalLink href={shortLink}>{shortLink}</ExternalLink>
|
||||
</h2>
|
||||
<hr />
|
||||
{shortUrl.dateCreated && <div>
|
||||
Created:
|
||||
|
||||
{loading && <small>Loading...</small>}
|
||||
{!loading && renderCreated()}
|
||||
</div>}
|
||||
{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>}
|
||||
{!loading && <ExternalLink href={shortUrl.longUrl}>{shortUrl.longUrl}</ExternalLink>}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -159,7 +187,7 @@ export class ShortUrlsVisits extends React.Component {
|
||||
selected={this.state.startDate}
|
||||
placeholderText="Since"
|
||||
isClearable
|
||||
onChange={date => this.setState({ startDate: date }, () => this.loadVisits())}
|
||||
onChange={(date) => this.setState({ startDate: date }, () => this.loadVisits())}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-xl-3 col-lg-4 col-md-6">
|
||||
@@ -167,8 +195,8 @@ export class ShortUrlsVisits extends React.Component {
|
||||
selected={this.state.endDate}
|
||||
placeholderText="Until"
|
||||
isClearable
|
||||
onChange={date => this.setState({ endDate: date }, () => this.loadVisits())}
|
||||
className="short-url-visits__date-input"
|
||||
onChange={(date) => this.setState({ endDate: date }, () => this.loadVisits())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,9 +210,12 @@ export class ShortUrlsVisits extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
ShortUrlsVisits.defaultProps = defaultProps;
|
||||
ShortUrlsVisitsComponent.propTypes = propTypes;
|
||||
ShortUrlsVisitsComponent.defaultProps = defaultProps;
|
||||
|
||||
export default connect(
|
||||
pick(['selectedServer', 'shortUrlVisits']),
|
||||
const ShortUrlsVisits = connect(
|
||||
pick([ 'selectedServer', 'shortUrlVisits' ]),
|
||||
{ getShortUrlVisits }
|
||||
)(ShortUrlsVisits);
|
||||
)(ShortUrlsVisitsComponent);
|
||||
|
||||
export default ShortUrlsVisits;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { assoc } from 'ramda';
|
||||
import Paginator from './Paginator';
|
||||
import SearchBar from './SearchBar';
|
||||
import ShortUrlsList from './ShortUrlsList';
|
||||
import { assoc } from 'ramda';
|
||||
|
||||
export function ShortUrls(props) {
|
||||
export function ShortUrlsComponent(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}`;
|
||||
|
||||
@@ -19,4 +20,8 @@ export function ShortUrls(props) {
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(state => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList))(ShortUrls);
|
||||
const ShortUrls = connect(
|
||||
(state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList)
|
||||
)(ShortUrlsComponent);
|
||||
|
||||
export default ShortUrls;
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import caretDownIcon from '@fortawesome/fontawesome-free-solid/faCaretDown'
|
||||
import caretUpIcon from '@fortawesome/fontawesome-free-solid/faCaretUp'
|
||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome'
|
||||
import { head, isEmpty, pick, toPairs, keys, values } from 'ramda'
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap'
|
||||
import { ShortUrlsRow } from './helpers/ShortUrlsRow'
|
||||
import { listShortUrls } from './reducers/shortUrlsList'
|
||||
import './ShortUrlsList.scss'
|
||||
import caretDownIcon from '@fortawesome/fontawesome-free-solid/faCaretDown';
|
||||
import caretUpIcon from '@fortawesome/fontawesome-free-solid/faCaretUp';
|
||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||
import { head, isEmpty, pick, toPairs, keys, values } from 'ramda';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||
import qs from 'qs';
|
||||
import PropTypes from 'prop-types';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import { ShortUrlsRow } from './helpers/ShortUrlsRow';
|
||||
import { listShortUrls, shortUrlType } from './reducers/shortUrlsList';
|
||||
import './ShortUrlsList.scss';
|
||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||
|
||||
const SORTABLE_FIELDS = {
|
||||
dateCreated: 'Created at',
|
||||
@@ -17,29 +20,43 @@ const SORTABLE_FIELDS = {
|
||||
visits: 'Visits',
|
||||
};
|
||||
|
||||
export class ShortUrlsList extends React.Component {
|
||||
refreshList = extraParams => {
|
||||
const propTypes = {
|
||||
listShortUrls: PropTypes.func,
|
||||
shortUrlsListParams: shortUrlsListParamsType,
|
||||
match: PropTypes.object,
|
||||
location: PropTypes.object,
|
||||
loading: PropTypes.bool,
|
||||
error: PropTypes.bool,
|
||||
shortUrlsList: PropTypes.arrayOf(shortUrlType),
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
export class ShortUrlsListComponent extends React.Component {
|
||||
refreshList = (extraParams) => {
|
||||
const { listShortUrls, shortUrlsListParams } = this.props;
|
||||
|
||||
listShortUrls({
|
||||
...shortUrlsListParams,
|
||||
...extraParams
|
||||
...extraParams,
|
||||
});
|
||||
};
|
||||
determineOrderDir = field => {
|
||||
determineOrderDir = (field) => {
|
||||
if (this.state.orderField !== field) {
|
||||
return 'ASC';
|
||||
}
|
||||
|
||||
const newOrderMap = {
|
||||
'ASC': 'DESC',
|
||||
'DESC': undefined,
|
||||
ASC: 'DESC',
|
||||
DESC: undefined,
|
||||
};
|
||||
|
||||
return this.state.orderDir ? newOrderMap[this.state.orderDir] : 'ASC';
|
||||
};
|
||||
orderBy = field => {
|
||||
orderBy = (field) => {
|
||||
const newOrderDir = this.determineOrderDir(field);
|
||||
|
||||
this.setState({ orderField: newOrderDir !== undefined ? field : undefined, orderDir: newOrderDir });
|
||||
this.refreshList({ orderBy: { [field]: newOrderDir } })
|
||||
this.refreshList({ orderBy: { [field]: newOrderDir } });
|
||||
};
|
||||
renderOrderIcon = (field, className = 'short-urls-list__header-icon') => {
|
||||
if (this.state.orderField !== field) {
|
||||
@@ -58,21 +75,23 @@ export class ShortUrlsList extends React.Component {
|
||||
super(props);
|
||||
|
||||
const { orderBy } = props.shortUrlsListParams;
|
||||
|
||||
this.state = {
|
||||
orderField: orderBy ? head(keys(orderBy)) : undefined,
|
||||
orderDir: orderBy ? head(values(orderBy)) : undefined,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { match: { params }, location } = this.props;
|
||||
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
|
||||
|
||||
this.refreshList({ page: params.page, tags: query.tag ? [query.tag] : [] });
|
||||
this.refreshList({ page: params.page, tags: query.tag ? [ query.tag ] : [] });
|
||||
}
|
||||
|
||||
renderShortUrls() {
|
||||
const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<tr>
|
||||
@@ -85,11 +104,11 @@ export class ShortUrlsList extends React.Component {
|
||||
return <tr><td colSpan="6" className="text-center">Loading...</td></tr>;
|
||||
}
|
||||
|
||||
if (! loading && isEmpty(shortUrlsList)) {
|
||||
if (!loading && isEmpty(shortUrlsList)) {
|
||||
return <tr><td colSpan="6" className="text-center">No results found</td></tr>;
|
||||
}
|
||||
|
||||
return shortUrlsList.map(shortUrl => (
|
||||
return shortUrlsList.map((shortUrl) => (
|
||||
<ShortUrlsRow
|
||||
shortUrl={shortUrl}
|
||||
selectedServer={selectedServer}
|
||||
@@ -108,11 +127,12 @@ export class ShortUrlsList extends React.Component {
|
||||
Order by
|
||||
</DropdownToggle>
|
||||
<DropdownMenu className="short-urls-list__order-dropdown">
|
||||
{toPairs(SORTABLE_FIELDS).map(([key, value]) =>
|
||||
{toPairs(SORTABLE_FIELDS).map(([ key, value ]) => (
|
||||
<DropdownItem key={key} active={this.state.orderField === key} onClick={() => this.orderBy(key)}>
|
||||
{value}
|
||||
{this.renderOrderIcon(key, 'short-urls-list__header-icon--mobile')}
|
||||
</DropdownItem>)}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</UncontrolledDropdown>
|
||||
</div>
|
||||
@@ -166,4 +186,11 @@ export class ShortUrlsList extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(pick(['selectedServer', 'shortUrlsListParams']), { listShortUrls })(ShortUrlsList);
|
||||
ShortUrlsListComponent.propTypes = propTypes;
|
||||
|
||||
const ShortUrlsList = connect(
|
||||
pick([ 'selectedServer', 'shortUrlsListParams' ]),
|
||||
{ listShortUrls }
|
||||
)(ShortUrlsListComponent);
|
||||
|
||||
export default ShortUrlsList;
|
||||
|
||||
@@ -3,8 +3,18 @@ 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';
|
||||
import PropTypes from 'prop-types';
|
||||
import { createShortUrlResultType } from '../reducers/shortUrlCreationResult';
|
||||
import './CreateShortUrlResult.scss';
|
||||
|
||||
const TIME_TO_SHOW_COPY_TOOLTIP = 2000;
|
||||
|
||||
const propTypes = {
|
||||
resetCreateShortUrl: PropTypes.func,
|
||||
error: PropTypes.bool,
|
||||
result: createShortUrlResultType,
|
||||
};
|
||||
|
||||
export default class CreateShortUrlResult extends React.Component {
|
||||
state = { showCopyTooltip: false };
|
||||
@@ -30,7 +40,7 @@ export default class CreateShortUrlResult extends React.Component {
|
||||
const { shortUrl } = result;
|
||||
const onCopy = () => {
|
||||
this.setState({ showCopyTooltip: true });
|
||||
setTimeout(() => this.setState({ showCopyTooltip: false }), 2000);
|
||||
setTimeout(() => this.setState({ showCopyTooltip: false }), TIME_TO_SHOW_COPY_TOOLTIP);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -39,8 +49,12 @@ export default class CreateShortUrlResult extends React.Component {
|
||||
<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
|
||||
className="btn btn-light btn-sm create-short-url-result__copy-btn"
|
||||
id="copyBtn"
|
||||
type="button"
|
||||
>
|
||||
<FontAwesomeIcon icon={copyIcon} /> Copy
|
||||
</button>
|
||||
</CopyToClipboard>
|
||||
|
||||
@@ -51,4 +65,6 @@ export default class CreateShortUrlResult extends React.Component {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
CreateShortUrlResult.propTypes = propTypes;
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import TagsSelector from '../../utils/TagsSelector';
|
||||
import PropTypes from 'prop-types';
|
||||
import { pick } from 'ramda';
|
||||
import TagsSelector from '../../utils/TagsSelector';
|
||||
import {
|
||||
editShortUrlTags,
|
||||
resetShortUrlsTags,
|
||||
shortUrlTagsType,
|
||||
shortUrlTagsEdited
|
||||
shortUrlTagsEdited,
|
||||
} from '../reducers/shortUrlTags';
|
||||
import { pick } from 'ramda';
|
||||
import ExternalLink from '../../utils/ExternalLink';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
|
||||
const propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
shortUrl: PropTypes.shape({
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
shortCode: PropTypes.string,
|
||||
}).isRequired,
|
||||
shortUrl: shortUrlType.isRequired,
|
||||
shortUrlTags: shortUrlTagsType,
|
||||
editShortUrlTags: PropTypes.func,
|
||||
shortUrlTagsEdited: PropTypes.func,
|
||||
resetShortUrlsTags: PropTypes.func,
|
||||
};
|
||||
|
||||
export class EditTagsModal extends React.Component {
|
||||
export class EditTagsModalComponent extends React.Component {
|
||||
saveTags = () => {
|
||||
const { editShortUrlTags, shortUrl, toggle } = this.props;
|
||||
|
||||
editShortUrlTags(shortUrl.shortCode, this.state.tags)
|
||||
.then(() => {
|
||||
this.tagsSaved = true;
|
||||
@@ -39,11 +42,13 @@ export class EditTagsModal extends React.Component {
|
||||
|
||||
const { shortUrlTagsEdited, shortUrl } = this.props;
|
||||
const { tags } = this.state;
|
||||
|
||||
shortUrlTagsEdited(shortUrl.shortCode, tags);
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { resetShortUrlsTags } = this.props;
|
||||
|
||||
resetShortUrlsTags();
|
||||
this.tagsSaved = false;
|
||||
}
|
||||
@@ -57,12 +62,12 @@ export class EditTagsModal extends React.Component {
|
||||
const { isOpen, toggle, url, shortUrlTags } = this.props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={this.refreshShortUrls}>
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={() => this.refreshShortUrls}>
|
||||
<ModalHeader toggle={toggle}>
|
||||
Edit tags for <a target="_blank" href={url}>{url}</a>
|
||||
Edit tags for <ExternalLink href={url}>{url}</ExternalLink>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<TagsSelector tags={this.state.tags} onChange={tags => this.setState({ tags })} />
|
||||
<TagsSelector tags={this.state.tags} onChange={(tags) => this.setState({ tags })} />
|
||||
{shortUrlTags.error && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while saving the tags :(
|
||||
@@ -74,8 +79,8 @@ export class EditTagsModal extends React.Component {
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
onClick={this.saveTags}
|
||||
disabled={shortUrlTags.saving}
|
||||
onClick={() => this.saveTags}
|
||||
>
|
||||
{shortUrlTags.saving ? 'Saving tags...' : 'Save tags'}
|
||||
</button>
|
||||
@@ -85,9 +90,11 @@ export class EditTagsModal extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
EditTagsModal.propTypes = propTypes;
|
||||
EditTagsModalComponent.propTypes = propTypes;
|
||||
|
||||
export default connect(
|
||||
pick(['shortUrlTags']),
|
||||
const EditTagsModal = connect(
|
||||
pick([ 'shortUrlTags' ]),
|
||||
{ editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited }
|
||||
)(EditTagsModal);
|
||||
)(EditTagsModalComponent);
|
||||
|
||||
export default EditTagsModal;
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import React from 'react'
|
||||
import React from 'react';
|
||||
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import './PreviewModal.scss';
|
||||
import ExternalLink from '../../utils/ExternalLink';
|
||||
|
||||
export default function PreviewModal ({ url, toggle, isOpen }) {
|
||||
const propTypes = {
|
||||
url: PropTypes.string,
|
||||
toggle: PropTypes.func,
|
||||
isOpen: PropTypes.bool,
|
||||
};
|
||||
|
||||
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>
|
||||
<ModalHeader toggle={toggle}>
|
||||
Preview for <ExternalLink href={url}>{url}</ExternalLink>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="text-center">
|
||||
<p className="preview-modal__loader">Loading...</p>
|
||||
@@ -15,3 +25,5 @@ export default function PreviewModal ({ url, toggle, isOpen }) {
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
PreviewModal.propTypes = propTypes;
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import React from 'react'
|
||||
import React from 'react';
|
||||
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import './QrCodeModal.scss';
|
||||
import ExternalLink from '../../utils/ExternalLink';
|
||||
|
||||
export default function QrCodeModal ({ url, toggle, isOpen }) {
|
||||
const propTypes = {
|
||||
url: PropTypes.string,
|
||||
toggle: PropTypes.func,
|
||||
isOpen: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default function QrCodeModal({ url, toggle, isOpen }) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<ModalHeader toggle={toggle}>QR code for <a target="_blank" href={url}>{url}</a></ModalHeader>
|
||||
<ModalHeader toggle={toggle}>
|
||||
QR code for <ExternalLink href={url}>{url}</ExternalLink>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="text-center">
|
||||
<img src={`${url}/qr-code`} className="qr-code-modal__img" alt="QR code" />
|
||||
@@ -14,3 +24,5 @@ export default function QrCodeModal ({ url, toggle, isOpen }) {
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
QrCodeModal.propTypes = propTypes;
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { isEmpty } from 'ramda';
|
||||
import React from 'react';
|
||||
import Moment from 'react-moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import Tag from '../../utils/Tag';
|
||||
import './ShortUrlsRow.scss';
|
||||
import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
|
||||
import { serverType } from '../../servers/prop-types';
|
||||
import ExternalLink from '../../utils/ExternalLink';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
import { ShortUrlsRowMenu } from './ShortUrlsRowMenu';
|
||||
import './ShortUrlsRow.scss';
|
||||
|
||||
const COPIED_MSG_TIME = 2000;
|
||||
|
||||
const propTypes = {
|
||||
refreshList: PropTypes.func,
|
||||
shortUrlsListParams: shortUrlsListParamsType,
|
||||
selectedServer: serverType,
|
||||
shortUrl: shortUrlType,
|
||||
};
|
||||
|
||||
export class ShortUrlsRow extends React.Component {
|
||||
state = { copiedToClipboard: false };
|
||||
@@ -15,7 +29,8 @@ export class ShortUrlsRow extends React.Component {
|
||||
|
||||
const { refreshList, shortUrlsListParams } = this.props;
|
||||
const selectedTags = shortUrlsListParams.tags || [];
|
||||
return tags.map(tag => (
|
||||
|
||||
return tags.map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
text={tag}
|
||||
@@ -34,10 +49,10 @@ export class ShortUrlsRow extends React.Component {
|
||||
<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>
|
||||
<ExternalLink href={completeShortUrl}>{completeShortUrl}</ExternalLink>
|
||||
</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>
|
||||
<ExternalLink href={shortUrl.originalUrl}>{shortUrl.originalUrl}</ExternalLink>
|
||||
</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>
|
||||
@@ -54,7 +69,7 @@ export class ShortUrlsRow extends React.Component {
|
||||
shortUrl={shortUrl}
|
||||
onCopyToClipboard={() => {
|
||||
this.setState({ copiedToClipboard: true });
|
||||
setTimeout(() => this.setState({ copiedToClipboard: false }), 2000);
|
||||
setTimeout(() => this.setState({ copiedToClipboard: false }), COPIED_MSG_TIME);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
@@ -62,3 +77,5 @@ export class ShortUrlsRow extends React.Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ShortUrlsRow.propTypes = propTypes;
|
||||
|
||||
@@ -9,11 +9,21 @@ 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 PropTypes from 'prop-types';
|
||||
import { serverType } from '../../servers/prop-types';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
import PreviewModal from './PreviewModal';
|
||||
import QrCodeModal from './QrCodeModal';
|
||||
import './ShortUrlsRowMenu.scss';
|
||||
import EditTagsModal from './EditTagsModal';
|
||||
|
||||
const propTypes = {
|
||||
completeShortUrl: PropTypes.string,
|
||||
onCopyToClipboard: PropTypes.func,
|
||||
selectedServer: serverType,
|
||||
shortUrl: shortUrlType,
|
||||
};
|
||||
|
||||
export class ShortUrlsRowMenu extends React.Component {
|
||||
state = {
|
||||
isOpen: false,
|
||||
@@ -21,26 +31,26 @@ export class ShortUrlsRowMenu extends React.Component {
|
||||
isPreviewOpen: false,
|
||||
isTagsModalOpen: false,
|
||||
};
|
||||
toggle = () => this.setState({ isOpen: !this.state.isOpen });
|
||||
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
|
||||
|
||||
render() {
|
||||
const { completeShortUrl, onCopyToClipboard, selectedServer, shortUrl } = this.props;
|
||||
const serverId = selectedServer ? selectedServer.id : '';
|
||||
const toggleQrCode = () => this.setState({isQrModalOpen: !this.state.isQrModalOpen});
|
||||
const togglePreview = () => this.setState({isPreviewOpen: !this.state.isPreviewOpen});
|
||||
const toggleTags = () => this.setState({isTagsModalOpen: !this.state.isTagsModalOpen});
|
||||
const toggleQrCode = () => this.setState(({ isQrModalOpen }) => ({ isQrModalOpen: !isQrModalOpen }));
|
||||
const togglePreview = () => this.setState(({ isPreviewOpen }) => ({ isPreviewOpen: !isPreviewOpen }));
|
||||
const toggleTags = () => this.setState(({ isTagsModalOpen }) => ({ isTagsModalOpen: !isTagsModalOpen }));
|
||||
|
||||
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}/>
|
||||
<FontAwesomeIcon icon={menuIcon} />
|
||||
</DropdownToggle>
|
||||
<DropdownMenu>
|
||||
<DropdownItem tag={Link} to={`/server/${serverId}/short-code/${shortUrl.shortCode}/visits`}>
|
||||
<FontAwesomeIcon icon={pieChartIcon}/> Visit Stats
|
||||
<FontAwesomeIcon icon={pieChartIcon} /> Visit Stats
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={toggleTags}>
|
||||
<FontAwesomeIcon icon={tagsIcon}/> Edit tags
|
||||
<FontAwesomeIcon icon={tagsIcon} /> Edit tags
|
||||
</DropdownItem>
|
||||
<EditTagsModal
|
||||
url={completeShortUrl}
|
||||
@@ -49,10 +59,10 @@ export class ShortUrlsRowMenu extends React.Component {
|
||||
toggle={toggleTags}
|
||||
/>
|
||||
|
||||
<DropdownItem divider/>
|
||||
<DropdownItem divider />
|
||||
|
||||
<DropdownItem onClick={togglePreview}>
|
||||
<FontAwesomeIcon icon={pictureIcon}/> Preview
|
||||
<FontAwesomeIcon icon={pictureIcon} /> Preview
|
||||
</DropdownItem>
|
||||
<PreviewModal
|
||||
url={completeShortUrl}
|
||||
@@ -61,7 +71,7 @@ export class ShortUrlsRowMenu extends React.Component {
|
||||
/>
|
||||
|
||||
<DropdownItem onClick={toggleQrCode}>
|
||||
<FontAwesomeIcon icon={qrIcon}/> QR code
|
||||
<FontAwesomeIcon icon={qrIcon} /> QR code
|
||||
</DropdownItem>
|
||||
<QrCodeModal
|
||||
url={completeShortUrl}
|
||||
@@ -69,11 +79,11 @@ export class ShortUrlsRowMenu extends React.Component {
|
||||
toggle={toggleQrCode}
|
||||
/>
|
||||
|
||||
<DropdownItem divider/>
|
||||
<DropdownItem divider />
|
||||
|
||||
<CopyToClipboard text={completeShortUrl} onCopy={onCopyToClipboard}>
|
||||
<DropdownItem>
|
||||
<FontAwesomeIcon icon={copyIcon}/> Copy to clipboard
|
||||
<FontAwesomeIcon icon={copyIcon} /> Copy to clipboard
|
||||
</DropdownItem>
|
||||
</CopyToClipboard>
|
||||
</DropdownMenu>
|
||||
@@ -81,3 +91,5 @@ export class ShortUrlsRowMenu extends React.Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ShortUrlsRowMenu.propTypes = propTypes;
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import ShlinkApiClient from '../../api/ShlinkApiClient';
|
||||
import { curry } from 'ramda';
|
||||
import PropTypes from 'prop-types';
|
||||
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';
|
||||
|
||||
export const createShortUrlResultType = {
|
||||
result: PropTypes.shape({
|
||||
shortUrl: PropTypes.string,
|
||||
}),
|
||||
saving: PropTypes.bool,
|
||||
error: PropTypes.bool,
|
||||
};
|
||||
|
||||
const defaultState = {
|
||||
result: null,
|
||||
saving: false,
|
||||
@@ -38,16 +47,18 @@ export default function reducer(state = defaultState, action) {
|
||||
}
|
||||
}
|
||||
|
||||
export const _createShortUrl = (ShlinkApiClient, data) => async dispatch => {
|
||||
export const _createShortUrl = (shlinkApiClient, data) => async (dispatch) => {
|
||||
dispatch({ type: CREATE_SHORT_URL_START });
|
||||
|
||||
try {
|
||||
const result = await ShlinkApiClient.createShortUrl(data);
|
||||
const result = await shlinkApiClient.createShortUrl(data);
|
||||
|
||||
dispatch({ type: CREATE_SHORT_URL, result });
|
||||
} catch (e) {
|
||||
dispatch({ type: CREATE_SHORT_URL_ERROR });
|
||||
}
|
||||
};
|
||||
export const createShortUrl = curry(_createShortUrl)(ShlinkApiClient);
|
||||
|
||||
export const createShortUrl = curry(_createShortUrl)(shlinkApiClient);
|
||||
|
||||
export const resetCreateShortUrl = () => ({ type: RESET_CREATE_SHORT_URL });
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import ShlinkApiClient from '../../api/ShlinkApiClient';
|
||||
import { curry } from 'ramda';
|
||||
import PropTypes from 'prop-types';
|
||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
||||
|
||||
export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
|
||||
|
||||
export const EDIT_SHORT_URL_TAGS_ERROR = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_ERROR';
|
||||
|
||||
export const EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS';
|
||||
|
||||
export const RESET_EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/RESET_EDIT_SHORT_URL_TAGS';
|
||||
|
||||
export const SHORT_URL_TAGS_EDITED = 'shlink/shortUrlTags/SHORT_URL_TAGS_EDITED';
|
||||
|
||||
export const shortUrlTagsType = PropTypes.shape({
|
||||
@@ -50,19 +54,21 @@ export default function reducer(state = defaultState, action) {
|
||||
}
|
||||
}
|
||||
|
||||
export const _editShortUrlTags = (ShlinkApiClient, shortCode, tags) => async (dispatch, getState) => {
|
||||
export const _editShortUrlTags = (shlinkApiClient, shortCode, tags) => async (dispatch) => {
|
||||
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
|
||||
|
||||
try {
|
||||
// Update short URL tags
|
||||
await ShlinkApiClient.updateShortUrlTags(shortCode, tags);
|
||||
await shlinkApiClient.updateShortUrlTags(shortCode, tags);
|
||||
dispatch({ tags, shortCode, type: EDIT_SHORT_URL_TAGS });
|
||||
} catch (e) {
|
||||
dispatch({ type: EDIT_SHORT_URL_TAGS_ERROR });
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
export const editShortUrlTags = curry(_editShortUrlTags)(ShlinkApiClient);
|
||||
|
||||
export const editShortUrlTags = curry(_editShortUrlTags)(shlinkApiClient);
|
||||
|
||||
export const resetShortUrlsTags = () => ({ type: RESET_EDIT_SHORT_URL_TAGS });
|
||||
|
||||
|
||||
@@ -1,50 +1,60 @@
|
||||
import ShlinkApiClient from '../../api/ShlinkApiClient';
|
||||
import { curry } from 'ramda';
|
||||
import PropTypes from 'prop-types';
|
||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
||||
import { shortUrlType } from './shortUrlsList';
|
||||
|
||||
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';
|
||||
|
||||
export const shortUrlVisitsType = {
|
||||
shortUrl: shortUrlType,
|
||||
visits: PropTypes.array,
|
||||
loading: PropTypes.bool,
|
||||
error: PropTypes.bool,
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
shortUrl: {},
|
||||
visits: [],
|
||||
loading: false,
|
||||
error: false
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default function dispatch (state = initialState, action) {
|
||||
export default function dispatch(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case GET_SHORT_URL_VISITS_START:
|
||||
return {
|
||||
...state,
|
||||
loading: true
|
||||
loading: true,
|
||||
};
|
||||
case GET_SHORT_URL_VISITS_ERROR:
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
error: true
|
||||
error: true,
|
||||
};
|
||||
case GET_SHORT_URL_VISITS:
|
||||
return {
|
||||
shortUrl: action.shortUrl,
|
||||
visits: action.visits,
|
||||
loading: false,
|
||||
error: false
|
||||
error: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export const _getShortUrlVisits = (ShlinkApiClient, shortCode, dates) => dispatch => {
|
||||
export const _getShortUrlVisits = (shlinkApiClient, shortCode, dates) => (dispatch) => {
|
||||
dispatch({ type: GET_SHORT_URL_VISITS_START });
|
||||
|
||||
Promise.all([
|
||||
ShlinkApiClient.getShortUrlVisits(shortCode, dates),
|
||||
ShlinkApiClient.getShortUrl(shortCode)
|
||||
shlinkApiClient.getShortUrlVisits(shortCode, dates),
|
||||
shlinkApiClient.getShortUrl(shortCode),
|
||||
])
|
||||
.then(([visits, shortUrl]) => dispatch({ visits, shortUrl, type: GET_SHORT_URL_VISITS }))
|
||||
.then(([ visits, shortUrl ]) => dispatch({ visits, shortUrl, type: GET_SHORT_URL_VISITS }))
|
||||
.catch(() => dispatch({ type: GET_SHORT_URL_VISITS_ERROR }));
|
||||
};
|
||||
export const getShortUrlVisits = curry(_getShortUrlVisits)(ShlinkApiClient);
|
||||
|
||||
export const getShortUrlVisits = curry(_getShortUrlVisits)(shlinkApiClient);
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import ShlinkApiClient from '../../api/ShlinkApiClient';
|
||||
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
|
||||
import { assoc, assocPath } from 'ramda';
|
||||
import PropTypes from 'prop-types';
|
||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
||||
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
|
||||
|
||||
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';
|
||||
|
||||
export const shortUrlType = PropTypes.shape({
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
shortCode: PropTypes.string,
|
||||
originalUrl: PropTypes.string,
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
shortUrls: {},
|
||||
loading: true,
|
||||
@@ -19,34 +27,36 @@ export default function reducer(state = initialState, action) {
|
||||
return {
|
||||
loading: false,
|
||||
error: false,
|
||||
shortUrls: action.shortUrls
|
||||
shortUrls: action.shortUrls,
|
||||
};
|
||||
case LIST_SHORT_URLS_ERROR:
|
||||
return {
|
||||
loading: false,
|
||||
error: true,
|
||||
shortUrls: []
|
||||
shortUrls: [],
|
||||
};
|
||||
case SHORT_URL_TAGS_EDITED:
|
||||
const { data } = state.shortUrls;
|
||||
return assocPath(['shortUrls', 'data'], data.map(shortUrl =>
|
||||
|
||||
return assocPath([ 'shortUrls', 'data' ], data.map((shortUrl) =>
|
||||
shortUrl.shortCode === action.shortCode
|
||||
? assoc('tags', action.tags, shortUrl)
|
||||
: shortUrl
|
||||
), state);
|
||||
: shortUrl), state);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export const _listShortUrls = (ShlinkApiClient, params = {}) => async dispatch => {
|
||||
export const _listShortUrls = (shlinkApiClient, params = {}) => async (dispatch) => {
|
||||
dispatch({ type: LIST_SHORT_URLS_START });
|
||||
|
||||
try {
|
||||
const shortUrls = await ShlinkApiClient.listShortUrls(params);
|
||||
const shortUrls = await shlinkApiClient.listShortUrls(params);
|
||||
|
||||
dispatch({ type: LIST_SHORT_URLS, shortUrls, params });
|
||||
} catch (e) {
|
||||
dispatch({ type: LIST_SHORT_URLS_ERROR, params });
|
||||
}
|
||||
};
|
||||
export const listShortUrls = (params = {}) => _listShortUrls(ShlinkApiClient, params);
|
||||
|
||||
export const listShortUrls = (params = {}) => _listShortUrls(shlinkApiClient, params);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { LIST_SHORT_URLS } from './shortUrlsList';
|
||||
|
||||
export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS';
|
||||
|
||||
export const shortUrlsListParamsType = {
|
||||
page: PropTypes.string,
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
searchTerm: PropTypes.string,
|
||||
};
|
||||
|
||||
const defaultState = { page: '1' };
|
||||
|
||||
export default function reducer(state = defaultState, action) {
|
||||
|
||||
@@ -2,20 +2,21 @@ import { Card, CardBody } from 'reactstrap';
|
||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||
import deleteIcon from '@fortawesome/fontawesome-free-solid/faTrash';
|
||||
import editIcon from '@fortawesome/fontawesome-free-solid/faPencilAlt';
|
||||
import DeleteTagConfirmModal from './helpers/DeleteTagConfirmModal';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ColorGenerator, { colorGeneratorType } from '../utils/ColorGenerator';
|
||||
import './TagCard.scss';
|
||||
import { Link } from 'react-router-dom';
|
||||
import colorGenerator, { colorGeneratorType } from '../utils/ColorGenerator';
|
||||
import './TagCard.scss';
|
||||
import DeleteTagConfirmModal from './helpers/DeleteTagConfirmModal';
|
||||
import EditTagModal from './helpers/EditTagModal';
|
||||
|
||||
const propTypes = {
|
||||
tag: PropTypes.string,
|
||||
currentServerId: PropTypes.string,
|
||||
colorGenerator: colorGeneratorType,
|
||||
};
|
||||
const defaultProps = {
|
||||
colorGenerator: ColorGenerator,
|
||||
colorGenerator,
|
||||
};
|
||||
|
||||
export default class TagCard extends React.Component {
|
||||
@@ -24,9 +25,9 @@ export default class TagCard extends React.Component {
|
||||
render() {
|
||||
const { tag, colorGenerator, currentServerId } = this.props;
|
||||
const toggleDelete = () =>
|
||||
this.setState({ isDeleteModalOpen: !this.state.isDeleteModalOpen });
|
||||
this.setState(({ isDeleteModalOpen }) => ({ isDeleteModalOpen: !isDeleteModalOpen }));
|
||||
const toggleEdit = () =>
|
||||
this.setState({ isEditModalOpen: !this.state.isEditModalOpen });
|
||||
this.setState(({ isEditModalOpen }) => ({ isEditModalOpen: !isEditModalOpen }));
|
||||
|
||||
return (
|
||||
<Card className="tag-card">
|
||||
@@ -35,17 +36,17 @@ export default class TagCard extends React.Component {
|
||||
className="btn btn-light btn-sm tag-card__btn tag-card__btn--last"
|
||||
onClick={toggleDelete}
|
||||
>
|
||||
<FontAwesomeIcon icon={deleteIcon}/>
|
||||
<FontAwesomeIcon icon={deleteIcon} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-light btn-sm tag-card__btn"
|
||||
onClick={toggleEdit}
|
||||
>
|
||||
<FontAwesomeIcon icon={editIcon}/>
|
||||
<FontAwesomeIcon icon={editIcon} />
|
||||
</button>
|
||||
<h5 className="tag-card__tag-title">
|
||||
<div
|
||||
style={{backgroundColor: colorGenerator.getColorForKey(tag)}}
|
||||
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
|
||||
className="tag-card__tag-bullet"
|
||||
/>
|
||||
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { pick, splitEvery } from 'ramda';
|
||||
import { filterTags, listTags } from './reducers/tagsList';
|
||||
import PropTypes from 'prop-types';
|
||||
import MuttedMessage from '../utils/MuttedMessage';
|
||||
import TagCard from './TagCard';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { filterTags, listTags } from './reducers/tagsList';
|
||||
import TagCard from './TagCard';
|
||||
|
||||
const { ceil } = Math;
|
||||
const TAGS_GROUP_SIZE = 4;
|
||||
const propTypes = {
|
||||
filterTags: PropTypes.func,
|
||||
listTags: PropTypes.func,
|
||||
tagsList: PropTypes.shape({
|
||||
loading: PropTypes.bool,
|
||||
}),
|
||||
match: PropTypes.object,
|
||||
};
|
||||
|
||||
export class TagsList extends React.Component {
|
||||
state = { isDeleteModalOpen: false };
|
||||
|
||||
export class TagsListComponent extends React.Component {
|
||||
componentDidMount() {
|
||||
const { listTags } = this.props;
|
||||
|
||||
listTags();
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
const { tagsList, match } = this.props;
|
||||
|
||||
if (tagsList.loading) {
|
||||
return <MuttedMessage marginSize={0}>Loading...</MuttedMessage>
|
||||
return <MuttedMessage marginSize={0}>Loading...</MuttedMessage>;
|
||||
}
|
||||
|
||||
if (tagsList.error) {
|
||||
@@ -31,17 +41,18 @@ export class TagsList extends React.Component {
|
||||
}
|
||||
|
||||
const tagsCount = tagsList.filteredTags.length;
|
||||
|
||||
if (tagsCount < 1) {
|
||||
return <MuttedMessage>No tags found</MuttedMessage>;
|
||||
}
|
||||
|
||||
const tagsGroups = splitEvery(ceil(tagsCount / 4), tagsList.filteredTags);
|
||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUP_SIZE), tagsList.filteredTags);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{tagsGroups.map((group, index) => (
|
||||
<div key={index} className="col-md-6 col-xl-3">
|
||||
{group.map(tag => (
|
||||
{group.map((tag) => (
|
||||
<TagCard
|
||||
key={tag}
|
||||
tag={tag}
|
||||
@@ -61,9 +72,9 @@ export class TagsList extends React.Component {
|
||||
<div className="shlink-container">
|
||||
{!this.props.tagsList.loading && (
|
||||
<SearchField
|
||||
onChange={filterTags}
|
||||
className="mb-3"
|
||||
placeholder="Search tags..."
|
||||
onChange={filterTags}
|
||||
/>
|
||||
)}
|
||||
<div className="row">
|
||||
@@ -74,4 +85,8 @@ export class TagsList extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(pick(['tagsList']), { listTags, filterTags })(TagsList);
|
||||
TagsListComponent.propTypes = propTypes;
|
||||
|
||||
const TagsList = connect(pick([ 'tagsList' ]), { listTags, filterTags })(TagsListComponent);
|
||||
|
||||
export default TagsList;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Component } from 'react';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
@@ -11,22 +11,25 @@ const propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
deleteTag: PropTypes.func,
|
||||
tagDelete: tagDeleteType,
|
||||
tagDeleted: PropTypes.func,
|
||||
};
|
||||
|
||||
export class DeleteTagConfirmModal extends Component {
|
||||
export class DeleteTagConfirmModalComponent extends React.Component {
|
||||
doDelete = () => {
|
||||
const { tag, toggle, deleteTag } = this.props;
|
||||
|
||||
deleteTag(tag).then(() => {
|
||||
this.tagWasDeleted = true;
|
||||
toggle();
|
||||
});
|
||||
};
|
||||
onClosed = () => {
|
||||
handleOnClosed = () => {
|
||||
if (!this.tagWasDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { tagDeleted, tag } = this.props;
|
||||
|
||||
tagDeleted(tag);
|
||||
};
|
||||
|
||||
@@ -38,7 +41,7 @@ export class DeleteTagConfirmModal extends Component {
|
||||
const { tag, toggle, isOpen, tagDelete } = this.props;
|
||||
|
||||
return (
|
||||
<Modal toggle={toggle} isOpen={isOpen} centered onClosed={this.onClosed}>
|
||||
<Modal toggle={toggle} isOpen={isOpen} centered onClosed={this.handleOnClosed}>
|
||||
<ModalHeader toggle={toggle}>
|
||||
<span className="text-danger">Delete tag</span>
|
||||
</ModalHeader>
|
||||
@@ -54,8 +57,8 @@ export class DeleteTagConfirmModal extends Component {
|
||||
<button className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={this.doDelete}
|
||||
disabled={tagDelete.deleting}
|
||||
onClick={() => this.doDelete()}
|
||||
>
|
||||
{tagDelete.deleting ? 'Deleting tag...' : 'Delete tag'}
|
||||
</button>
|
||||
@@ -65,9 +68,11 @@ export class DeleteTagConfirmModal extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
DeleteTagConfirmModal.propTypes = propTypes;
|
||||
DeleteTagConfirmModalComponent.propTypes = propTypes;
|
||||
|
||||
export default connect(
|
||||
pick(['tagDelete']),
|
||||
const DeleteTagConfirmModal = connect(
|
||||
pick([ 'tagDelete' ]),
|
||||
{ deleteTag, tagDeleted }
|
||||
)(DeleteTagConfirmModal);
|
||||
)(DeleteTagConfirmModalComponent);
|
||||
|
||||
export default DeleteTagConfirmModal;
|
||||
|
||||
@@ -2,20 +2,32 @@ import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
||||
import { pick } from 'ramda';
|
||||
import { editTag, tagEdited } from '../reducers/tagEdit';
|
||||
import { ChromePicker } from 'react-color';
|
||||
import ColorGenerator from '../../utils/ColorGenerator';
|
||||
import colorIcon from '@fortawesome/fontawesome-free-solid/faPalette'
|
||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome'
|
||||
import colorIcon from '@fortawesome/fontawesome-free-solid/faPalette';
|
||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||
import PropTypes from 'prop-types';
|
||||
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
|
||||
import { editTag, tagEdited } from '../reducers/tagEdit';
|
||||
import './EditTagModal.scss';
|
||||
|
||||
const propTypes = {
|
||||
tag: PropTypes.string,
|
||||
editTag: PropTypes.func,
|
||||
toggle: PropTypes.func,
|
||||
tagEdited: PropTypes.func,
|
||||
colorGenerator: colorGeneratorType,
|
||||
isOpen: PropTypes.bool,
|
||||
tagEdit: PropTypes.shape({
|
||||
error: PropTypes.bool,
|
||||
editing: PropTypes.bool,
|
||||
}),
|
||||
};
|
||||
const defaultProps = {
|
||||
colorGenerator: ColorGenerator,
|
||||
colorGenerator,
|
||||
};
|
||||
|
||||
|
||||
export class EditTagModal extends React.Component {
|
||||
saveTag = e => {
|
||||
export class EditTagModalComponent extends React.Component {
|
||||
saveTag = (e) => {
|
||||
e.preventDefault();
|
||||
const { tag: oldName, editTag, toggle } = this.props;
|
||||
const { tag: newName, color } = this.state;
|
||||
@@ -27,13 +39,14 @@ export class EditTagModal extends React.Component {
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
onClosed = () => {
|
||||
handleOnClosed = () => {
|
||||
if (!this.tagWasEdited) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { tag: oldName, tagEdited } = this.props;
|
||||
const { tag: newName, color } = this.state;
|
||||
|
||||
tagEdited(oldName, newName, color);
|
||||
};
|
||||
|
||||
@@ -41,11 +54,12 @@ export class EditTagModal extends React.Component {
|
||||
super(props);
|
||||
|
||||
const { colorGenerator, tag } = props;
|
||||
|
||||
this.state = {
|
||||
showColorPicker: false,
|
||||
tag,
|
||||
color: colorGenerator.getColorForKey(tag)
|
||||
}
|
||||
color: colorGenerator.getColorForKey(tag),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -56,11 +70,11 @@ export class EditTagModal extends React.Component {
|
||||
const { isOpen, toggle, tagEdit } = this.props;
|
||||
const { tag, color } = this.state;
|
||||
const toggleColorPicker = () =>
|
||||
this.setState({ showColorPicker: !this.state.showColorPicker });
|
||||
this.setState(({ showColorPicker }) => ({ showColorPicker: !showColorPicker }));
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={this.onClosed}>
|
||||
<form onSubmit={this.saveTag}>
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={this.handleOnClosed}>
|
||||
<form onSubmit={() => this.saveTag()}>
|
||||
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="input-group">
|
||||
@@ -87,17 +101,17 @@ export class EditTagModal extends React.Component {
|
||||
>
|
||||
<ChromePicker
|
||||
color={color}
|
||||
onChange={color => this.setState({ color: color.hex })}
|
||||
disableAlpha
|
||||
onChange={(color) => this.setState({ color: color.hex })}
|
||||
/>
|
||||
</Popover>
|
||||
<input
|
||||
type="text"
|
||||
value={tag}
|
||||
onChange={e => this.setState({ tag: e.target.value })}
|
||||
placeholder="Tag"
|
||||
required
|
||||
className="form-control"
|
||||
onChange={(e) => this.setState({ tag: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -119,6 +133,9 @@ export class EditTagModal extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
EditTagModal.defaultProps = defaultProps;
|
||||
EditTagModalComponent.propTypes = propTypes;
|
||||
EditTagModalComponent.defaultProps = defaultProps;
|
||||
|
||||
export default connect(pick(['tagEdit']), { editTag, tagEdited })(EditTagModal);
|
||||
const EditTagModal = connect(pick([ 'tagEdit' ]), { editTag, tagEdited })(EditTagModalComponent);
|
||||
|
||||
export default EditTagModal;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import ShlinkApiClient from '../../api/ShlinkApiClient';
|
||||
import { curry } from 'ramda';
|
||||
import PropTypes from 'prop-types';
|
||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
||||
|
||||
const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
||||
const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
|
||||
const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
|
||||
|
||||
export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
|
||||
|
||||
export const tagDeleteType = PropTypes.shape({
|
||||
@@ -39,17 +40,19 @@ export default function reduce(state = defaultState, action) {
|
||||
}
|
||||
}
|
||||
|
||||
export const _deleteTag = (ShlinkApiClient, tag) => async dispatch => {
|
||||
export const _deleteTag = (shlinkApiClient, tag) => async (dispatch) => {
|
||||
dispatch({ type: DELETE_TAG_START });
|
||||
|
||||
try {
|
||||
await ShlinkApiClient.deleteTags([tag]);
|
||||
await shlinkApiClient.deleteTags([ tag ]);
|
||||
dispatch({ type: DELETE_TAG });
|
||||
} catch (e) {
|
||||
dispatch({ type: DELETE_TAG_ERROR });
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
export const deleteTag = curry(_deleteTag)(ShlinkApiClient);
|
||||
|
||||
export const tagDeleted = tag => ({ type: TAG_DELETED, tag });
|
||||
export const deleteTag = curry(_deleteTag)(shlinkApiClient);
|
||||
|
||||
export const tagDeleted = (tag) => ({ type: TAG_DELETED, tag });
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import ShlinkApiClient from '../../api/ShlinkApiClient';
|
||||
import ColorGenerator from '../../utils/ColorGenerator';
|
||||
import { curry, pick } from 'ramda';
|
||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
||||
import colorGenerator from '../../utils/ColorGenerator';
|
||||
|
||||
const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
||||
const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
|
||||
const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
|
||||
|
||||
export const TAG_EDITED = 'shlink/editTag/TAG_EDITED';
|
||||
|
||||
const defaultState = {
|
||||
@@ -30,7 +31,7 @@ export default function reducer(state = defaultState, action) {
|
||||
};
|
||||
case EDIT_TAG:
|
||||
return {
|
||||
...pick(['oldName', 'newName'], action),
|
||||
...pick([ 'oldName', 'newName' ], action),
|
||||
editing: false,
|
||||
error: false,
|
||||
};
|
||||
@@ -39,20 +40,22 @@ export default function reducer(state = defaultState, action) {
|
||||
}
|
||||
}
|
||||
|
||||
export const _editTag = (ShlinkApiClient, ColorGenerator, oldName, newName, color) =>
|
||||
async dispatch => {
|
||||
export const _editTag = (shlinkApiClient, colorGenerator, oldName, newName, color) =>
|
||||
async (dispatch) => {
|
||||
dispatch({ type: EDIT_TAG_START });
|
||||
|
||||
try {
|
||||
await ShlinkApiClient.editTag(oldName, newName);
|
||||
ColorGenerator.setColorForKey(newName, color);
|
||||
await shlinkApiClient.editTag(oldName, newName);
|
||||
colorGenerator.setColorForKey(newName, color);
|
||||
dispatch({ type: EDIT_TAG, oldName, newName });
|
||||
} catch (e) {
|
||||
dispatch({ type: EDIT_TAG_ERROR });
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
export const editTag = curry(_editTag)(ShlinkApiClient, ColorGenerator);
|
||||
|
||||
export const editTag = curry(_editTag)(shlinkApiClient, colorGenerator);
|
||||
|
||||
export const tagEdited = (oldName, newName, color) => ({
|
||||
type: TAG_EDITED,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ShlinkApiClient from '../../api/ShlinkApiClient';
|
||||
import { TAG_DELETED } from './tagDelete';
|
||||
import { reject } from 'ramda';
|
||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
||||
import { TAG_DELETED } from './tagDelete';
|
||||
import { TAG_EDITED } from './tagEdit';
|
||||
|
||||
const LIST_TAGS_START = 'shlink/tagsList/LIST_TAGS_START';
|
||||
@@ -16,7 +16,7 @@ const defaultState = {
|
||||
};
|
||||
|
||||
export default function reducer(state = defaultState, action) {
|
||||
switch(action.type) {
|
||||
switch (action.type) {
|
||||
case LIST_TAGS_START:
|
||||
return {
|
||||
...state,
|
||||
@@ -39,14 +39,17 @@ export default function reducer(state = defaultState, action) {
|
||||
case TAG_DELETED:
|
||||
return {
|
||||
...state,
|
||||
|
||||
// FIXME This should be optimized somehow...
|
||||
tags: reject(tag => tag === action.tag, state.tags),
|
||||
filteredTags: reject(tag => tag === action.tag, state.filteredTags),
|
||||
tags: reject((tag) => tag === action.tag, state.tags),
|
||||
filteredTags: reject((tag) => tag === action.tag, state.filteredTags),
|
||||
};
|
||||
case TAG_EDITED:
|
||||
const renameTag = tag => tag === action.oldName ? action.newName : tag;
|
||||
const renameTag = (tag) => tag === action.oldName ? action.newName : tag;
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
||||
// FIXME This should be optimized somehow...
|
||||
tags: state.tags.map(renameTag).sort(),
|
||||
filteredTags: state.filteredTags.map(renameTag).sort(),
|
||||
@@ -55,7 +58,7 @@ export default function reducer(state = defaultState, action) {
|
||||
return {
|
||||
...state,
|
||||
filteredTags: state.tags.filter(
|
||||
tag => tag.toLowerCase().match(action.searchTerm),
|
||||
(tag) => tag.toLowerCase().match(action.searchTerm),
|
||||
),
|
||||
};
|
||||
default:
|
||||
@@ -63,19 +66,21 @@ export default function reducer(state = defaultState, action) {
|
||||
}
|
||||
}
|
||||
|
||||
export const _listTags = ShlinkApiClient => async dispatch => {
|
||||
export const _listTags = (shlinkApiClient) => async (dispatch) => {
|
||||
dispatch({ type: LIST_TAGS_START });
|
||||
|
||||
try {
|
||||
const tags = await ShlinkApiClient.listTags();
|
||||
const tags = await shlinkApiClient.listTags();
|
||||
|
||||
dispatch({ tags, type: LIST_TAGS });
|
||||
} catch (e) {
|
||||
dispatch({ type: LIST_TAGS_ERROR });
|
||||
}
|
||||
};
|
||||
export const listTags = () => _listTags(ShlinkApiClient);
|
||||
|
||||
export const filterTags = searchTerm => ({
|
||||
export const listTags = () => _listTags(shlinkApiClient);
|
||||
|
||||
export const filterTags = (searchTerm) => ({
|
||||
type: FILTER_TAGS,
|
||||
searchTerm,
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import Storage from './Storage';
|
||||
import PropTypes from 'prop-types';
|
||||
import { range } from 'ramda';
|
||||
import PropTypes from 'prop-types';
|
||||
import storage from './Storage';
|
||||
|
||||
const HEX_COLOR_LENGTH = 6;
|
||||
const { floor, random } = Math;
|
||||
const letters = '0123456789ABCDEF';
|
||||
const buildRandomColor = () =>
|
||||
`#${
|
||||
range(0, 6)
|
||||
.map(() => letters[floor(random() * 16)])
|
||||
range(0, HEX_COLOR_LENGTH)
|
||||
.map(() => letters[floor(random() * letters.length)])
|
||||
.join('')
|
||||
}`;
|
||||
|
||||
@@ -17,12 +18,13 @@ export class ColorGenerator {
|
||||
this.colors = this.storage.get('colors') || {};
|
||||
}
|
||||
|
||||
getColorForKey = key => {
|
||||
getColorForKey = (key) => {
|
||||
const color = this.colors[key];
|
||||
|
||||
// If a color has not been set yet, generate a random one and save it
|
||||
if (!color) {
|
||||
this.setColorForKey(key, buildRandomColor());
|
||||
|
||||
return this.getColorForKey(key);
|
||||
}
|
||||
|
||||
@@ -40,4 +42,6 @@ export const colorGeneratorType = PropTypes.shape({
|
||||
setColorForKey: PropTypes.func,
|
||||
});
|
||||
|
||||
export default new ColorGenerator(Storage);
|
||||
const colorGenerator = new ColorGenerator(storage);
|
||||
|
||||
export default colorGenerator;
|
||||
|
||||
19
src/utils/ExternalLink.js
Normal file
19
src/utils/ExternalLink.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const propTypes = {
|
||||
href: PropTypes.string.isRequired,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default function ExternalLink(props) {
|
||||
const { href, children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<a target="_blank" rel="noopener noreferrer" href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
ExternalLink.propTypes = propTypes;
|
||||
@@ -1,8 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Card } from 'reactstrap';
|
||||
import classnames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default function MutedMessage({ children, marginSize = 4 }) {
|
||||
const DEFAULT_MARGIN_SIZE = 4;
|
||||
const propTypes = {
|
||||
marginSize: PropTypes.number,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default function MutedMessage({ children, marginSize = DEFAULT_MARGIN_SIZE }) {
|
||||
const cardClasses = classnames('bg-light', {
|
||||
[`mt-${marginSize}`]: marginSize > 0,
|
||||
});
|
||||
@@ -17,3 +24,5 @@ export default function MutedMessage({ children, marginSize = 4 }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MutedMessage.propTypes = propTypes;
|
||||
|
||||
@@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import './SearchField.scss';
|
||||
|
||||
const DEFAULT_SEARCH_INTERVAL = 500;
|
||||
const propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
@@ -19,7 +20,7 @@ export default class SearchField extends React.Component {
|
||||
state = { showClearBtn: false, searchTerm: '' };
|
||||
timer = null;
|
||||
|
||||
searchTermChanged(searchTerm, timeout = 500) {
|
||||
searchTermChanged(searchTerm, timeout = DEFAULT_SEARCH_INTERVAL) {
|
||||
this.setState({
|
||||
showClearBtn: searchTerm !== '',
|
||||
searchTerm,
|
||||
@@ -29,6 +30,7 @@ export default class SearchField extends React.Component {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
};
|
||||
|
||||
resetTimer();
|
||||
|
||||
this.timer = setTimeout(() => {
|
||||
@@ -46,15 +48,15 @@ export default class SearchField extends React.Component {
|
||||
type="text"
|
||||
className="form-control form-control-lg search-field__input"
|
||||
placeholder={placeholder}
|
||||
onChange={e => this.searchTermChanged(e.target.value)}
|
||||
value={this.state.searchTerm}
|
||||
onChange={(e) => this.searchTermChanged(e.target.value)}
|
||||
/>
|
||||
<FontAwesomeIcon icon={searchIcon} className="search-field__icon" />
|
||||
<div
|
||||
className="close search-field__close"
|
||||
hidden={! this.state.showClearBtn}
|
||||
onClick={() => this.searchTermChanged('', 0)}
|
||||
hidden={!this.state.showClearBtn}
|
||||
id="search-field__close"
|
||||
onClick={() => this.searchTermChanged('', 0)}
|
||||
>
|
||||
×
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
const PREFIX = 'shlink';
|
||||
const buildPath = path => `${PREFIX}.${path}`;
|
||||
const buildPath = (path) => `${PREFIX}.${path}`;
|
||||
|
||||
export class Storage {
|
||||
constructor(localStorage) {
|
||||
this.localStorage = localStorage;
|
||||
}
|
||||
|
||||
get = key => {
|
||||
get = (key) => {
|
||||
const item = this.localStorage.getItem(buildPath(key));
|
||||
|
||||
return item ? JSON.parse(item) : undefined;
|
||||
};
|
||||
|
||||
@@ -15,4 +16,5 @@ export class Storage {
|
||||
}
|
||||
|
||||
const storage = typeof localStorage !== 'undefined' ? localStorage : {};
|
||||
|
||||
export default new Storage(storage);
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import React from 'react';
|
||||
import ColorGenerator from '../utils/ColorGenerator';
|
||||
import PropTypes from 'prop-types';
|
||||
import colorGenerator, { colorGeneratorType } from '../utils/ColorGenerator';
|
||||
import './Tag.scss';
|
||||
|
||||
export default function Tag (
|
||||
const propTypes = {
|
||||
colorGenerator: colorGeneratorType,
|
||||
text: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
clearable: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
const defaultProps = {
|
||||
colorGenerator,
|
||||
};
|
||||
|
||||
export default function Tag(
|
||||
{
|
||||
colorGenerator,
|
||||
text,
|
||||
children,
|
||||
clearable,
|
||||
onClick = () => ({}),
|
||||
onClose = () => ({})
|
||||
onClose = () => ({}),
|
||||
}
|
||||
) {
|
||||
return (
|
||||
@@ -24,6 +37,5 @@ export default function Tag (
|
||||
);
|
||||
}
|
||||
|
||||
Tag.defaultProps = {
|
||||
colorGenerator: ColorGenerator
|
||||
};
|
||||
Tag.defaultProps = defaultProps;
|
||||
Tag.propTypes = propTypes;
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
import React from 'react';
|
||||
import TagsInput from 'react-tagsinput';
|
||||
import ColorGenerator, { colorGeneratorType } from './ColorGenerator';
|
||||
import PropTypes from 'prop-types';
|
||||
import colorGenerator, { colorGeneratorType } from './ColorGenerator';
|
||||
|
||||
const defaultProps = {
|
||||
colorGenerator: ColorGenerator,
|
||||
colorGenerator,
|
||||
placeholder: 'Add tags to the URL',
|
||||
};
|
||||
const propTypes = {
|
||||
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
placeholder: PropTypes.string,
|
||||
colorGenerator: colorGeneratorType
|
||||
colorGenerator: colorGeneratorType,
|
||||
};
|
||||
|
||||
export default function TagsSelector({ tags, onChange, placeholder, colorGenerator }) {
|
||||
const renderTag = (props) => {
|
||||
const { tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other } = props;
|
||||
|
||||
return (
|
||||
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
|
||||
{getTagDisplayValue(tag)}
|
||||
{!disabled && <span className={classNameRemove} onClick={() => onRemove(key)} />}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TagsInput
|
||||
value={tags}
|
||||
onChange={onChange}
|
||||
inputProps={{ placeholder }}
|
||||
onlyUnique
|
||||
addOnBlur // FIXME Workaround to be able to add tags on Android
|
||||
renderTag={renderTag}
|
||||
|
||||
// FIXME Workaround to be able to add tags on Android
|
||||
addOnBlur
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,87 +1,90 @@
|
||||
import { assoc, isNil, isEmpty, reduce } from 'ramda';
|
||||
|
||||
const osFromUserAgent = userAgent => {
|
||||
const osFromUserAgent = (userAgent) => {
|
||||
const lowerUserAgent = userAgent.toLowerCase();
|
||||
|
||||
switch (true) {
|
||||
case (lowerUserAgent.indexOf('linux') >= 0):
|
||||
case lowerUserAgent.indexOf('linux') >= 0:
|
||||
return 'Linux';
|
||||
case (lowerUserAgent.indexOf('windows') >= 0):
|
||||
case lowerUserAgent.indexOf('windows') >= 0:
|
||||
return 'Windows';
|
||||
case (lowerUserAgent.indexOf('mac') >= 0):
|
||||
case lowerUserAgent.indexOf('mac') >= 0:
|
||||
return 'MacOS';
|
||||
case (lowerUserAgent.indexOf('mobi') >= 0):
|
||||
case lowerUserAgent.indexOf('mobi') >= 0:
|
||||
return 'Mobile';
|
||||
default:
|
||||
return 'Others';
|
||||
}
|
||||
};
|
||||
|
||||
const browserFromUserAgent = userAgent => {
|
||||
const browserFromUserAgent = (userAgent) => {
|
||||
const lowerUserAgent = userAgent.toLowerCase();
|
||||
|
||||
switch (true) {
|
||||
case (lowerUserAgent.indexOf('opera') >= 0 || lowerUserAgent.indexOf('opr') >= 0):
|
||||
case lowerUserAgent.indexOf('opera') >= 0 || lowerUserAgent.indexOf('opr') >= 0:
|
||||
return 'Opera';
|
||||
case (lowerUserAgent.indexOf('firefox') >= 0):
|
||||
case lowerUserAgent.indexOf('firefox') >= 0:
|
||||
return 'Firefox';
|
||||
case (lowerUserAgent.indexOf('chrome') >= 0):
|
||||
case lowerUserAgent.indexOf('chrome') >= 0:
|
||||
return 'Chrome';
|
||||
case (lowerUserAgent.indexOf('safari') >= 0):
|
||||
case lowerUserAgent.indexOf('safari') >= 0:
|
||||
return 'Safari';
|
||||
case (lowerUserAgent.indexOf('msie') >= 0):
|
||||
case lowerUserAgent.indexOf('msie') >= 0:
|
||||
return 'Internet Explorer';
|
||||
default:
|
||||
return 'Others';
|
||||
}
|
||||
};
|
||||
|
||||
const extractDomain = url => {
|
||||
const extractDomain = (url) => {
|
||||
const domain = url.indexOf('://') > -1 ? url.split('/')[2] : url.split('/')[0];
|
||||
|
||||
return domain.split(':')[0];
|
||||
};
|
||||
|
||||
export const processOsStats = visits =>
|
||||
export const processOsStats = (visits) =>
|
||||
reduce(
|
||||
(stats, visit) => {
|
||||
const userAgent = visit.userAgent;
|
||||
(stats, { userAgent }) => {
|
||||
const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent);
|
||||
|
||||
return assoc(os, (stats[os] || 0) + 1, stats);
|
||||
},
|
||||
{},
|
||||
visits,
|
||||
);
|
||||
|
||||
export const processBrowserStats = visits =>
|
||||
export const processBrowserStats = (visits) =>
|
||||
reduce(
|
||||
(stats, visit) => {
|
||||
const userAgent = visit.userAgent;
|
||||
(stats, { userAgent }) => {
|
||||
const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent);
|
||||
|
||||
return assoc(browser, (stats[browser] || 0) + 1, stats);
|
||||
},
|
||||
{},
|
||||
visits,
|
||||
);
|
||||
|
||||
export const processReferrersStats = visits =>
|
||||
export const processReferrersStats = (visits) =>
|
||||
reduce(
|
||||
(stats, visit) => {
|
||||
const notHasDomain = isNil(visit.referer) || isEmpty(visit.referer);
|
||||
const domain = notHasDomain ? 'Unknown' : extractDomain(visit.referer);
|
||||
return assoc(domain, (stats[domain]|| 0) + 1, stats);
|
||||
|
||||
return assoc(domain, (stats[domain] || 0) + 1, stats);
|
||||
},
|
||||
{},
|
||||
visits,
|
||||
);
|
||||
|
||||
export const processCountriesStats = visits =>
|
||||
export const processCountriesStats = (visits) =>
|
||||
reduce(
|
||||
(stats, { visitLocation }) => {
|
||||
const notHasCountry = isNil(visitLocation)
|
||||
|| isNil(visitLocation.countryName)
|
||||
|| isEmpty(visitLocation.countryName);
|
||||
const country = notHasCountry ? 'Unknown' : visitLocation.countryName;
|
||||
return assoc(country, (stats[country]|| 0) + 1, stats);
|
||||
|
||||
return assoc(country, (stats[country] || 0) + 1, stats);
|
||||
},
|
||||
{},
|
||||
visits,
|
||||
|
||||
Reference in New Issue
Block a user