mirror of
https://github.com/lidarr/lidarr.git
synced 2025-07-07 21:42:16 -07:00
Initial Commit Rework
This commit is contained in:
parent
74a4cc048c
commit
95051cbd63
2483 changed files with 101351 additions and 111396 deletions
31
frontend/src/Components/Alert.css
Normal file
31
frontend/src/Components/Alert.css
Normal file
|
@ -0,0 +1,31 @@
|
|||
.alert {
|
||||
display: block;
|
||||
margin: 5px;
|
||||
padding: 15px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.danger {
|
||||
border-color: $alertDangerBorderColor;
|
||||
background-color: $alertDangerBackgroundColor;
|
||||
color: $alertDangerColor;
|
||||
}
|
||||
|
||||
.info {
|
||||
border-color: $alertInfoBorderColor;
|
||||
background-color: $alertInfoBackgroundColor;
|
||||
color: $alertInfoColor;
|
||||
}
|
||||
|
||||
.success {
|
||||
border-color: $alertSuccessBorderColor;
|
||||
background-color: $alertSuccessBackgroundColor;
|
||||
color: $alertSuccessColor;
|
||||
}
|
||||
|
||||
.warning {
|
||||
border-color: $alertWarningBorderColor;
|
||||
background-color: $alertWarningBackgroundColor;
|
||||
color: $alertWarningColor;
|
||||
}
|
32
frontend/src/Components/Alert.js
Normal file
32
frontend/src/Components/Alert.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import styles from './Alert.css';
|
||||
|
||||
function Alert({ className, kind, children, ...otherProps }) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind]
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Alert.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
kind: PropTypes.oneOf(kinds.all).isRequired,
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
Alert.defaultProps = {
|
||||
className: styles.alert,
|
||||
kind: kinds.INFO
|
||||
};
|
||||
|
||||
export default Alert;
|
8
frontend/src/Components/Card.css
Normal file
8
frontend/src/Components/Card.css
Normal file
|
@ -0,0 +1,8 @@
|
|||
.card {
|
||||
margin: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
background-color: $white;
|
||||
box-shadow: 0 0 10px 1px $cardShadowColor;
|
||||
color: $defaultColor;
|
||||
}
|
39
frontend/src/Components/Card.js
Normal file
39
frontend/src/Components/Card.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Link from 'Components/Link/Link';
|
||||
import styles from './Card.css';
|
||||
|
||||
class Card extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
children,
|
||||
onPress
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={className}
|
||||
onPress={onPress}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Card.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
Card.defaultProps = {
|
||||
className: styles.card
|
||||
};
|
||||
|
||||
export default Card;
|
21
frontend/src/Components/CircularProgressBar.css
Normal file
21
frontend/src/Components/CircularProgressBar.css
Normal file
|
@ -0,0 +1,21 @@
|
|||
.circularProgressBarContainer {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.circularProgressBar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.circularProgressBarText {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-weight: bold;
|
||||
}
|
140
frontend/src/Components/CircularProgressBar.js
Normal file
140
frontend/src/Components/CircularProgressBar.js
Normal file
|
@ -0,0 +1,140 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import colors from 'Styles/Variables/colors';
|
||||
import styles from './CircularProgressBar.css';
|
||||
|
||||
class CircularProgressBar extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
progress: 0
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._progressStep();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const progress = this.props.progress;
|
||||
|
||||
if (prevProps.progress !== progress) {
|
||||
this._cancelProgressStep();
|
||||
this._progressStep();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelProgressStep();
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_progressStep() {
|
||||
this.requestAnimationFrame = window.requestAnimationFrame(() => {
|
||||
this.setState({
|
||||
progress: this.state.progress + 1
|
||||
}, () => {
|
||||
if (this.state.progress < this.props.progress) {
|
||||
this._progressStep();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_cancelProgressStep() {
|
||||
if (this.requestAnimationFrame) {
|
||||
window.cancelAnimationFrame(this.requestAnimationFrame);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
containerClassName,
|
||||
size,
|
||||
strokeWidth,
|
||||
strokeColor,
|
||||
showProgressText
|
||||
} = this.props;
|
||||
|
||||
const progress = this.state.progress;
|
||||
|
||||
const center = size / 2;
|
||||
const radius = center - strokeWidth;
|
||||
const circumference = Math.PI * (radius * 2);
|
||||
const sizeInPixels = `${size}px`;
|
||||
const strokeDashoffset = ((100 - progress) / 100) * circumference;
|
||||
const progressText = `${Math.round(progress)}%`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={containerClassName}
|
||||
style={{
|
||||
width: sizeInPixels,
|
||||
height: sizeInPixels,
|
||||
lineHeight: sizeInPixels
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className={className}
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
<circle
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx={center}
|
||||
cy={center}
|
||||
strokeDasharray={circumference}
|
||||
style={{
|
||||
stroke: strokeColor,
|
||||
strokeWidth,
|
||||
strokeDashoffset
|
||||
}}
|
||||
>
|
||||
</circle>
|
||||
</svg>
|
||||
|
||||
{
|
||||
showProgressText &&
|
||||
<div className={styles.circularProgressBarText}>
|
||||
{progressText}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CircularProgressBar.propTypes = {
|
||||
className: PropTypes.string,
|
||||
containerClassName: PropTypes.string,
|
||||
size: PropTypes.number,
|
||||
progress: PropTypes.number.isRequired,
|
||||
strokeWidth: PropTypes.number,
|
||||
strokeColor: PropTypes.string,
|
||||
showProgressText: PropTypes.bool
|
||||
};
|
||||
|
||||
CircularProgressBar.defaultProps = {
|
||||
className: styles.circularProgressBar,
|
||||
containerClassName: styles.circularProgressBarContainer,
|
||||
size: 60,
|
||||
strokeWidth: 5,
|
||||
strokeColor: colors.sonarrBlue,
|
||||
showProgressText: false
|
||||
};
|
||||
|
||||
export default CircularProgressBar;
|
|
@ -0,0 +1,4 @@
|
|||
.descriptionList {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
27
frontend/src/Components/DescriptionList/DescriptionList.js
Normal file
27
frontend/src/Components/DescriptionList/DescriptionList.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import styles from './DescriptionList.css';
|
||||
|
||||
class DescriptionList extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<dl className={styles.descriptionList}>
|
||||
{children}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DescriptionList.propTypes = {
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
export default DescriptionList;
|
|
@ -0,0 +1,44 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import DescriptionListItemTitle from './DescriptionListItemTitle';
|
||||
import DescriptionListItemDescription from './DescriptionListItemDescription';
|
||||
|
||||
class DescriptionListItem extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
titleClassName,
|
||||
descriptionClassName,
|
||||
title,
|
||||
data
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<span>
|
||||
<DescriptionListItemTitle
|
||||
className={titleClassName}
|
||||
>
|
||||
{title}
|
||||
</DescriptionListItemTitle>
|
||||
|
||||
<DescriptionListItemDescription
|
||||
className={descriptionClassName}
|
||||
>
|
||||
{data}
|
||||
</DescriptionListItemDescription>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DescriptionListItem.propTypes = {
|
||||
titleClassName: PropTypes.string,
|
||||
descriptionClassName: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
|
||||
};
|
||||
|
||||
export default DescriptionListItem;
|
|
@ -0,0 +1,13 @@
|
|||
.description {
|
||||
line-height: 1.528571429;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.description {
|
||||
margin-left: 180px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './DescriptionListItemDescription.css';
|
||||
|
||||
function DescriptionListItemDescription(props) {
|
||||
const {
|
||||
className,
|
||||
children
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<dd className={className}>
|
||||
{children}
|
||||
</dd>
|
||||
);
|
||||
}
|
||||
|
||||
DescriptionListItemDescription.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
|
||||
};
|
||||
|
||||
DescriptionListItemDescription.defaultProps = {
|
||||
className: styles.description
|
||||
};
|
||||
|
||||
export default DescriptionListItemDescription;
|
|
@ -0,0 +1,18 @@
|
|||
.title {
|
||||
line-height: 1.528571429;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.title {
|
||||
composes: truncate from 'Styles/Mixins/truncate.css';
|
||||
|
||||
float: left;
|
||||
clear: left;
|
||||
width: 160px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './DescriptionListItemTitle.css';
|
||||
|
||||
function DescriptionListItemTitle(props) {
|
||||
const {
|
||||
className,
|
||||
children
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<dt className={className}>
|
||||
{children}
|
||||
</dt>
|
||||
);
|
||||
}
|
||||
|
||||
DescriptionListItemTitle.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.string
|
||||
};
|
||||
|
||||
DescriptionListItemTitle.defaultProps = {
|
||||
className: styles.title
|
||||
};
|
||||
|
||||
export default DescriptionListItemTitle;
|
9
frontend/src/Components/DragPreviewLayer.css
Normal file
9
frontend/src/Components/DragPreviewLayer.css
Normal file
|
@ -0,0 +1,9 @@
|
|||
.dragLayer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
22
frontend/src/Components/DragPreviewLayer.js
Normal file
22
frontend/src/Components/DragPreviewLayer.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './DragPreviewLayer.css';
|
||||
|
||||
function DragPreviewLayer({ children, ...otherProps }) {
|
||||
return (
|
||||
<div {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DragPreviewLayer.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
DragPreviewLayer.defaultProps = {
|
||||
className: styles.dragLayer
|
||||
};
|
||||
|
||||
export default DragPreviewLayer;
|
19
frontend/src/Components/FieldSet.css
Normal file
19
frontend/src/Components/FieldSet.css
Normal file
|
@ -0,0 +1,19 @@
|
|||
.fieldSet {
|
||||
margin: 0;
|
||||
margin-bottom: 20px;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: block;
|
||||
margin-bottom: 21px;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
color: #3a3f51;
|
||||
font-size: 21px;
|
||||
line-height: inherit;
|
||||
}
|
33
frontend/src/Components/FieldSet.js
Normal file
33
frontend/src/Components/FieldSet.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import styles from './FieldSet.css';
|
||||
|
||||
class FieldSet extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
legend,
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<fieldset className={styles.fieldSet}>
|
||||
<legend className={styles.legend}>
|
||||
{legend}
|
||||
</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
FieldSet.propTypes = {
|
||||
legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
export default FieldSet;
|
5
frontend/src/Components/FileBrowser/FileBrowserModal.css
Normal file
5
frontend/src/Components/FileBrowser/FileBrowserModal.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
.modal {
|
||||
composes: modal from 'Components/Modal/Modal.css';
|
||||
|
||||
height: 600px;
|
||||
}
|
39
frontend/src/Components/FileBrowser/FileBrowserModal.js
Normal file
39
frontend/src/Components/FileBrowser/FileBrowserModal.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import FileBrowserModalContentConnector from './FileBrowserModalContentConnector';
|
||||
import styles from './FileBrowserModal.css';
|
||||
|
||||
class FileBrowserModal extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={styles.modal}
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<FileBrowserModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FileBrowserModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default FileBrowserModal;
|
|
@ -0,0 +1,16 @@
|
|||
.modalBody {
|
||||
composes: modalBody from 'Components/Modal/ModalBody.css';
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pathInput {
|
||||
composes: pathInputWrapper from 'Components/Form/PathInput.css';
|
||||
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
margin-top: 20px;
|
||||
}
|
213
frontend/src/Components/FileBrowser/FileBrowserModalContent.js
Normal file
213
frontend/src/Components/FileBrowser/FileBrowserModalContent.js
Normal file
|
@ -0,0 +1,213 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { scrollDirections } from 'Helpers/Props';
|
||||
import Button from 'Components/Link/Button';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import PathInput from 'Components/Form/PathInput';
|
||||
import FileBrowserRow from './FileBrowserRow';
|
||||
import styles from './FileBrowserModalContent.css';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'type',
|
||||
label: 'Type',
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
isVisible: true
|
||||
}
|
||||
];
|
||||
|
||||
class FileBrowserModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._scrollerNode = null;
|
||||
|
||||
this.state = {
|
||||
isFileBrowserModalOpen: false,
|
||||
currentPath: props.value
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
currentPath
|
||||
} = this.props;
|
||||
|
||||
if (currentPath !== this.state.currentPath) {
|
||||
this.setState({ currentPath });
|
||||
this._scrollerNode.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
setScrollerRef = (ref) => {
|
||||
if (ref) {
|
||||
this._scrollerNode = ReactDOM.findDOMNode(ref);
|
||||
} else {
|
||||
this._scrollerNode = null;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPathInputChange = ({ value }) => {
|
||||
this.setState({ currentPath: value });
|
||||
}
|
||||
|
||||
onRowPress = (path) => {
|
||||
this.props.onFetchPaths(path);
|
||||
}
|
||||
|
||||
onOkPress = () => {
|
||||
this.props.onChange({
|
||||
name: this.props.name,
|
||||
value: this.state.currentPath
|
||||
});
|
||||
|
||||
this.props.onClearPaths();
|
||||
this.props.onModalClose();
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
parent,
|
||||
directories,
|
||||
files,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const emptyParent = parent === '';
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<ModalHeader>
|
||||
File Browser
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody
|
||||
className={styles.modalBody}
|
||||
scrollDirection={scrollDirections.NONE}
|
||||
>
|
||||
<PathInput
|
||||
className={styles.pathInput}
|
||||
placeholder="Start typing or select a path below"
|
||||
hasFileBrowser={false}
|
||||
{...otherProps}
|
||||
value={this.state.currentPath}
|
||||
onChange={this.onPathInputChange}
|
||||
/>
|
||||
|
||||
<Scroller
|
||||
ref={this.setScrollerRef}
|
||||
className={styles.scroller}
|
||||
>
|
||||
<Table columns={columns}>
|
||||
<TableBody>
|
||||
{
|
||||
emptyParent &&
|
||||
<FileBrowserRow
|
||||
type="computer"
|
||||
name="My Computer"
|
||||
path={parent}
|
||||
onPress={this.onRowPress}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!emptyParent && parent &&
|
||||
<FileBrowserRow
|
||||
type="parent"
|
||||
name="..."
|
||||
path={parent}
|
||||
onPress={this.onRowPress}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
directories.map((directory) => {
|
||||
return (
|
||||
<FileBrowserRow
|
||||
key={directory.path}
|
||||
type={directory.type}
|
||||
name={directory.name}
|
||||
path={directory.path}
|
||||
onPress={this.onRowPress}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
{
|
||||
files.map((file) => {
|
||||
return (
|
||||
<FileBrowserRow
|
||||
key={file.path}
|
||||
type={file.type}
|
||||
name={file.name}
|
||||
path={file.path}
|
||||
onPress={this.onRowPress}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Scroller>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={this.onOkPress}
|
||||
>
|
||||
Ok
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FileBrowserModalContent.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
parent: PropTypes.string,
|
||||
currentPath: PropTypes.string.isRequired,
|
||||
directories: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
files: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onFetchPaths: PropTypes.func.isRequired,
|
||||
onClearPaths: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default FileBrowserModalContent;
|
|
@ -0,0 +1,86 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchPaths, clearPaths } from 'Store/Actions/pathActions';
|
||||
import FileBrowserModalContent from './FileBrowserModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.paths,
|
||||
(paths) => {
|
||||
const {
|
||||
parent,
|
||||
currentPath,
|
||||
directories,
|
||||
files
|
||||
} = paths;
|
||||
|
||||
const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
|
||||
return path.toLowerCase().startsWith(currentPath.toLowerCase());
|
||||
});
|
||||
|
||||
return {
|
||||
parent,
|
||||
currentPath,
|
||||
directories,
|
||||
files,
|
||||
paths: filteredPaths
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchPaths,
|
||||
clearPaths
|
||||
};
|
||||
|
||||
class FileBrowserModalContentConnector extends Component {
|
||||
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchPaths({ path: this.props.value });
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onFetchPaths = (path) => {
|
||||
this.props.fetchPaths({ path });
|
||||
}
|
||||
|
||||
onClearPaths = () => {
|
||||
// this.props.clearPaths();
|
||||
}
|
||||
|
||||
onModalClose = () => {
|
||||
this.props.clearPaths();
|
||||
this.props.onModalClose();
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<FileBrowserModalContent
|
||||
onFetchPaths={this.onFetchPaths}
|
||||
onClearPaths={this.onClearPaths}
|
||||
{...this.props}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FileBrowserModalContentConnector.propTypes = {
|
||||
value: PropTypes.string,
|
||||
fetchPaths: PropTypes.func.isRequired,
|
||||
clearPaths: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(FileBrowserModalContentConnector);
|
5
frontend/src/Components/FileBrowser/FileBrowserRow.css
Normal file
5
frontend/src/Components/FileBrowser/FileBrowserRow.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
.type {
|
||||
composes: cell from 'Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 32px;
|
||||
}
|
62
frontend/src/Components/FileBrowser/FileBrowserRow.js
Normal file
62
frontend/src/Components/FileBrowser/FileBrowserRow.js
Normal file
|
@ -0,0 +1,62 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import TableRowButton from 'Components/Table/TableRowButton';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import styles from './FileBrowserRow.css';
|
||||
|
||||
function getIconName(type) {
|
||||
switch (type) {
|
||||
case 'computer':
|
||||
return icons.COMPUTER;
|
||||
case 'drive':
|
||||
return icons.DRIVE;
|
||||
case 'file':
|
||||
return icons.FILE;
|
||||
case 'parent':
|
||||
return icons.PARENT;
|
||||
default:
|
||||
return icons.FOLDER;
|
||||
}
|
||||
}
|
||||
|
||||
class FileBrowserRow extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
this.props.onPress(this.props.path);
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
type,
|
||||
name
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<TableRowButton onPress={this.onPress}>
|
||||
<TableRowCell className={styles.type}>
|
||||
<Icon name={getIconName(type)} />
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>{name}</TableRowCell>
|
||||
</TableRowButton>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
FileBrowserRow.propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default FileBrowserRow;
|
23
frontend/src/Components/Form/CaptchaInput.css
Normal file
23
frontend/src/Components/Form/CaptchaInput.css
Normal file
|
@ -0,0 +1,23 @@
|
|||
.captchaInputWrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.input {
|
||||
composes: input from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasError {
|
||||
composes: hasError from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasWarning {
|
||||
composes: hasWarning from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasButton {
|
||||
composes: hasButton from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.recaptchaWrapper {
|
||||
margin-top: 10px;
|
||||
}
|
86
frontend/src/Components/Form/CaptchaInput.js
Normal file
86
frontend/src/Components/Form/CaptchaInput.js
Normal file
|
@ -0,0 +1,86 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import classNames from 'classnames';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import FormInputButton from './FormInputButton';
|
||||
import TextInput from './TextInput';
|
||||
import styles from './CaptchaInput.css';
|
||||
|
||||
function CaptchaInput(props) {
|
||||
const {
|
||||
className,
|
||||
name,
|
||||
value,
|
||||
hasError,
|
||||
hasWarning,
|
||||
refreshing,
|
||||
siteKey,
|
||||
secretToken,
|
||||
onChange,
|
||||
onRefreshPress,
|
||||
onCaptchaChange
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.captchaInputWrapper}>
|
||||
<TextInput
|
||||
className={classNames(
|
||||
className,
|
||||
styles.hasButton,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning
|
||||
)}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<FormInputButton
|
||||
onPress={onRefreshPress}
|
||||
>
|
||||
<Icon
|
||||
name={classNames(
|
||||
icons.REFRESH,
|
||||
refreshing && 'fa-spin'
|
||||
)}
|
||||
/>
|
||||
</FormInputButton>
|
||||
</div>
|
||||
|
||||
{
|
||||
!!siteKey && !!secretToken &&
|
||||
<div className={styles.recaptchaWrapper}>
|
||||
<ReCAPTCHA
|
||||
sitekey={siteKey}
|
||||
stoken={secretToken}
|
||||
onChange={onCaptchaChange}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CaptchaInput.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
hasError: PropTypes.bool,
|
||||
hasWarning: PropTypes.bool,
|
||||
refreshing: PropTypes.bool.isRequired,
|
||||
siteKey: PropTypes.string,
|
||||
secretToken: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onRefreshPress: PropTypes.func.isRequired,
|
||||
onCaptchaChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
CaptchaInput.defaultProps = {
|
||||
className: styles.input,
|
||||
value: ''
|
||||
};
|
||||
|
||||
export default CaptchaInput;
|
98
frontend/src/Components/Form/CaptchaInputConnector.js
Normal file
98
frontend/src/Components/Form/CaptchaInputConnector.js
Normal file
|
@ -0,0 +1,98 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { refreshCaptcha, getCaptchaCookie, resetCaptcha } from 'Store/Actions/captchaActions';
|
||||
import CaptchaInput from './CaptchaInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.captcha,
|
||||
(captcha) => {
|
||||
return captcha;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
refreshCaptcha,
|
||||
getCaptchaCookie,
|
||||
resetCaptcha
|
||||
};
|
||||
|
||||
class CaptchaInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
name,
|
||||
token,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
if (token && token !== prevProps.token) {
|
||||
onChange({ name, value: token });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
this.props.resetCaptcha();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onRefreshPress = () => {
|
||||
const {
|
||||
provider,
|
||||
providerData
|
||||
} = this.props;
|
||||
|
||||
this.props.refreshCaptcha({ provider, providerData });
|
||||
}
|
||||
|
||||
onCaptchaChange = (captchaResponse) => {
|
||||
// If the captcha has expired `captchaResponse` will be null.
|
||||
// In the event it's null don't try to get the captchaCookie.
|
||||
// TODO: Should we clear the cookie? or reset the captcha?
|
||||
|
||||
if (!captchaResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
provider,
|
||||
providerData
|
||||
} = this.props;
|
||||
|
||||
this.props.getCaptchaCookie({ provider, providerData, captchaResponse });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<CaptchaInput
|
||||
{...this.props}
|
||||
onRefreshPress={this.onRefreshPress}
|
||||
onCaptchaChange={this.onCaptchaChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CaptchaInputConnector.propTypes = {
|
||||
provider: PropTypes.string.isRequired,
|
||||
providerData: PropTypes.object.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
token: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
refreshCaptcha: PropTypes.func.isRequired,
|
||||
getCaptchaCookie: PropTypes.func.isRequired,
|
||||
resetCaptcha: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(CaptchaInputConnector);
|
105
frontend/src/Components/Form/CheckInput.css
Normal file
105
frontend/src/Components/Form/CheckInput.css
Normal file
|
@ -0,0 +1,105 @@
|
|||
.container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 65%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
margin-bottom: 0;
|
||||
min-height: 21px;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
pointer-events: none;
|
||||
|
||||
&:global(.isDisabled) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1 0 auto;
|
||||
margin-top: 7px;
|
||||
margin-right: 5px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 2px;
|
||||
background-color: $white;
|
||||
color: $white;
|
||||
text-align: center;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.checkbox:focus + .input {
|
||||
outline: 0;
|
||||
border-color: $inputFocusBorderColor;
|
||||
box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
|
||||
}
|
||||
|
||||
.dangerIsChecked {
|
||||
border-color: $dangerColor;
|
||||
background-color: $dangerColor;
|
||||
|
||||
&.isDisabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.primaryIsChecked {
|
||||
border-color: $primaryColor;
|
||||
background-color: $primaryColor;
|
||||
|
||||
&.isDisabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.successIsChecked {
|
||||
border-color: $successColor;
|
||||
background-color: $successColor;
|
||||
|
||||
&.isDisabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.warningIsChecked {
|
||||
border-color: $warningColor;
|
||||
background-color: $warningColor;
|
||||
|
||||
&.isDisabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.isNotChecked {
|
||||
&.isDisabled {
|
||||
border-color: $disabledCheckInputColor;
|
||||
background-color: $disabledCheckInputColor;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.isIndeterminate {
|
||||
border-color: $gray;
|
||||
background-color: $gray;
|
||||
}
|
||||
|
||||
.helpText {
|
||||
composes: helpText from 'Components/Form/FormInputHelpText.css';
|
||||
|
||||
margin-top: 8px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.isDisabled {
|
||||
cursor: not-allowed;
|
||||
}
|
187
frontend/src/Components/Form/CheckInput.js
Normal file
187
frontend/src/Components/Form/CheckInput.js
Normal file
|
@ -0,0 +1,187 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import FormInputHelpText from './FormInputHelpText';
|
||||
import styles from './CheckInput.css';
|
||||
|
||||
class CheckInput extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._checkbox = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setIndeterminate();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.setIndeterminate();
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
setIndeterminate() {
|
||||
if (!this._checkbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
value,
|
||||
uncheckedValue,
|
||||
checkedValue
|
||||
} = this.props;
|
||||
|
||||
this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue;
|
||||
}
|
||||
|
||||
toggleChecked = (checked, shiftKey) => {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
checkedValue,
|
||||
uncheckedValue
|
||||
} = this.props;
|
||||
|
||||
const newValue = checked ? checkedValue : uncheckedValue;
|
||||
|
||||
if (value !== newValue) {
|
||||
this.props.onChange({
|
||||
name,
|
||||
value: newValue,
|
||||
shiftKey
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
setRef = (ref) => {
|
||||
this._checkbox = ref;
|
||||
}
|
||||
|
||||
onClick = (event) => {
|
||||
const shiftKey = event.nativeEvent.shiftKey;
|
||||
const checked = !this._checkbox.checked;
|
||||
|
||||
event.preventDefault();
|
||||
this.toggleChecked(checked, shiftKey);
|
||||
}
|
||||
|
||||
onChange = (event) => {
|
||||
const checked = event.target.checked;
|
||||
const shiftKey = event.nativeEvent.shiftKey;
|
||||
|
||||
this.toggleChecked(checked, shiftKey);
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
containerClassName,
|
||||
name,
|
||||
value,
|
||||
checkedValue,
|
||||
uncheckedValue,
|
||||
helpText,
|
||||
helpTextWarning,
|
||||
isDisabled,
|
||||
kind
|
||||
} = this.props;
|
||||
|
||||
const isChecked = value === checkedValue;
|
||||
const isUnchecked = value === uncheckedValue;
|
||||
const isIndeterminate = !isChecked && !isUnchecked;
|
||||
const isCheckClass = `${kind}IsChecked`;
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<label
|
||||
className={styles.label}
|
||||
onClick={this.onClick}
|
||||
>
|
||||
<input
|
||||
ref={this.setRef}
|
||||
className={styles.checkbox}
|
||||
type="checkbox"
|
||||
name={name}
|
||||
checked={isChecked}
|
||||
disabled={isDisabled}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
isChecked ? styles[isCheckClass] : styles.isNotChecked,
|
||||
isIndeterminate && styles.isIndeterminate,
|
||||
isDisabled && styles.isDisabled
|
||||
)}
|
||||
>
|
||||
{
|
||||
isChecked &&
|
||||
<Icon name={icons.CHECK} />
|
||||
}
|
||||
|
||||
{
|
||||
isIndeterminate &&
|
||||
<Icon name={icons.CHECK_INDETERMINATE} />
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
helpText &&
|
||||
<FormInputHelpText
|
||||
className={styles.helpText}
|
||||
text={helpText}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!helpText && helpTextWarning &&
|
||||
<FormInputHelpText
|
||||
className={styles.helpText}
|
||||
text={helpTextWarning}
|
||||
isWarning={true}
|
||||
/>
|
||||
}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CheckInput.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
containerClassName: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
checkedValue: PropTypes.bool,
|
||||
uncheckedValue: PropTypes.bool,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
|
||||
helpText: PropTypes.string,
|
||||
helpTextWarning: PropTypes.string,
|
||||
isDisabled: PropTypes.bool,
|
||||
kind: PropTypes.oneOf(kinds.all).isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
CheckInput.defaultProps = {
|
||||
className: styles.input,
|
||||
containerClassName: styles.container,
|
||||
checkedValue: true,
|
||||
uncheckedValue: false,
|
||||
kind: kinds.PRIMARY
|
||||
};
|
||||
|
||||
export default CheckInput;
|
66
frontend/src/Components/Form/EnhancedSelectInput.css
Normal file
66
frontend/src/Components/Form/EnhancedSelectInput.css
Normal file
|
@ -0,0 +1,66 @@
|
|||
.tether {
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.enhancedSelect {
|
||||
composes: input from 'Components/Form/Input.css';
|
||||
composes: link from 'Components/Link/Link.css';
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 16px;
|
||||
width: 100%;
|
||||
height: 35px;
|
||||
border: 1px solid $inputBorderColor;
|
||||
border-radius: 4px;
|
||||
background-color: $white;
|
||||
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
|
||||
color: $black;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.hasError {
|
||||
composes: hasError from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasWarning {
|
||||
composes: hasWarning from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.isDisabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dropdownArrowContainer {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.optionsContainer {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.options {
|
||||
border: 1px solid $inputBorderColor;
|
||||
border-radius: 4px;
|
||||
background-color: $white;
|
||||
}
|
||||
|
||||
.optionsModal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
max-width: 90%;
|
||||
width: 350px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.optionsInnerModalBody {
|
||||
composes: innerModalBody from 'Components/Modal/ModalBody.css';
|
||||
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
border: 1px solid $inputBorderColor;
|
||||
border-radius: 4px;
|
||||
background-color: $white;
|
||||
}
|
399
frontend/src/Components/Form/EnhancedSelectInput.js
Normal file
399
frontend/src/Components/Form/EnhancedSelectInput.js
Normal file
|
@ -0,0 +1,399 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import Measure from 'react-measure';
|
||||
import TetherComponent from 'react-tether';
|
||||
import classNames from 'classnames';
|
||||
import isMobileUtil from 'Utilities/isMobile';
|
||||
import * as keyCodes from 'Utilities/Constants/keyCodes';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
|
||||
import EnhancedSelectInputOption from './EnhancedSelectInputOption';
|
||||
import styles from './EnhancedSelectInput.css';
|
||||
|
||||
const tetherOptions = {
|
||||
skipMoveElement: true,
|
||||
constraints: [
|
||||
{
|
||||
to: 'window',
|
||||
attachment: 'together',
|
||||
pin: true
|
||||
}
|
||||
],
|
||||
attachment: 'top left',
|
||||
targetAttachment: 'bottom left'
|
||||
};
|
||||
|
||||
function isArrowKey(keyCode) {
|
||||
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
|
||||
}
|
||||
|
||||
function getSelectedOption(selectedIndex, values) {
|
||||
return values[selectedIndex];
|
||||
}
|
||||
|
||||
function findIndex(startingIndex, direction, values) {
|
||||
let indexToTest = startingIndex + direction;
|
||||
|
||||
while (indexToTest !== startingIndex) {
|
||||
if (indexToTest < 0) {
|
||||
indexToTest = values.length - 1;
|
||||
} else if (indexToTest >= values.length) {
|
||||
indexToTest = 0;
|
||||
}
|
||||
|
||||
if (getSelectedOption(indexToTest, values).isDisabled) {
|
||||
indexToTest = indexToTest + direction;
|
||||
} else {
|
||||
return indexToTest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function previousIndex(selectedIndex, values) {
|
||||
return findIndex(selectedIndex, -1, values);
|
||||
}
|
||||
|
||||
function nextIndex(selectedIndex, values) {
|
||||
return findIndex(selectedIndex, 1, values);
|
||||
}
|
||||
|
||||
function getSelectedIndex(props) {
|
||||
const {
|
||||
value,
|
||||
values
|
||||
} = props;
|
||||
|
||||
return values.findIndex((v) => {
|
||||
return v.key === value;
|
||||
});
|
||||
}
|
||||
|
||||
function getKey(selectedIndex, values) {
|
||||
return values[selectedIndex].key;
|
||||
}
|
||||
|
||||
class EnhancedSelectInput extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isOpen: false,
|
||||
selectedIndex: getSelectedIndex(props),
|
||||
width: 0,
|
||||
isMobile: isMobileUtil()
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.value !== this.props.value) {
|
||||
this.setState({
|
||||
selectedIndex: getSelectedIndex(this.props)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_setButtonRef = (ref) => {
|
||||
this._buttonRef = ref;
|
||||
}
|
||||
|
||||
_setOptionsRef = (ref) => {
|
||||
this._optionsRef = ref;
|
||||
}
|
||||
|
||||
_addListener() {
|
||||
window.addEventListener('click', this.onWindowClick);
|
||||
}
|
||||
|
||||
_removeListener() {
|
||||
window.removeEventListener('click', this.onWindowClick);
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onWindowClick = (event) => {
|
||||
const button = ReactDOM.findDOMNode(this._buttonRef);
|
||||
const options = ReactDOM.findDOMNode(this._optionsRef);
|
||||
|
||||
if (!button || this.state.isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!button.contains(event.target) &&
|
||||
options &&
|
||||
!options.contains(event.target) &&
|
||||
this.state.isOpen
|
||||
) {
|
||||
this.setState({ isOpen: false });
|
||||
this._removeListener();
|
||||
}
|
||||
}
|
||||
|
||||
onBlur = () => {
|
||||
this.setState({
|
||||
selectedIndex: getSelectedIndex(this.props)
|
||||
});
|
||||
}
|
||||
|
||||
onKeyDown = (event) => {
|
||||
const {
|
||||
values
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
selectedIndex
|
||||
} = this.state;
|
||||
|
||||
const keyCode = event.keyCode;
|
||||
const newState = {};
|
||||
|
||||
if (!isOpen) {
|
||||
if (isArrowKey(keyCode)) {
|
||||
event.preventDefault();
|
||||
newState.isOpen = true;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedIndex == null ||
|
||||
getSelectedOption(selectedIndex, values).isDisabled
|
||||
) {
|
||||
if (keyCode === keyCodes.UP_ARROW) {
|
||||
newState.selectedIndex = previousIndex(0, values);
|
||||
} else if (keyCode === keyCodes.DOWN_ARROW) {
|
||||
newState.selectedIndex = nextIndex(values.length - 1, values);
|
||||
}
|
||||
}
|
||||
|
||||
this.setState(newState);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyCode === keyCodes.UP_ARROW) {
|
||||
event.preventDefault();
|
||||
newState.selectedIndex = previousIndex(selectedIndex, values);
|
||||
}
|
||||
|
||||
if (keyCode === keyCodes.DOWN_ARROW) {
|
||||
event.preventDefault();
|
||||
newState.selectedIndex = nextIndex(selectedIndex, values);
|
||||
}
|
||||
|
||||
if (keyCode === keyCodes.ENTER) {
|
||||
event.preventDefault();
|
||||
newState.isOpen = false;
|
||||
this.onSelect(getKey(selectedIndex, values));
|
||||
}
|
||||
|
||||
if (keyCode === keyCodes.TAB) {
|
||||
newState.isOpen = false;
|
||||
this.onSelect(getKey(selectedIndex, values));
|
||||
}
|
||||
|
||||
if (keyCode === keyCodes.ESCAPE) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
newState.isOpen = false;
|
||||
newState.selectedIndex = getSelectedIndex(this.props);
|
||||
}
|
||||
|
||||
if (!_.isEmpty(newState)) {
|
||||
this.setState(newState);
|
||||
}
|
||||
}
|
||||
|
||||
onPress = () => {
|
||||
if (this.state.isOpen) {
|
||||
this._removeListener();
|
||||
} else {
|
||||
this._addListener();
|
||||
}
|
||||
|
||||
this.setState({ isOpen: !this.state.isOpen });
|
||||
}
|
||||
|
||||
onSelect = (value) => {
|
||||
this.setState({ isOpen: false });
|
||||
|
||||
this.props.onChange({
|
||||
name: this.props.name,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
onMeasure = ({ width }) => {
|
||||
this.setState({ width });
|
||||
}
|
||||
|
||||
onOptionsModalClose = () => {
|
||||
this.setState({ isOpen: false });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
disabledClassName,
|
||||
values,
|
||||
isDisabled,
|
||||
hasError,
|
||||
hasWarning,
|
||||
selectedValueOptions,
|
||||
selectedValueComponent: SelectedValueComponent,
|
||||
optionComponent: OptionComponent
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
selectedIndex,
|
||||
width,
|
||||
isOpen,
|
||||
isMobile
|
||||
} = this.state;
|
||||
|
||||
const selectedOption = getSelectedOption(selectedIndex, values);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TetherComponent
|
||||
classes={{
|
||||
element: styles.tether
|
||||
}}
|
||||
{...tetherOptions}
|
||||
>
|
||||
<Measure
|
||||
whitelist={['width']}
|
||||
onMeasure={this.onMeasure}
|
||||
>
|
||||
<Link
|
||||
ref={this._setButtonRef}
|
||||
className={classNames(
|
||||
className,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
isDisabled && disabledClassName
|
||||
)}
|
||||
onBlur={this.onBlur}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onPress={this.onPress}
|
||||
>
|
||||
<SelectedValueComponent
|
||||
{...selectedValueOptions}
|
||||
{...selectedOption}
|
||||
>
|
||||
{selectedOption ? selectedOption.value : null}
|
||||
</SelectedValueComponent>
|
||||
|
||||
<div className={styles.dropdownArrowContainer}>
|
||||
<Icon
|
||||
name={icons.CARET_DOWN}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</Measure>
|
||||
|
||||
{
|
||||
isOpen && !isMobile &&
|
||||
<div
|
||||
ref={this._setOptionsRef}
|
||||
className={styles.optionsContainer}
|
||||
style={{
|
||||
minWidth: width
|
||||
}}
|
||||
>
|
||||
<div className={styles.options}>
|
||||
{
|
||||
values.map((v, index) => {
|
||||
return (
|
||||
<OptionComponent
|
||||
key={v.key}
|
||||
id={v.key}
|
||||
isSelected={index === selectedIndex}
|
||||
{...v}
|
||||
isMobile={false}
|
||||
onSelect={this.onSelect}
|
||||
>
|
||||
{v.value}
|
||||
</OptionComponent>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</TetherComponent>
|
||||
|
||||
{
|
||||
isMobile &&
|
||||
<Modal
|
||||
className={styles.optionsModal}
|
||||
isOpen={isOpen}
|
||||
onModalClose={this.onOptionsModalClose}
|
||||
>
|
||||
<ModalBody
|
||||
innerClassName={styles.optionsInnerModalBody}
|
||||
>
|
||||
{
|
||||
values.map((v, index) => {
|
||||
return (
|
||||
<OptionComponent
|
||||
key={v.key}
|
||||
id={v.key}
|
||||
isSelected={index === selectedIndex}
|
||||
{...v}
|
||||
isMobile={true}
|
||||
onSelect={this.onSelect}
|
||||
>
|
||||
{v.value}
|
||||
</OptionComponent>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EnhancedSelectInput.propTypes = {
|
||||
className: PropTypes.string,
|
||||
disabledClassName: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
values: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isDisabled: PropTypes.bool,
|
||||
hasError: PropTypes.bool,
|
||||
hasWarning: PropTypes.bool,
|
||||
selectedValueOptions: PropTypes.object.isRequired,
|
||||
selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
|
||||
optionComponent: PropTypes.func,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
EnhancedSelectInput.defaultProps = {
|
||||
className: styles.enhancedSelect,
|
||||
disabledClassName: styles.isDisabled,
|
||||
isDisabled: false,
|
||||
selectedValueOptions: {},
|
||||
selectedValueComponent: EnhancedSelectInputSelectedValue,
|
||||
optionComponent: EnhancedSelectInputOption
|
||||
};
|
||||
|
||||
export default EnhancedSelectInput;
|
37
frontend/src/Components/Form/EnhancedSelectInputOption.css
Normal file
37
frontend/src/Components/Form/EnhancedSelectInputOption.css
Normal file
|
@ -0,0 +1,37 @@
|
|||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 10px;
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
.isSelected {
|
||||
background-color: #e2e2e2;
|
||||
|
||||
&.isMobile {
|
||||
background-color: inherit;
|
||||
|
||||
.iconContainer {
|
||||
color: $primaryColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.isDisabled {
|
||||
background-color: #aaa;
|
||||
}
|
||||
|
||||
.isMobile {
|
||||
height: 50px;
|
||||
border-bottom: 1px solid $borderColor;
|
||||
|
||||
&:last-child {
|
||||
border: none;
|
||||
}
|
||||
}
|
77
frontend/src/Components/Form/EnhancedSelectInputOption.js
Normal file
77
frontend/src/Components/Form/EnhancedSelectInputOption.js
Normal file
|
@ -0,0 +1,77 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import styles from './EnhancedSelectInputOption.css';
|
||||
|
||||
class EnhancedSelectInputOption extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
const {
|
||||
id,
|
||||
onSelect
|
||||
} = this.props;
|
||||
|
||||
onSelect(id);
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
isSelected,
|
||||
isDisabled,
|
||||
isMobile,
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
isSelected && styles.isSelected,
|
||||
isDisabled && styles.isDisabled,
|
||||
isMobile && styles.isMobile
|
||||
)}
|
||||
component="div"
|
||||
isDisabled={isDisabled}
|
||||
onPress={this.onPress}
|
||||
>
|
||||
{children}
|
||||
|
||||
{
|
||||
isMobile &&
|
||||
<div className={styles.iconContainer}>
|
||||
<Icon
|
||||
name={isSelected ? icons.CHECK_CIRCLE : icons.CIRCLE_OUTLINE}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EnhancedSelectInputOption.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
isSelected: PropTypes.bool.isRequired,
|
||||
isDisabled: PropTypes.bool.isRequired,
|
||||
isMobile: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
onSelect: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
EnhancedSelectInputOption.defaultProps = {
|
||||
className: styles.option,
|
||||
isDisabled: false
|
||||
};
|
||||
|
||||
export default EnhancedSelectInputOption;
|
|
@ -0,0 +1,3 @@
|
|||
.selectedValue {
|
||||
flex: 1 1 auto;
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './EnhancedSelectInputSelectedValue.css';
|
||||
|
||||
function EnhancedSelectInputSelectedValue(props) {
|
||||
const {
|
||||
className,
|
||||
children
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EnhancedSelectInputSelectedValue.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
EnhancedSelectInputSelectedValue.defaultProps = {
|
||||
className: styles.selectedValue
|
||||
};
|
||||
|
||||
export default EnhancedSelectInputSelectedValue;
|
11
frontend/src/Components/Form/Form.css
Normal file
11
frontend/src/Components/Form/Form.css
Normal file
|
@ -0,0 +1,11 @@
|
|||
.form {
|
||||
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $dangerColor;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: $warningColor;
|
||||
}
|
52
frontend/src/Components/Form/Form.js
Normal file
52
frontend/src/Components/Form/Form.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './Form.css';
|
||||
|
||||
function Form({ children, validationErrors, validationWarnings, ...otherProps }) {
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{
|
||||
validationErrors.map((error, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={styles.error}
|
||||
>
|
||||
{error.errorMessage}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
{
|
||||
validationWarnings.map((warning, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={styles.error}
|
||||
>
|
||||
{warning.errorMessage}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Form.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
validationErrors: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
validationWarnings: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||
};
|
||||
|
||||
Form.defaultProps = {
|
||||
validationErrors: [],
|
||||
validationWarnings: []
|
||||
};
|
||||
|
||||
export default Form;
|
24
frontend/src/Components/Form/FormGroup.css
Normal file
24
frontend/src/Components/Form/FormGroup.css
Normal file
|
@ -0,0 +1,24 @@
|
|||
.group {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
|
||||
.small {
|
||||
max-width: $formGroupSmallWidth;
|
||||
}
|
||||
|
||||
.medium {
|
||||
max-width: $formGroupMediumWidth;
|
||||
}
|
||||
|
||||
.large {
|
||||
max-width: $formGroupLargeWidth;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointLarge) {
|
||||
.group {
|
||||
display: block;
|
||||
}
|
||||
}
|
56
frontend/src/Components/Form/FormGroup.js
Normal file
56
frontend/src/Components/Form/FormGroup.js
Normal file
|
@ -0,0 +1,56 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { map } from 'Helpers/elementChildren';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import styles from './FormGroup.css';
|
||||
|
||||
function FormGroup(props) {
|
||||
const {
|
||||
className,
|
||||
children,
|
||||
size,
|
||||
advancedSettings,
|
||||
isAdvanced,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
if (!advancedSettings && isAdvanced) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const childProps = isAdvanced ? { isAdvanced } : {};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
styles[size]
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
{
|
||||
map(children, (child) => {
|
||||
return React.cloneElement(child, childProps);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FormGroup.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
size: PropTypes.string.isRequired,
|
||||
advancedSettings: PropTypes.bool.isRequired,
|
||||
isAdvanced: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
FormGroup.defaultProps = {
|
||||
className: styles.group,
|
||||
size: sizes.SMALL,
|
||||
advancedSettings: false,
|
||||
isAdvanced: false
|
||||
};
|
||||
|
||||
export default FormGroup;
|
12
frontend/src/Components/Form/FormInputButton.css
Normal file
12
frontend/src/Components/Form/FormInputButton.css
Normal file
|
@ -0,0 +1,12 @@
|
|||
.button {
|
||||
composes: button from 'Components/Link/Button.css';
|
||||
|
||||
border-left: none;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.middleButton {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
54
frontend/src/Components/Form/FormInputButton.js
Normal file
54
frontend/src/Components/Form/FormInputButton.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import styles from './FormInputButton.css';
|
||||
|
||||
function FormInputButton(props) {
|
||||
const {
|
||||
className,
|
||||
canSpin,
|
||||
isLastButton,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
if (canSpin) {
|
||||
return (
|
||||
<SpinnerButton
|
||||
className={classNames(
|
||||
className,
|
||||
!isLastButton && styles.middleButton
|
||||
)}
|
||||
kind={kinds.PRIMARY}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={classNames(
|
||||
className,
|
||||
!isLastButton && styles.middleButton
|
||||
)}
|
||||
kind={kinds.PRIMARY}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
FormInputButton.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
isLastButton: PropTypes.bool.isRequired,
|
||||
canSpin: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
FormInputButton.defaultProps = {
|
||||
className: styles.button,
|
||||
isLastButton: true,
|
||||
canSpin: false
|
||||
};
|
||||
|
||||
export default FormInputButton;
|
30
frontend/src/Components/Form/FormInputGroup.css
Normal file
30
frontend/src/Components/Form/FormInputGroup.css
Normal file
|
@ -0,0 +1,30 @@
|
|||
.inputGroupContainer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.inputGroup {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.pendingChangesContainer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.pendingChangesIcon {
|
||||
color: $warningColor;
|
||||
font-size: 20px;
|
||||
line-height: 35px;
|
||||
}
|
||||
|
||||
.helpLink {
|
||||
margin-top: 5px;
|
||||
line-height: 20px;
|
||||
}
|
235
frontend/src/Components/Form/FormInputGroup.js
Normal file
235
frontend/src/Components/Form/FormInputGroup.js
Normal file
|
@ -0,0 +1,235 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { icons, inputTypes } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import CaptchaInputConnector from './CaptchaInputConnector';
|
||||
import CheckInput from './CheckInput';
|
||||
import MonitorEpisodesSelectInput from './MonitorEpisodesSelectInput';
|
||||
import NumberInput from './NumberInput';
|
||||
import OAuthInputConnector from './OAuthInputConnector';
|
||||
import PasswordInput from './PasswordInput';
|
||||
import PathInputConnector from './PathInputConnector';
|
||||
import QualityProfileSelectInputConnector from './QualityProfileSelectInputConnector';
|
||||
import LanguageProfileSelectInputConnector from './LanguageProfileSelectInputConnector';
|
||||
import RootFolderSelectInputConnector from './RootFolderSelectInputConnector';
|
||||
import SeriesTypeSelectInput from './SeriesTypeSelectInput';
|
||||
import SelectInput from './SelectInput';
|
||||
import TagInputConnector from './TagInputConnector';
|
||||
import TextTagInputConnector from './TextTagInputConnector';
|
||||
import TextInput from './TextInput';
|
||||
import FormInputHelpText from './FormInputHelpText';
|
||||
import styles from './FormInputGroup.css';
|
||||
|
||||
function getComponent(type) {
|
||||
switch (type) {
|
||||
case inputTypes.CAPTCHA:
|
||||
return CaptchaInputConnector;
|
||||
|
||||
case inputTypes.CHECK:
|
||||
return CheckInput;
|
||||
|
||||
case inputTypes.MONITOR_EPISODES_SELECT:
|
||||
return MonitorEpisodesSelectInput;
|
||||
|
||||
case inputTypes.NUMBER:
|
||||
return NumberInput;
|
||||
|
||||
case inputTypes.OAUTH:
|
||||
return OAuthInputConnector;
|
||||
|
||||
case inputTypes.PASSWORD:
|
||||
return PasswordInput;
|
||||
|
||||
case inputTypes.PATH:
|
||||
return PathInputConnector;
|
||||
|
||||
case inputTypes.QUALITY_PROFILE_SELECT:
|
||||
return QualityProfileSelectInputConnector;
|
||||
|
||||
case inputTypes.LANGUAGE_PROFILE_SELECT:
|
||||
return LanguageProfileSelectInputConnector;
|
||||
|
||||
case inputTypes.ROOT_FOLDER_SELECT:
|
||||
return RootFolderSelectInputConnector;
|
||||
|
||||
case inputTypes.SELECT:
|
||||
return SelectInput;
|
||||
|
||||
case inputTypes.SERIES_TYPE_SELECT:
|
||||
return SeriesTypeSelectInput;
|
||||
|
||||
case inputTypes.TAG:
|
||||
return TagInputConnector;
|
||||
|
||||
case inputTypes.TEXT_TAG:
|
||||
return TextTagInputConnector;
|
||||
|
||||
default:
|
||||
return TextInput;
|
||||
}
|
||||
}
|
||||
|
||||
function FormInputGroup(props) {
|
||||
const {
|
||||
className,
|
||||
containerClassName,
|
||||
inputClassName,
|
||||
type,
|
||||
buttons,
|
||||
helpText,
|
||||
helpTexts,
|
||||
helpTextWarning,
|
||||
helpLink,
|
||||
pending,
|
||||
errors,
|
||||
warnings,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const InputComponent = getComponent(type);
|
||||
const checkInput = type === inputTypes.CHECK;
|
||||
const hasError = !!errors.length;
|
||||
const hasWarning = !hasError && !!warnings.length;
|
||||
const buttonsArray = React.Children.toArray(buttons);
|
||||
const lastButtonIndex = buttonsArray.length - 1;
|
||||
const hasButton = !!buttonsArray.length;
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<div className={className}>
|
||||
<div className={styles.inputContainer}>
|
||||
<InputComponent
|
||||
className={inputClassName}
|
||||
helpText={helpText}
|
||||
helpTextWarning={helpTextWarning}
|
||||
hasError={hasError}
|
||||
hasWarning={hasWarning}
|
||||
hasButton={hasButton}
|
||||
{...otherProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
buttonsArray.map((button, index) => {
|
||||
return React.cloneElement(
|
||||
button,
|
||||
{
|
||||
isLastButton: index === lastButtonIndex
|
||||
}
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
{/* <div className={styles.pendingChangesContainer}>
|
||||
{
|
||||
pending &&
|
||||
<Icon
|
||||
name={icons.UNSAVED_SETTING}
|
||||
className={styles.pendingChangesIcon}
|
||||
title="Change has not been saved yet"
|
||||
/>
|
||||
}
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{
|
||||
!checkInput && helpText &&
|
||||
<FormInputHelpText
|
||||
text={helpText}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!checkInput && helpTexts &&
|
||||
<div>
|
||||
{
|
||||
helpTexts.map((text, index) => {
|
||||
return (
|
||||
<FormInputHelpText
|
||||
key={index}
|
||||
text={text}
|
||||
isCheckInput={checkInput}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
!checkInput && helpTextWarning &&
|
||||
<FormInputHelpText
|
||||
text={helpTextWarning}
|
||||
isWarning={true}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
helpLink &&
|
||||
<Link
|
||||
to={helpLink}
|
||||
>
|
||||
More Info
|
||||
</Link>
|
||||
}
|
||||
|
||||
{
|
||||
errors.map((error, index) => {
|
||||
return (
|
||||
<FormInputHelpText
|
||||
key={index}
|
||||
text={error.message}
|
||||
link={error.link}
|
||||
linkTooltip={error.detailedMessage}
|
||||
isError={true}
|
||||
isCheckInput={checkInput}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
{
|
||||
warnings.map((warning, index) => {
|
||||
return (
|
||||
<FormInputHelpText
|
||||
key={index}
|
||||
text={warning.message}
|
||||
link={warning.link}
|
||||
linkTooltip={warning.detailedMessage}
|
||||
isWarning={true}
|
||||
isCheckInput={checkInput}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FormInputGroup.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
containerClassName: PropTypes.string.isRequired,
|
||||
inputClassName: PropTypes.string,
|
||||
type: PropTypes.string.isRequired,
|
||||
buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
|
||||
helpText: PropTypes.string,
|
||||
helpTexts: PropTypes.arrayOf(PropTypes.string),
|
||||
helpTextWarning: PropTypes.string,
|
||||
helpLink: PropTypes.string,
|
||||
pending: PropTypes.bool,
|
||||
errors: PropTypes.arrayOf(PropTypes.object),
|
||||
warnings: PropTypes.arrayOf(PropTypes.object)
|
||||
};
|
||||
|
||||
FormInputGroup.defaultProps = {
|
||||
className: styles.inputGroup,
|
||||
containerClassName: styles.inputGroupContainer,
|
||||
type: inputTypes.TEXT,
|
||||
buttons: [],
|
||||
helpTexts: [],
|
||||
errors: [],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
export default FormInputGroup;
|
39
frontend/src/Components/Form/FormInputHelpText.css
Normal file
39
frontend/src/Components/Form/FormInputHelpText.css
Normal file
|
@ -0,0 +1,39 @@
|
|||
.helpText {
|
||||
margin-top: 5px;
|
||||
color: $helpTextColor;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.isError {
|
||||
color: $dangerColor;
|
||||
|
||||
.link {
|
||||
color: $dangerColor;
|
||||
|
||||
&:hover {
|
||||
color: #e01313;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.isWarning {
|
||||
color: $warningColor;
|
||||
|
||||
.link {
|
||||
color: $warningColor;
|
||||
|
||||
&:hover {
|
||||
color: #e36c00;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.isCheckInput {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.link {
|
||||
composes: link from 'Components/Link/Link.css';
|
||||
|
||||
margin-left: 5px;
|
||||
}
|
62
frontend/src/Components/Form/FormInputHelpText.js
Normal file
62
frontend/src/Components/Form/FormInputHelpText.js
Normal file
|
@ -0,0 +1,62 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import styles from './FormInputHelpText.css';
|
||||
|
||||
function FormInputHelpText(props) {
|
||||
const {
|
||||
className,
|
||||
text,
|
||||
link,
|
||||
linkTooltip,
|
||||
isError,
|
||||
isWarning,
|
||||
isCheckInput
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={classNames(
|
||||
className,
|
||||
isError && styles.isError,
|
||||
isWarning && styles.isWarning,
|
||||
isCheckInput && styles.isCheckInput
|
||||
)}>
|
||||
{text}
|
||||
|
||||
{
|
||||
!!link &&
|
||||
<Link
|
||||
className={styles.link}
|
||||
to={link}
|
||||
title={linkTooltip}
|
||||
>
|
||||
<Icon
|
||||
name={icons.EXTERNAL_LINK}
|
||||
/>
|
||||
</Link>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FormInputHelpText.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
link: PropTypes.string,
|
||||
linkTooltip: PropTypes.string,
|
||||
isError: PropTypes.bool,
|
||||
isWarning: PropTypes.bool,
|
||||
isCheckInput: PropTypes.bool
|
||||
};
|
||||
|
||||
FormInputHelpText.defaultProps = {
|
||||
className: styles.helpText,
|
||||
isError: false,
|
||||
isWarning: false,
|
||||
isCheckInput: false
|
||||
};
|
||||
|
||||
export default FormInputHelpText;
|
22
frontend/src/Components/Form/FormLabel.css
Normal file
22
frontend/src/Components/Form/FormLabel.css
Normal file
|
@ -0,0 +1,22 @@
|
|||
.label {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 $formLabelWidth;
|
||||
margin-right: $formLabelRightMarginWidth;
|
||||
font-weight: bold;
|
||||
line-height: 35px;
|
||||
}
|
||||
|
||||
.hasError {
|
||||
color: $dangerColor;
|
||||
}
|
||||
|
||||
.isAdvanced {
|
||||
color: $advancedFormLabelColor;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointLarge) {
|
||||
.label {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
45
frontend/src/Components/Form/FormLabel.js
Normal file
45
frontend/src/Components/Form/FormLabel.js
Normal file
|
@ -0,0 +1,45 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import styles from './FormLabel.css';
|
||||
|
||||
function FormLabel({
|
||||
children,
|
||||
className,
|
||||
errorClassName,
|
||||
name,
|
||||
hasError,
|
||||
isAdvanced,
|
||||
...otherProps
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
{...otherProps}
|
||||
className={classNames(
|
||||
className,
|
||||
hasError && errorClassName,
|
||||
isAdvanced && styles.isAdvanced
|
||||
)}
|
||||
htmlFor={name}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
FormLabel.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
className: PropTypes.string,
|
||||
errorClassName: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
hasError: PropTypes.bool,
|
||||
isAdvanced: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
FormLabel.defaultProps = {
|
||||
className: styles.label,
|
||||
errorClassName: styles.hasError,
|
||||
isAdvanced: false
|
||||
};
|
||||
|
||||
export default FormLabel;
|
30
frontend/src/Components/Form/Input.css
Normal file
30
frontend/src/Components/Form/Input.css
Normal file
|
@ -0,0 +1,30 @@
|
|||
.input {
|
||||
padding: 6px 16px;
|
||||
width: 100%;
|
||||
height: 35px;
|
||||
border: 1px solid $inputBorderColor;
|
||||
border-radius: 4px;
|
||||
background-color: $white;
|
||||
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
border-color: $inputFocusBorderColor;
|
||||
box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
|
||||
}
|
||||
}
|
||||
|
||||
.hasError {
|
||||
border-color: $inputErrorBorderColor;
|
||||
box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputErrorBoxShadowColor;
|
||||
}
|
||||
|
||||
.hasWarning {
|
||||
border-color: $inputWarningBorderColor;
|
||||
box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputWarningBoxShadowColor;
|
||||
}
|
||||
|
||||
.hasButton {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import SelectInput from './SelectInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.languageProfiles,
|
||||
(state, { includeNoChange }) => includeNoChange,
|
||||
(state, { includeMixed }) => includeMixed,
|
||||
(languageProfiles, includeNoChange, includeMixed) => {
|
||||
const values = _.map(languageProfiles.items.sort(sortByName), (languageProfile) => {
|
||||
return {
|
||||
key: languageProfile.id,
|
||||
value: languageProfile.name
|
||||
};
|
||||
});
|
||||
|
||||
if (includeNoChange) {
|
||||
values.unshift({
|
||||
key: 'noChange',
|
||||
value: 'No Change',
|
||||
disabled: true
|
||||
});
|
||||
}
|
||||
|
||||
if (includeMixed) {
|
||||
values.unshift({
|
||||
key: 'mixed',
|
||||
value: '(Mixed)',
|
||||
disabled: true
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
values
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
class LanguageProfileSelectInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
values
|
||||
} = this.props;
|
||||
|
||||
if (!value || !_.some(values, (option) => parseInt(option.key) === value)) {
|
||||
const firstValue = _.find(values, (option) => !isNaN(parseInt(option.key)));
|
||||
|
||||
if (firstValue) {
|
||||
this.onChange({ name, value: firstValue.key });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onChange = ({ name, value }) => {
|
||||
this.props.onChange({ name, value: parseInt(value) });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SelectInput
|
||||
{...this.props}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LanguageProfileSelectInputConnector.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||
values: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
includeNoChange: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
LanguageProfileSelectInputConnector.defaultProps = {
|
||||
includeNoChange: false
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps)(LanguageProfileSelectInputConnector);
|
59
frontend/src/Components/Form/MonitorEpisodesSelectInput.js
Normal file
59
frontend/src/Components/Form/MonitorEpisodesSelectInput.js
Normal file
|
@ -0,0 +1,59 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import SelectInput from './SelectInput';
|
||||
|
||||
const monitorOptions = [
|
||||
{ key: 'all', value: 'All Episodes' },
|
||||
{ key: 'future', value: 'Future Episodes' },
|
||||
{ key: 'missing', value: 'Missing Episodes' },
|
||||
{ key: 'existing', value: 'Existing Episodes' },
|
||||
{ key: 'first', value: 'Only First Season' },
|
||||
{ key: 'latest', value: 'Only Latest Season' },
|
||||
{ key: 'none', value: 'None' }
|
||||
];
|
||||
|
||||
function MonitorEpisodesSelectInput(props) {
|
||||
const {
|
||||
includeNoChange,
|
||||
includeMixed,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const values = [...monitorOptions];
|
||||
|
||||
if (includeNoChange) {
|
||||
values.unshift({
|
||||
key: 'noChange',
|
||||
value: 'No Change',
|
||||
disabled: true
|
||||
});
|
||||
}
|
||||
|
||||
if (includeMixed) {
|
||||
values.unshift({
|
||||
key: 'mixed',
|
||||
value: '(Mixed)',
|
||||
disabled: true
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectInput
|
||||
values={values}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
MonitorEpisodesSelectInput.propTypes = {
|
||||
includeNoChange: PropTypes.bool.isRequired,
|
||||
includeMixed: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
MonitorEpisodesSelectInput.defaultProps = {
|
||||
includeNoChange: false,
|
||||
includeMixed: false
|
||||
};
|
||||
|
||||
export default MonitorEpisodesSelectInput;
|
52
frontend/src/Components/Form/NumberInput.js
Normal file
52
frontend/src/Components/Form/NumberInput.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import TextInput from './TextInput';
|
||||
|
||||
class NumberInput extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onChange = ({ name, value }) => {
|
||||
let newValue = null;
|
||||
|
||||
if (value) {
|
||||
newValue = this.props.isFloat ? parseFloat(value) : parseInt(value);
|
||||
}
|
||||
|
||||
this.props.onChange({
|
||||
name,
|
||||
value: newValue
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
type="number"
|
||||
{...otherProps}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
NumberInput.propTypes = {
|
||||
value: PropTypes.number,
|
||||
isFloat: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
NumberInput.defaultProps = {
|
||||
value: null,
|
||||
isFloat: false
|
||||
};
|
||||
|
||||
export default NumberInput;
|
30
frontend/src/Components/Form/OAuthInput.js
Normal file
30
frontend/src/Components/Form/OAuthInput.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
|
||||
function OAuthInput(props) {
|
||||
const {
|
||||
authorizing,
|
||||
onPress
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SpinnerButton
|
||||
kind={kinds.PRIMARY}
|
||||
isSpinning={authorizing}
|
||||
onPress={onPress}
|
||||
>
|
||||
Start OAuth
|
||||
</SpinnerButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
OAuthInput.propTypes = {
|
||||
authorizing: PropTypes.bool.isRequired,
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default OAuthInput;
|
82
frontend/src/Components/Form/OAuthInputConnector.js
Normal file
82
frontend/src/Components/Form/OAuthInputConnector.js
Normal file
|
@ -0,0 +1,82 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { startOAuth, resetOAuth } from 'Store/Actions/oAuthActions';
|
||||
import OAuthInput from './OAuthInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.oAuth,
|
||||
(oAuth) => {
|
||||
return oAuth;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
startOAuth,
|
||||
resetOAuth
|
||||
};
|
||||
|
||||
class OAuthInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
accessToken,
|
||||
accessTokenSecret,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
if (accessToken &&
|
||||
accessToken !== prevProps.accessToken &&
|
||||
accessTokenSecret &&
|
||||
accessTokenSecret !== prevProps.accessTokenSecret) {
|
||||
onChange({ name: 'AccessToken', value: accessToken });
|
||||
onChange({ name: 'AccessTokenSecret', value: accessTokenSecret });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
this.props.resetOAuth();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
const {
|
||||
provider,
|
||||
providerData
|
||||
} = this.props;
|
||||
|
||||
this.props.startOAuth({ provider, providerData });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<OAuthInput
|
||||
{...this.props}
|
||||
onPress={this.onPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
OAuthInputConnector.propTypes = {
|
||||
accessToken: PropTypes.string,
|
||||
accessTokenSecret: PropTypes.string,
|
||||
provider: PropTypes.string.isRequired,
|
||||
providerData: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
startOAuth: PropTypes.func.isRequired,
|
||||
resetOAuth: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(OAuthInputConnector);
|
13
frontend/src/Components/Form/PasswordInput.js
Normal file
13
frontend/src/Components/Form/PasswordInput.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
import TextInput from './TextInput';
|
||||
|
||||
function PasswordInput(props) {
|
||||
return (
|
||||
<TextInput
|
||||
type="password"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordInput;
|
68
frontend/src/Components/Form/PathInput.css
Normal file
68
frontend/src/Components/Form/PathInput.css
Normal file
|
@ -0,0 +1,68 @@
|
|||
.path {
|
||||
composes: input from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasError {
|
||||
composes: hasError from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasWarning {
|
||||
composes: hasWarning from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasFileBrowser {
|
||||
composes: hasButton from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.pathInputWrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pathInputContainer {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.pathContainer {
|
||||
composes: scrollbar from 'Styles/Mixins/scroller.css';
|
||||
composes: scrollbarTrack from 'Styles/Mixins/scroller.css';
|
||||
composes: scrollbarThumb from 'Styles/Mixins/scroller.css';
|
||||
}
|
||||
|
||||
.pathInputContainerOpen {
|
||||
.pathContainer {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 200px;
|
||||
width: 100%;
|
||||
border: 1px solid $inputBorderColor;
|
||||
border-radius: 4px;
|
||||
background-color: $white;
|
||||
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
|
||||
}
|
||||
}
|
||||
|
||||
.pathList {
|
||||
margin: 5px 0;
|
||||
padding-left: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.pathListItem {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.pathMatch {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pathHighlighted {
|
||||
background-color: $menuItemHoverColor;
|
||||
}
|
||||
|
||||
.fileBrowserButton {
|
||||
composes: button from './FormInputButton.css';
|
||||
|
||||
height: 35px;
|
||||
}
|
206
frontend/src/Components/Form/PathInput.js
Normal file
206
frontend/src/Components/Form/PathInput.js
Normal file
|
@ -0,0 +1,206 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Autosuggest from 'react-autosuggest';
|
||||
import classNames from 'classnames';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
|
||||
import FormInputButton from './FormInputButton';
|
||||
import styles from './PathInput.css';
|
||||
|
||||
class PathInput extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isFileBrowserModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
getSuggestionValue({ path }) {
|
||||
return path;
|
||||
}
|
||||
|
||||
renderSuggestion({ path }, { query }) {
|
||||
const lastSeparatorIndex = query.lastIndexOf('\\') || query.lastIndexOf('/');
|
||||
|
||||
if (lastSeparatorIndex === -1) {
|
||||
return (
|
||||
<span>{path}</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<span className={styles.pathMatch}>
|
||||
{path.substr(0, lastSeparatorIndex)}
|
||||
</span>
|
||||
{path.substr(lastSeparatorIndex)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = (event, { newValue }) => {
|
||||
this.props.onChange({
|
||||
name: this.props.name,
|
||||
value: newValue
|
||||
});
|
||||
}
|
||||
|
||||
onInputKeyDown = (event) => {
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
const path = this.props.paths[0];
|
||||
|
||||
this.props.onChange({
|
||||
name: this.props.name,
|
||||
value: path.path
|
||||
});
|
||||
|
||||
if (path.type !== 'file') {
|
||||
this.props.onFetchPaths(path.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onInputBlur = () => {
|
||||
this.props.onClearPaths();
|
||||
this.props.onFetchPaths('');
|
||||
}
|
||||
|
||||
onSuggestionsFetchRequested = ({ value }) => {
|
||||
this.props.onFetchPaths(value);
|
||||
}
|
||||
|
||||
onSuggestionsClearRequested = () => {
|
||||
// Required because props aren't always rendered, but no-op
|
||||
// because we don't want to reset the paths after a path is selected.
|
||||
// `onInputBlur` will handle clearing when the user leaves the input.
|
||||
}
|
||||
|
||||
onSuggestionSelected = (event, { suggestionValue }) => {
|
||||
this.props.onFetchPaths(suggestionValue);
|
||||
}
|
||||
|
||||
onFileBrowserOpenPress = () => {
|
||||
this.setState({ isFileBrowserModalOpen: true });
|
||||
}
|
||||
|
||||
onFileBrowserModalClose = () => {
|
||||
this.setState({ isFileBrowserModalOpen: false });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
inputClassName,
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
paths,
|
||||
hasError,
|
||||
hasWarning,
|
||||
hasFileBrowser,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
const inputProps = {
|
||||
className: classNames(
|
||||
inputClassName,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
hasFileBrowser && styles.hasFileBrowser
|
||||
),
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
autoComplete: 'off',
|
||||
spellCheck: false,
|
||||
onChange: this.onInputChange,
|
||||
onKeyDown: this.onInputKeyDown,
|
||||
onBlur: this.onInputBlur
|
||||
};
|
||||
|
||||
const theme = {
|
||||
container: styles.pathInputContainer,
|
||||
containerOpen: styles.pathInputContainerOpen,
|
||||
suggestionsContainer: styles.pathContainer,
|
||||
suggestionsList: styles.pathList,
|
||||
suggestion: styles.pathListItem,
|
||||
suggestionHighlighted: styles.pathHighlighted
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Autosuggest
|
||||
id={name}
|
||||
inputProps={inputProps}
|
||||
theme={theme}
|
||||
suggestions={paths}
|
||||
getSuggestionValue={this.getSuggestionValue}
|
||||
renderSuggestion={this.renderSuggestion}
|
||||
onSuggestionSelected={this.onSuggestionSelected}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
/>
|
||||
|
||||
{
|
||||
hasFileBrowser &&
|
||||
<div>
|
||||
<FormInputButton
|
||||
className={styles.fileBrowserButton}
|
||||
onPress={this.onFileBrowserOpenPress}
|
||||
>
|
||||
<Icon name={icons.FOLDER_OPEN} />
|
||||
</FormInputButton>
|
||||
|
||||
<FileBrowserModal
|
||||
isOpen={this.state.isFileBrowserModalOpen}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onModalClose={this.onFileBrowserModalClose}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PathInput.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
inputClassName: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
paths: PropTypes.array.isRequired,
|
||||
hasError: PropTypes.bool,
|
||||
hasWarning: PropTypes.bool,
|
||||
hasFileBrowser: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onFetchPaths: PropTypes.func.isRequired,
|
||||
onClearPaths: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
PathInput.defaultProps = {
|
||||
className: styles.pathInputWrapper,
|
||||
inputClassName: styles.path,
|
||||
value: '',
|
||||
hasFileBrowser: true
|
||||
};
|
||||
|
||||
export default PathInput;
|
67
frontend/src/Components/Form/PathInputConnector.js
Normal file
67
frontend/src/Components/Form/PathInputConnector.js
Normal file
|
@ -0,0 +1,67 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchPaths, clearPaths } from 'Store/Actions/pathActions';
|
||||
import PathInput from './PathInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.paths,
|
||||
(paths) => {
|
||||
const {
|
||||
currentPath,
|
||||
directories,
|
||||
files
|
||||
} = paths;
|
||||
|
||||
const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
|
||||
return path.toLowerCase().startsWith(currentPath.toLowerCase());
|
||||
});
|
||||
|
||||
return {
|
||||
paths: filteredPaths
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchPaths,
|
||||
clearPaths
|
||||
};
|
||||
|
||||
class PathInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onFetchPaths = (path) => {
|
||||
this.props.fetchPaths({ path });
|
||||
}
|
||||
|
||||
onClearPaths = () => {
|
||||
this.props.clearPaths();
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<PathInput
|
||||
onFetchPaths={this.onFetchPaths}
|
||||
onClearPaths={this.onClearPaths}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PathInputConnector.propTypes = {
|
||||
fetchPaths: PropTypes.func.isRequired,
|
||||
clearPaths: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(PathInputConnector);
|
126
frontend/src/Components/Form/ProviderFieldFormGroup.js
Normal file
126
frontend/src/Components/Form/ProviderFieldFormGroup.js
Normal file
|
@ -0,0 +1,126 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
|
||||
function getType(type) {
|
||||
// Textbox,
|
||||
// Password,
|
||||
// Checkbox,
|
||||
// Select,
|
||||
// Path,
|
||||
// FilePath,
|
||||
// Hidden,
|
||||
// Tag,
|
||||
// Action,
|
||||
// Url,
|
||||
// Captcha
|
||||
// OAuth
|
||||
|
||||
switch (type) {
|
||||
case 'captcha':
|
||||
return inputTypes.CAPTCHA;
|
||||
case 'checkbox':
|
||||
return inputTypes.CHECK;
|
||||
case 'password':
|
||||
return inputTypes.PASSWORD;
|
||||
case 'path':
|
||||
return inputTypes.PATH;
|
||||
case 'select':
|
||||
return inputTypes.SELECT;
|
||||
case 'textbox':
|
||||
return inputTypes.TEXT;
|
||||
case 'oauth':
|
||||
return inputTypes.OAUTH;
|
||||
default:
|
||||
return inputTypes.TEXT;
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectValues(selectOptions) {
|
||||
if (!selectOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
return _.reduce(selectOptions, (result, option) => {
|
||||
result.push({
|
||||
key: option.value,
|
||||
value: option.name
|
||||
});
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function ProviderFieldFormGroup(props) {
|
||||
const {
|
||||
advancedSettings,
|
||||
name,
|
||||
label,
|
||||
helpText,
|
||||
helpLink,
|
||||
value,
|
||||
type,
|
||||
advanced,
|
||||
pending,
|
||||
errors,
|
||||
warnings,
|
||||
selectOptions,
|
||||
onChange,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={advanced}
|
||||
>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={getType(type)}
|
||||
name={name}
|
||||
helpText={helpText}
|
||||
helpLink={helpLink}
|
||||
value={value}
|
||||
values={getSelectValues(selectOptions)}
|
||||
errors={errors}
|
||||
warnings={warnings}
|
||||
pending={pending}
|
||||
hasFileBrowser={false}
|
||||
onChange={onChange}
|
||||
{...otherProps}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const selectOptionsShape = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
ProviderFieldFormGroup.propTypes = {
|
||||
advancedSettings: PropTypes.bool.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
helpText: PropTypes.string,
|
||||
helpLink: PropTypes.string,
|
||||
value: PropTypes.any,
|
||||
type: PropTypes.string.isRequired,
|
||||
advanced: PropTypes.bool.isRequired,
|
||||
pending: PropTypes.bool.isRequired,
|
||||
errors: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
warnings: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
selectOptions: PropTypes.arrayOf(PropTypes.shape(selectOptionsShape)),
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
ProviderFieldFormGroup.defaultProps = {
|
||||
advancedSettings: false
|
||||
};
|
||||
|
||||
export default ProviderFieldFormGroup;
|
|
@ -0,0 +1,98 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import SelectInput from './SelectInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.qualityProfiles,
|
||||
(state, { includeNoChange }) => includeNoChange,
|
||||
(state, { includeMixed }) => includeMixed,
|
||||
(qualityProfiles, includeNoChange, includeMixed) => {
|
||||
const values = _.map(qualityProfiles.items.sort(sortByName), (qualityProfile) => {
|
||||
return {
|
||||
key: qualityProfile.id,
|
||||
value: qualityProfile.name
|
||||
};
|
||||
});
|
||||
|
||||
if (includeNoChange) {
|
||||
values.unshift({
|
||||
key: 'noChange',
|
||||
value: 'No Change',
|
||||
disabled: true
|
||||
});
|
||||
}
|
||||
|
||||
if (includeMixed) {
|
||||
values.unshift({
|
||||
key: 'mixed',
|
||||
value: '(Mixed)',
|
||||
disabled: true
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
values
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
class QualityProfileSelectInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
values
|
||||
} = this.props;
|
||||
|
||||
if (!value || !_.some(values, (option) => parseInt(option.key) === value)) {
|
||||
const firstValue = _.find(values, (option) => !isNaN(parseInt(option.key)));
|
||||
|
||||
if (firstValue) {
|
||||
this.onChange({ name, value: firstValue.key });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onChange = ({ name, value }) => {
|
||||
this.props.onChange({ name, value: parseInt(value) });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SelectInput
|
||||
{...this.props}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
QualityProfileSelectInputConnector.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||
values: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
includeNoChange: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
QualityProfileSelectInputConnector.defaultProps = {
|
||||
includeNoChange: false
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps)(QualityProfileSelectInputConnector);
|
105
frontend/src/Components/Form/RootFolderSelectInput.js
Normal file
105
frontend/src/Components/Form/RootFolderSelectInput.js
Normal file
|
@ -0,0 +1,105 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
import RootFolderSelectInputOption from './RootFolderSelectInputOption';
|
||||
import RootFolderSelectInputSelectedValue from './RootFolderSelectInputSelectedValue';
|
||||
|
||||
class RootFolderSelectInput extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isAddNewRootFolderModalOpen: false,
|
||||
newRootFolderPath: ''
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
name,
|
||||
values,
|
||||
isSaving,
|
||||
saveError,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
if (
|
||||
prevProps.isSaving &&
|
||||
!isSaving &&
|
||||
!saveError &&
|
||||
values.length - prevProps.values.length === 1
|
||||
) {
|
||||
const newRootFolderPath = this.state.newRootFolderPath;
|
||||
|
||||
onChange({ name, value: newRootFolderPath });
|
||||
this.setState({ newRootFolderPath: '' });
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onChange = ({ name, value }) => {
|
||||
if (value === 'addNew') {
|
||||
this.setState({ isAddNewRootFolderModalOpen: true });
|
||||
} else {
|
||||
this.props.onChange({ name, value });
|
||||
}
|
||||
}
|
||||
|
||||
onNewRootFolderSelect = ({ value }) => {
|
||||
this.setState({ newRootFolderPath: value }, () => {
|
||||
this.props.onNewRootFolderSelect(value);
|
||||
});
|
||||
}
|
||||
|
||||
onAddRootFolderModalClose = () => {
|
||||
this.setState({ isAddNewRootFolderModalOpen: false });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
includeNoChange,
|
||||
onNewRootFolderSelect,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EnhancedSelectInput
|
||||
{...otherProps}
|
||||
selectedValueComponent={RootFolderSelectInputSelectedValue}
|
||||
optionComponent={RootFolderSelectInputOption}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
|
||||
<FileBrowserModal
|
||||
isOpen={this.state.isAddNewRootFolderModalOpen}
|
||||
name="rootFolderPath"
|
||||
value=""
|
||||
onChange={this.onNewRootFolderSelect}
|
||||
onModalClose={this.onAddRootFolderModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RootFolderSelectInput.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
values: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onNewRootFolderSelect: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default RootFolderSelectInput;
|
124
frontend/src/Components/Form/RootFolderSelectInputConnector.js
Normal file
124
frontend/src/Components/Form/RootFolderSelectInputConnector.js
Normal file
|
@ -0,0 +1,124 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { addRootFolder } from 'Store/Actions/rootFolderActions';
|
||||
import RootFolderSelectInput from './RootFolderSelectInput';
|
||||
|
||||
const ADD_NEW_KEY = 'addNew';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.rootFolders,
|
||||
(state, { includeNoChange }) => includeNoChange,
|
||||
(rootFolders, includeNoChange) => {
|
||||
const values = _.map(rootFolders.items, (rootFolder) => {
|
||||
return {
|
||||
key: rootFolder.path,
|
||||
value: rootFolder.path,
|
||||
freeSpace: rootFolder.freeSpace
|
||||
};
|
||||
});
|
||||
|
||||
if (includeNoChange) {
|
||||
values.unshift({
|
||||
key: 'noChange',
|
||||
value: 'No Change',
|
||||
isDisabled: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!values.length) {
|
||||
values.push({
|
||||
key: '',
|
||||
value: '',
|
||||
isDisabled: true
|
||||
});
|
||||
}
|
||||
|
||||
values.push({
|
||||
key: ADD_NEW_KEY,
|
||||
value: 'Add a new path'
|
||||
});
|
||||
|
||||
return {
|
||||
values,
|
||||
isSaving: rootFolders.isSaving,
|
||||
saveError: rootFolders.saveError
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
dispatchAddRootFolder(path) {
|
||||
dispatch(addRootFolder({ path }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class RootFolderSelectInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
values,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
if (!value || !_.some(values, (v) => v.hasOwnProperty(value)) || value === ADD_NEW_KEY) {
|
||||
const defaultValue = values[0];
|
||||
|
||||
if (defaultValue.key === ADD_NEW_KEY) {
|
||||
onChange({ name, value: '' });
|
||||
} else {
|
||||
onChange({ name, value: defaultValue.key });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onNewRootFolderSelect = (path) => {
|
||||
this.props.dispatchAddRootFolder(path);
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
dispatchAddRootFolder,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<RootFolderSelectInput
|
||||
{...otherProps}
|
||||
onNewRootFolderSelect={this.onNewRootFolderSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RootFolderSelectInputConnector.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string,
|
||||
values: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
includeNoChange: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
dispatchAddRootFolder: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
RootFolderSelectInputConnector.defaultProps = {
|
||||
includeNoChange: false
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(RootFolderSelectInputConnector);
|
20
frontend/src/Components/Form/RootFolderSelectInputOption.css
Normal file
20
frontend/src/Components/Form/RootFolderSelectInputOption.css
Normal file
|
@ -0,0 +1,20 @@
|
|||
.optionText {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex: 1 0 0;
|
||||
|
||||
&.isMobile {
|
||||
display: block;
|
||||
|
||||
.freeSpace {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.freeSpace {
|
||||
margin-left: 15px;
|
||||
color: $gray;
|
||||
font-size: $smallFontSize;
|
||||
}
|
44
frontend/src/Components/Form/RootFolderSelectInputOption.js
Normal file
44
frontend/src/Components/Form/RootFolderSelectInputOption.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import EnhancedSelectInputOption from './EnhancedSelectInputOption';
|
||||
import styles from './RootFolderSelectInputOption.css';
|
||||
|
||||
function RootFolderSelectInputOption(props) {
|
||||
const {
|
||||
value,
|
||||
freeSpace,
|
||||
isMobile,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<EnhancedSelectInputOption
|
||||
isMobile={isMobile}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={classNames(
|
||||
styles.optionText,
|
||||
isMobile && styles.isMobile
|
||||
)}>
|
||||
<div>{value}</div>
|
||||
|
||||
{
|
||||
freeSpace != null &&
|
||||
<div className={styles.freeSpace}>
|
||||
{formatBytes(freeSpace)} Free
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</EnhancedSelectInputOption>
|
||||
);
|
||||
}
|
||||
|
||||
RootFolderSelectInputOption.propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
freeSpace: PropTypes.number,
|
||||
isMobile: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default RootFolderSelectInputOption;
|
|
@ -0,0 +1,24 @@
|
|||
.selectedValue {
|
||||
composes: selectedValue from './EnhancedSelectInputSelectedValue.css';
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.path {
|
||||
composes: truncate from 'Styles/mixins/truncate.css';
|
||||
|
||||
flex: 1 0 0;
|
||||
}
|
||||
|
||||
.freeSpace {
|
||||
composes: truncate from 'Styles/mixins/truncate.css';
|
||||
|
||||
flex: 1 0 0;
|
||||
margin-left: 15px;
|
||||
color: $gray;
|
||||
text-align: right;
|
||||
font-size: $smallFontSize;
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
|
||||
import styles from './RootFolderSelectInputSelectedValue.css';
|
||||
|
||||
function RootFolderSelectInputSelectedValue(props) {
|
||||
const {
|
||||
value,
|
||||
freeSpace,
|
||||
includeFreeSpace,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<EnhancedSelectInputSelectedValue
|
||||
className={styles.selectedValue}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.path}>
|
||||
{value
|
||||
}</div>
|
||||
|
||||
{
|
||||
freeSpace != null && includeFreeSpace &&
|
||||
<div className={styles.freeSpace}>
|
||||
{formatBytes(freeSpace)} Free
|
||||
</div>
|
||||
}
|
||||
</EnhancedSelectInputSelectedValue>
|
||||
);
|
||||
}
|
||||
|
||||
RootFolderSelectInputSelectedValue.propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
freeSpace: PropTypes.number,
|
||||
includeFreeSpace: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
RootFolderSelectInputSelectedValue.defaultProps = {
|
||||
includeFreeSpace: true
|
||||
};
|
||||
|
||||
export default RootFolderSelectInputSelectedValue;
|
18
frontend/src/Components/Form/SelectInput.css
Normal file
18
frontend/src/Components/Form/SelectInput.css
Normal file
|
@ -0,0 +1,18 @@
|
|||
.select {
|
||||
composes: input from 'Components/Form/Input.css';
|
||||
|
||||
padding: 0 11px;
|
||||
}
|
||||
|
||||
.hasError {
|
||||
composes: hasError from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasWarning {
|
||||
composes: hasWarning from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.isDisabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
88
frontend/src/Components/Form/SelectInput.js
Normal file
88
frontend/src/Components/Form/SelectInput.js
Normal file
|
@ -0,0 +1,88 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import styles from './SelectInput.css';
|
||||
|
||||
class SelectInput extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onChange = (event) => {
|
||||
this.props.onChange({
|
||||
name: this.props.name,
|
||||
value: event.target.value
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
disabledClassName,
|
||||
name,
|
||||
value,
|
||||
values,
|
||||
isDisabled,
|
||||
hasError,
|
||||
hasWarning
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<select
|
||||
className={classNames(
|
||||
className,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
isDisabled && disabledClassName
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
>
|
||||
{
|
||||
values.map((option) => {
|
||||
const {
|
||||
key,
|
||||
value: optionValue,
|
||||
...otherOptionProps
|
||||
} = option;
|
||||
|
||||
return (
|
||||
<option
|
||||
key={key}
|
||||
value={key}
|
||||
{...otherOptionProps}
|
||||
>
|
||||
{optionValue}
|
||||
</option>
|
||||
);
|
||||
})
|
||||
}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SelectInput.propTypes = {
|
||||
className: PropTypes.string,
|
||||
disabledClassName: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
values: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isDisabled: PropTypes.bool,
|
||||
hasError: PropTypes.bool,
|
||||
hasWarning: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
SelectInput.defaultProps = {
|
||||
className: styles.select,
|
||||
disabledClassName: styles.isDisabled,
|
||||
isDisabled: false
|
||||
};
|
||||
|
||||
export default SelectInput;
|
53
frontend/src/Components/Form/SeriesTypeSelectInput.js
Normal file
53
frontend/src/Components/Form/SeriesTypeSelectInput.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import SelectInput from './SelectInput';
|
||||
|
||||
const seriesTypeOptions = [
|
||||
{ key: 'standard', value: 'Standard' },
|
||||
{ key: 'daily', value: 'Daily' },
|
||||
{ key: 'anime', value: 'Anime' }
|
||||
];
|
||||
|
||||
function SeriesTypeSelectInput(props) {
|
||||
const values = [...seriesTypeOptions];
|
||||
|
||||
const {
|
||||
includeNoChange,
|
||||
includeMixed
|
||||
} = props;
|
||||
|
||||
if (includeNoChange) {
|
||||
values.unshift({
|
||||
key: 'noChange',
|
||||
value: 'No Change',
|
||||
disabled: true
|
||||
});
|
||||
}
|
||||
|
||||
if (includeMixed) {
|
||||
values.unshift({
|
||||
key: 'mixed',
|
||||
value: '(Mixed)',
|
||||
disabled: true
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectInput
|
||||
{...props}
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
SeriesTypeSelectInput.propTypes = {
|
||||
includeNoChange: PropTypes.bool.isRequired,
|
||||
includeMixed: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
SeriesTypeSelectInput.defaultProps = {
|
||||
includeNoChange: false,
|
||||
includeMixed: false
|
||||
};
|
||||
|
||||
export default SeriesTypeSelectInput;
|
97
frontend/src/Components/Form/TagInput.css
Normal file
97
frontend/src/Components/Form/TagInput.css
Normal file
|
@ -0,0 +1,97 @@
|
|||
.container {
|
||||
composes: input from 'Components/Form/Input.css';
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
min-height: 35px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.containerFocused {
|
||||
outline: 0;
|
||||
border-color: $inputFocusBorderColor;
|
||||
box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
|
||||
}
|
||||
|
||||
.selectedTagContainer {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.selectedTag {
|
||||
composes: label from 'Components/Label.css';
|
||||
|
||||
border-style: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Selected Tag Kinds */
|
||||
|
||||
.info {
|
||||
composes: info from 'Components/Label.css';
|
||||
}
|
||||
|
||||
.success {
|
||||
composes: success from 'Components/Label.css';
|
||||
}
|
||||
|
||||
.warning {
|
||||
composes: warning from 'Components/Label.css';
|
||||
}
|
||||
|
||||
.danger {
|
||||
composes: danger from 'Components/Label.css';
|
||||
}
|
||||
|
||||
.searchInputContainer {
|
||||
position: relative;
|
||||
flex: 1 0 100px;
|
||||
margin-top: 1px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 200px;
|
||||
width: 100%;
|
||||
border: 1px solid $inputBorderColor;
|
||||
border-radius: 4px;
|
||||
background-color: $white;
|
||||
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
|
||||
|
||||
ul {
|
||||
margin: 5px 0;
|
||||
padding-left: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
li mark {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
li:hover {
|
||||
background-color: $menuItemHoverColor;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestionActive {
|
||||
background-color: $menuItemHoverColor;
|
||||
}
|
126
frontend/src/Components/Form/TagInput.js
Normal file
126
frontend/src/Components/Form/TagInput.js
Normal file
|
@ -0,0 +1,126 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ReactTags from 'react-tag-autocomplete';
|
||||
import classNames from 'classnames';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import styles from './TagInput.css';
|
||||
|
||||
class TagInput extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._tagsRef = null;
|
||||
this._inputRef = null;
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_setTagsRef = (ref) => {
|
||||
this._tagsRef = ref;
|
||||
|
||||
if (ref) {
|
||||
this._inputRef = this._tagsRef.input.input;
|
||||
|
||||
this._inputRef.addEventListener('blur', this.onInputBlur);
|
||||
} else if (this._inputRef) {
|
||||
this._inputRef.removeEventListener('blur', this.onInputBlur);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputBlur = () => {
|
||||
if (!this._tagsRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
tagList,
|
||||
allowNew
|
||||
} = this.props;
|
||||
|
||||
const query = this._tagsRef.state.query.trim();
|
||||
|
||||
if (query) {
|
||||
const existingTag = _.find(tagList, { name: query });
|
||||
|
||||
if (existingTag) {
|
||||
this._tagsRef.addTag(existingTag);
|
||||
} else if (allowNew) {
|
||||
this._tagsRef.addTag({ name: query });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
tags,
|
||||
tagList,
|
||||
allowNew,
|
||||
kind,
|
||||
placeholder,
|
||||
onTagAdd,
|
||||
onTagDelete
|
||||
} = this.props;
|
||||
|
||||
const tagInputClassNames = {
|
||||
root: styles.container,
|
||||
rootFocused: styles.containerFocused,
|
||||
selected: styles.selectedTagContainer,
|
||||
selectedTag: classNames(styles.selectedTag, styles[kind]),
|
||||
search: styles.searchInputContainer,
|
||||
searchInput: styles.searchInput,
|
||||
suggestions: styles.suggestions,
|
||||
suggestionActive: styles.suggestionActive,
|
||||
suggestionDisabled: styles.suggestionDisabled
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactTags
|
||||
ref={this._setTagsRef}
|
||||
classNames={tagInputClassNames}
|
||||
tags={tags}
|
||||
suggestions={tagList}
|
||||
allowNew={allowNew}
|
||||
minQueryLength={1}
|
||||
placeholder={placeholder}
|
||||
delimiters={[9, 13, 32, 188]}
|
||||
handleAddition={onTagAdd}
|
||||
handleDelete={onTagDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const tagShape = {
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
TagInput.propTypes = {
|
||||
tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
|
||||
tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
|
||||
allowNew: PropTypes.bool.isRequired,
|
||||
kind: PropTypes.oneOf(kinds.all).isRequired,
|
||||
placeholder: PropTypes.string.isRequired,
|
||||
onTagAdd: PropTypes.func.isRequired,
|
||||
onTagDelete: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
TagInput.defaultProps = {
|
||||
allowNew: true,
|
||||
kind: kinds.INFO,
|
||||
placeholder: ''
|
||||
};
|
||||
|
||||
export default TagInput;
|
156
frontend/src/Components/Form/TagInputConnector.js
Normal file
156
frontend/src/Components/Form/TagInputConnector.js
Normal file
|
@ -0,0 +1,156 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { addTag } from 'Store/Actions/tagActions';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import TagInput from './TagInput';
|
||||
|
||||
const validTagRegex = new RegExp('[^-_a-z0-9]', 'i');
|
||||
|
||||
function isValidTag(tagName) {
|
||||
try {
|
||||
return !validTagRegex.test(tagName);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { value }) => value,
|
||||
createTagsSelector(),
|
||||
(tags, tagList) => {
|
||||
const sortedTags = _.sortBy(tagList, 'label');
|
||||
const filteredTagList = _.filter(sortedTags, (tag) => _.indexOf(tags, tag.id) === -1);
|
||||
|
||||
return {
|
||||
tags: tags.reduce((acc, tag) => {
|
||||
const matchingTag = _.find(tagList, { id: tag });
|
||||
|
||||
if (matchingTag) {
|
||||
acc.push({
|
||||
id: tag,
|
||||
name: matchingTag.label
|
||||
});
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []),
|
||||
|
||||
tagList: filteredTagList.map(({ id, label: name }) => {
|
||||
return {
|
||||
id,
|
||||
name
|
||||
};
|
||||
}),
|
||||
|
||||
allTags: sortedTags
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
addTag
|
||||
};
|
||||
|
||||
class TagInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
tags,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
if (value.length !== tags.length) {
|
||||
onChange({ name, value: tags.map((tag) => tag.id) });
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onTagAdd = (tag) => {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
allTags
|
||||
} = this.props;
|
||||
|
||||
if (!tag.id) {
|
||||
const existingTag =_.some(allTags, { label: tag.name });
|
||||
|
||||
if (isValidTag(tag.name) && !existingTag) {
|
||||
this.props.addTag({
|
||||
tag: { label: tag.name },
|
||||
onTagCreated: this.onTagCreated
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = value.slice();
|
||||
newValue.push(tag.id);
|
||||
|
||||
this.props.onChange({ name, value: newValue });
|
||||
}
|
||||
|
||||
onTagDelete = (index) => {
|
||||
const {
|
||||
name,
|
||||
value
|
||||
} = this.props;
|
||||
|
||||
const newValue = value.slice();
|
||||
newValue.splice(index, 1);
|
||||
|
||||
this.props.onChange({
|
||||
name,
|
||||
value: newValue
|
||||
});
|
||||
}
|
||||
|
||||
onTagCreated = (tag) => {
|
||||
const {
|
||||
name,
|
||||
value
|
||||
} = this.props;
|
||||
|
||||
const newValue = value.slice();
|
||||
newValue.push(tag.id);
|
||||
|
||||
this.props.onChange({ name, value: newValue });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<TagInput
|
||||
onTagAdd={this.onTagAdd}
|
||||
onTagDelete={this.onTagDelete}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TagInputConnector.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
allTags: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
addTag: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(TagInputConnector);
|
19
frontend/src/Components/Form/TextInput.css
Normal file
19
frontend/src/Components/Form/TextInput.css
Normal file
|
@ -0,0 +1,19 @@
|
|||
.text {
|
||||
composes: input from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.readOnly {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.hasError {
|
||||
composes: hasError from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasWarning {
|
||||
composes: hasWarning from 'Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasButton {
|
||||
composes: hasButton from 'Components/Form/Input.css';
|
||||
}
|
81
frontend/src/Components/Form/TextInput.js
Normal file
81
frontend/src/Components/Form/TextInput.js
Normal file
|
@ -0,0 +1,81 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import styles from './TextInput.css';
|
||||
|
||||
class TextInput extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onChange = (event) => {
|
||||
this.props.onChange({
|
||||
name: this.props.name,
|
||||
value: event.target.value
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
type,
|
||||
readOnly,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
name,
|
||||
value,
|
||||
hasError,
|
||||
hasWarning,
|
||||
hasButton,
|
||||
onFocus
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
readOnly={readOnly}
|
||||
autoFocus={autoFocus}
|
||||
placeholder={placeholder}
|
||||
className={classNames(
|
||||
className,
|
||||
readOnly && styles.readOnly,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
hasButton && styles.hasButton
|
||||
)}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TextInput.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
readOnly: PropTypes.bool,
|
||||
autoFocus: PropTypes.bool,
|
||||
placeholder: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]).isRequired,
|
||||
hasError: PropTypes.bool,
|
||||
hasWarning: PropTypes.bool,
|
||||
hasButton: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onFocus: PropTypes.func
|
||||
};
|
||||
|
||||
TextInput.defaultProps = {
|
||||
className: styles.text,
|
||||
type: 'text',
|
||||
readOnly: false,
|
||||
autoFocus: false,
|
||||
value: ''
|
||||
};
|
||||
|
||||
export default TextInput;
|
68
frontend/src/Components/Form/TextTagInput.js
Normal file
68
frontend/src/Components/Form/TextTagInput.js
Normal file
|
@ -0,0 +1,68 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ReactTags from 'react-tag-autocomplete';
|
||||
import classNames from 'classnames';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import styles from './TagInput.css';
|
||||
|
||||
class TextTagInput extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
tags,
|
||||
allowNew,
|
||||
kind,
|
||||
placeholder,
|
||||
onTagAdd,
|
||||
onTagDelete
|
||||
} = this.props;
|
||||
|
||||
const tagInputClassNames = {
|
||||
root: styles.container,
|
||||
rootFocused: styles.containerFocused,
|
||||
selected: styles.selectedTagContainer,
|
||||
selectedTag: classNames(styles.selectedTag, styles[kind]),
|
||||
search: styles.searchInputContainer,
|
||||
searchInput: styles.searchInput,
|
||||
suggestions: styles.suggestions,
|
||||
suggestionActive: styles.suggestionActive,
|
||||
suggestionDisabled: styles.suggestionDisabled
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactTags
|
||||
classNames={tagInputClassNames}
|
||||
tags={tags}
|
||||
allowNew={allowNew}
|
||||
minQueryLength={1}
|
||||
placeholder={placeholder}
|
||||
handleAddition={onTagAdd}
|
||||
handleDelete={onTagDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const tagShape = {
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
TextTagInput.propTypes = {
|
||||
tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
|
||||
allowNew: PropTypes.bool.isRequired,
|
||||
kind: PropTypes.string.isRequired,
|
||||
placeholder: PropTypes.string,
|
||||
onTagAdd: PropTypes.func.isRequired,
|
||||
onTagDelete: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
TextTagInput.defaultProps = {
|
||||
allowNew: true,
|
||||
kind: kinds.INFO
|
||||
};
|
||||
|
||||
export default TextTagInput;
|
81
frontend/src/Components/Form/TextTagInputConnector.js
Normal file
81
frontend/src/Components/Form/TextTagInputConnector.js
Normal file
|
@ -0,0 +1,81 @@
|
|||
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import split from 'Utilities/String/split';
|
||||
import TextTagInput from './TextTagInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { value }) => value,
|
||||
(tags) => {
|
||||
return {
|
||||
tags: split(tags).reduce((result, tag) => {
|
||||
if (tag) {
|
||||
result.push({
|
||||
id: tag,
|
||||
name: tag
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [])
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
class TextTagInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onTagAdd = (tag) => {
|
||||
const {
|
||||
name,
|
||||
value
|
||||
} = this.props;
|
||||
|
||||
const newValue = split(value);
|
||||
newValue.push(tag.name);
|
||||
|
||||
this.props.onChange({ name, value: newValue.join(',') });
|
||||
}
|
||||
|
||||
onTagDelete = (index) => {
|
||||
const {
|
||||
name,
|
||||
value
|
||||
} = this.props;
|
||||
|
||||
const newValue = split(value);
|
||||
newValue.splice(index, 1);
|
||||
|
||||
this.props.onChange({
|
||||
name,
|
||||
value: newValue.join(',')
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<TextTagInput
|
||||
onTagAdd={this.onTagAdd}
|
||||
onTagDelete={this.onTagDelete}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TextTagInputConnector.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, null)(TextTagInputConnector);
|
4
frontend/src/Components/HeartRating.css
Normal file
4
frontend/src/Components/HeartRating.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
.heart {
|
||||
margin-right: 5px;
|
||||
color: $themeRed;
|
||||
}
|
30
frontend/src/Components/HeartRating.js
Normal file
30
frontend/src/Components/HeartRating.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import styles from './HeartRating.css';
|
||||
|
||||
function HeartRating({ rating, iconSize }) {
|
||||
return (
|
||||
<span>
|
||||
<Icon
|
||||
className={styles.heart}
|
||||
name={icons.HEART}
|
||||
size={iconSize}
|
||||
/>
|
||||
|
||||
{rating * 10}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
HeartRating.propTypes = {
|
||||
rating: PropTypes.number.isRequired,
|
||||
iconSize: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
HeartRating.defaultProps = {
|
||||
iconSize: 14
|
||||
};
|
||||
|
||||
export default HeartRating;
|
15
frontend/src/Components/Icon.css
Normal file
15
frontend/src/Components/Icon.css
Normal file
|
@ -0,0 +1,15 @@
|
|||
.danger {
|
||||
color: $dangerColor;
|
||||
}
|
||||
|
||||
.default {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: $successColor;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: $warningColor;
|
||||
}
|
45
frontend/src/Components/Icon.js
Normal file
45
frontend/src/Components/Icon.js
Normal file
|
@ -0,0 +1,45 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import classNames from 'classnames';
|
||||
import styles from './Icon.css';
|
||||
|
||||
function Icon(props) {
|
||||
const {
|
||||
className,
|
||||
name,
|
||||
kind,
|
||||
size,
|
||||
title
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<icon
|
||||
className={classNames(
|
||||
name,
|
||||
className,
|
||||
styles[kind]
|
||||
)}
|
||||
title={title}
|
||||
style={{
|
||||
fontSize: `${size}px`
|
||||
}}
|
||||
>
|
||||
</icon>
|
||||
);
|
||||
}
|
||||
|
||||
Icon.propTypes = {
|
||||
className: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
kind: PropTypes.string.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
title: PropTypes.string
|
||||
};
|
||||
|
||||
Icon.defaultProps = {
|
||||
kind: kinds.DEFAULT,
|
||||
size: 14
|
||||
};
|
||||
|
||||
export default Icon;
|
102
frontend/src/Components/Label.css
Normal file
102
frontend/src/Components/Label.css
Normal file
|
@ -0,0 +1,102 @@
|
|||
.label {
|
||||
display: inline-block;
|
||||
margin: 2px;
|
||||
border: 1px solid;
|
||||
border-radius: 2px;
|
||||
color: $white;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/** Kinds **/
|
||||
|
||||
.danger {
|
||||
border-color: $dangerColor;
|
||||
background-color: $dangerColor;
|
||||
|
||||
&.outline {
|
||||
color: $dangerColor;
|
||||
}
|
||||
}
|
||||
|
||||
.default {
|
||||
border-color: $themeLightColor;
|
||||
background-color: $themeLightColor;
|
||||
|
||||
&.outline {
|
||||
color: $themeLightColor;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
border-color: $infoColor;
|
||||
background-color: $infoColor;
|
||||
|
||||
&.outline {
|
||||
color: $infoColor;
|
||||
}
|
||||
}
|
||||
|
||||
.inverse {
|
||||
border-color: $gray;
|
||||
background-color: $gray;
|
||||
color: $defaultColor;
|
||||
|
||||
&.outline {
|
||||
background-color: $defaultColor !important;
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
|
||||
.primary {
|
||||
border-color: $primaryColor;
|
||||
background-color: $primaryColor;
|
||||
|
||||
&.outline {
|
||||
color: $primaryColor;
|
||||
}
|
||||
}
|
||||
|
||||
.success {
|
||||
border-color: $successColor;
|
||||
background-color: $successColor;
|
||||
|
||||
&.outline {
|
||||
color: $successColor;
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
border-color: $warningColor;
|
||||
background-color: $warningColor;
|
||||
|
||||
&.outline {
|
||||
color: $warningColor;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sizes **/
|
||||
|
||||
.small {
|
||||
padding: 1px 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.medium {
|
||||
padding: 2px 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.large {
|
||||
padding: 3px 7px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/** Outline **/
|
||||
|
||||
.outline {
|
||||
background-color: $white;
|
||||
}
|
47
frontend/src/Components/Label.js
Normal file
47
frontend/src/Components/Label.js
Normal file
|
@ -0,0 +1,47 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import styles from './Label.css';
|
||||
|
||||
function Label(props) {
|
||||
const {
|
||||
className,
|
||||
kind,
|
||||
size,
|
||||
outline,
|
||||
children,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind],
|
||||
styles[size],
|
||||
outline && styles.outline
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Label.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
kind: PropTypes.oneOf(kinds.all).isRequired,
|
||||
size: PropTypes.oneOf(sizes.all).isRequired,
|
||||
outline: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
Label.defaultProps = {
|
||||
className: styles.label,
|
||||
kind: kinds.DEFAULT,
|
||||
size: sizes.SMALL,
|
||||
outline: false
|
||||
};
|
||||
|
||||
export default Label;
|
119
frontend/src/Components/Link/Button.css
Normal file
119
frontend/src/Components/Link/Button.css
Normal file
|
@ -0,0 +1,119 @@
|
|||
.button {
|
||||
composes: link from './Link.css';
|
||||
|
||||
overflow: hidden;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
line-height: normal;
|
||||
|
||||
&:global(.isDisabled) {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.danger {
|
||||
border-color: $dangerBorderColor;
|
||||
background-color: $dangerBackgroundColor;
|
||||
color: $white;
|
||||
|
||||
&:hover {
|
||||
border-color: $dangerHoverBorderColor;
|
||||
background-color: $dangerHoverBackgroundColor;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
.default {
|
||||
border-color: $defaultBorderColor;
|
||||
background-color: $defaultBackgroundColor;
|
||||
color: $defaultColor;
|
||||
|
||||
&:hover {
|
||||
border-color: $defaultHoverBorderColor;
|
||||
background-color: $defaultHoverBackgroundColor;
|
||||
color: $defaultColor;
|
||||
}
|
||||
}
|
||||
|
||||
.primary {
|
||||
border-color: $primaryBorderColor;
|
||||
background-color: $primaryBackgroundColor;
|
||||
color: $white;
|
||||
|
||||
&:hover {
|
||||
border-color: $primaryHoverBorderColor;
|
||||
background-color: $primaryHoverBackgroundColor;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
.success {
|
||||
border-color: $successBorderColor;
|
||||
background-color: $successBackgroundColor;
|
||||
color: $white;
|
||||
|
||||
&:hover {
|
||||
border-color: $successHoverBorderColor;
|
||||
background-color: $successHoverBackgroundColor;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
border-color: $warningBorderColor;
|
||||
background-color: $warningBackgroundColor;
|
||||
color: $white;
|
||||
|
||||
&:hover {
|
||||
border-color: $warningHoverBorderColor;
|
||||
background-color: $warningHoverBackgroundColor;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Sizes
|
||||
*/
|
||||
|
||||
.small {
|
||||
padding: 1px 5px;
|
||||
font-size: $smallFontSize;
|
||||
}
|
||||
|
||||
.medium {
|
||||
padding: 6px 16px;
|
||||
font-size: $defaultFontSize;
|
||||
}
|
||||
|
||||
.large {
|
||||
padding: 10px 20px;
|
||||
font-size: $largeFontSize;
|
||||
}
|
||||
|
||||
/*
|
||||
* Sizes
|
||||
*/
|
||||
|
||||
.left {
|
||||
margin-left: -1px;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin-left: -1px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.right {
|
||||
margin-left: -1px;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
54
frontend/src/Components/Link/Button.js
Normal file
54
frontend/src/Components/Link/Button.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { align, kinds, sizes } from 'Helpers/Props';
|
||||
import Link from './Link';
|
||||
import styles from './Button.css';
|
||||
|
||||
class Button extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
buttonGroupPosition,
|
||||
kind,
|
||||
size,
|
||||
children,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind],
|
||||
styles[size],
|
||||
buttonGroupPosition && styles[buttonGroupPosition]
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Button.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
buttonGroupPosition: PropTypes.oneOf(align.all),
|
||||
kind: PropTypes.oneOf(kinds.all),
|
||||
size: PropTypes.oneOf(sizes.all),
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
Button.defaultProps = {
|
||||
className: styles.button,
|
||||
kind: kinds.DEFAULT,
|
||||
size: sizes.MEDIUM
|
||||
};
|
||||
|
||||
export default Button;
|
33
frontend/src/Components/Link/ClipboardButton.css
Normal file
33
frontend/src/Components/Link/ClipboardButton.css
Normal file
|
@ -0,0 +1,33 @@
|
|||
.button {
|
||||
composes: button from 'Components/Form/FormInputButton.css';
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stateIconContainer {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -100%;
|
||||
display: inline-flex;
|
||||
visibility: hidden;
|
||||
transition: left $defaultSpeed;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
}
|
||||
|
||||
.clipboardIconContainer {
|
||||
position: relative;
|
||||
left: 0;
|
||||
transition: left $defaultSpeed, opacity $defaultSpeed;
|
||||
}
|
||||
|
||||
.showStateIcon {
|
||||
.stateIconContainer {
|
||||
left: 50%;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.clipboardIconContainer {
|
||||
left: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
128
frontend/src/Components/Link/ClipboardButton.js
Normal file
128
frontend/src/Components/Link/ClipboardButton.js
Normal file
|
@ -0,0 +1,128 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Clipboard from 'Clipboard';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import getUniqueElememtId from 'Utilities/getUniqueElementId';
|
||||
import Icon from 'Components/Icon';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import styles from './ClipboardButton.css';
|
||||
|
||||
class ClipboardButton extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._id = getUniqueElememtId();
|
||||
this._successTimeout = null;
|
||||
|
||||
this.state = {
|
||||
showSuccess: false,
|
||||
showError: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._clipboard = new Clipboard(`#${this._id}`, {
|
||||
text: () => this.props.value
|
||||
});
|
||||
|
||||
this._clipboard.on('success', this.onSuccess);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const {
|
||||
showSuccess,
|
||||
showError
|
||||
} = this.state;
|
||||
|
||||
if (showSuccess || showError) {
|
||||
this._testResultTimeout = setTimeout(this.resetState, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._clipboard) {
|
||||
this._clipboard.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
resetState = () => {
|
||||
this.setState({
|
||||
showSuccess: false,
|
||||
showError: false
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSuccess = () => {
|
||||
this.setState({
|
||||
showSuccess: true
|
||||
});
|
||||
}
|
||||
|
||||
onError = () => {
|
||||
this.setState({
|
||||
showError: true
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
value,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
|
||||
const {
|
||||
showSuccess,
|
||||
showError
|
||||
} = this.state;
|
||||
|
||||
const showStateIcon = showSuccess || showError;
|
||||
const iconName = showError ? icons.DANGER : icons.CHECK;
|
||||
const iconKind = showError ? kinds.DANGER : kinds.SUCCESS;
|
||||
|
||||
return (
|
||||
<FormInputButton
|
||||
id={this._id}
|
||||
className={styles.button}
|
||||
{...otherProps}
|
||||
>
|
||||
<span className={showStateIcon && styles.showStateIcon}>
|
||||
{
|
||||
showSuccess &&
|
||||
<span className={styles.stateIconContainer}>
|
||||
<Icon
|
||||
name={iconName}
|
||||
kind={iconKind}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
|
||||
{
|
||||
<span className={styles.clipboardIconContainer}>
|
||||
<Icon name={icons.CLIPBOARD} />
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</FormInputButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ClipboardButton.propTypes = {
|
||||
value: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default ClipboardButton;
|
16
frontend/src/Components/Link/IconButton.css
Normal file
16
frontend/src/Components/Link/IconButton.css
Normal file
|
@ -0,0 +1,16 @@
|
|||
.button {
|
||||
composes: link from 'Components/Link/Link.css';
|
||||
|
||||
margin: 0 2px;
|
||||
width: 22px;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
text-align: center;
|
||||
font-size: inherit;
|
||||
|
||||
&:hover {
|
||||
border: none;
|
||||
background-color: inherit;
|
||||
color: $iconButtonHoverColor;
|
||||
}
|
||||
}
|
44
frontend/src/Components/Link/IconButton.js
Normal file
44
frontend/src/Components/Link/IconButton.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from './Link';
|
||||
import styles from './IconButton.css';
|
||||
|
||||
function IconButton(props) {
|
||||
const {
|
||||
className,
|
||||
iconClassName,
|
||||
name,
|
||||
kind,
|
||||
size,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={className}
|
||||
{...otherProps}
|
||||
>
|
||||
<Icon
|
||||
className={iconClassName}
|
||||
name={name}
|
||||
kind={kind}
|
||||
size={size}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
IconButton.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
iconClassName: PropTypes.string,
|
||||
kind: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
size: PropTypes.number
|
||||
};
|
||||
|
||||
IconButton.defaultProps = {
|
||||
className: styles.button
|
||||
};
|
||||
|
||||
export default IconButton;
|
25
frontend/src/Components/Link/Link.css
Normal file
25
frontend/src/Components/Link/Link.css
Normal file
|
@ -0,0 +1,25 @@
|
|||
.link {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
text-align: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:global(.isDisabled) {
|
||||
/*color: $disabledColor;*/
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.to {
|
||||
color: $linkColor;
|
||||
|
||||
&:hover {
|
||||
color: $linkHoverColor;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
101
frontend/src/Components/Link/Link.js
Normal file
101
frontend/src/Components/Link/Link.js
Normal file
|
@ -0,0 +1,101 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import styles from './Link.css';
|
||||
|
||||
class Link extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onClick = (event) => {
|
||||
const {
|
||||
isDisabled,
|
||||
onPress
|
||||
} = this.props;
|
||||
|
||||
if (!isDisabled && onPress) {
|
||||
onPress(event);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
component,
|
||||
to,
|
||||
target,
|
||||
isDisabled,
|
||||
noRouter,
|
||||
onPress,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const linkProps = { target };
|
||||
let el = component;
|
||||
|
||||
if (to) {
|
||||
if (/\w+?:\/\//.test(to)) {
|
||||
el = 'a';
|
||||
linkProps.href = to;
|
||||
linkProps.target = target || '_blank';
|
||||
} else if (noRouter) {
|
||||
el = 'a';
|
||||
linkProps.href = to;
|
||||
linkProps.target = target || '_self';
|
||||
} else if (to.startsWith(window.Sonarr.urlBase)) {
|
||||
el = RouterLink;
|
||||
linkProps.to = to;
|
||||
linkProps.target = target;
|
||||
} else {
|
||||
el = RouterLink;
|
||||
linkProps.to = `${window.Sonarr.urlBase}/${to.replace(/^\//, '')}`;
|
||||
linkProps.target = target;
|
||||
}
|
||||
}
|
||||
|
||||
if (el === 'button' || el === 'input') {
|
||||
linkProps.type = otherProps.type || 'button';
|
||||
linkProps.disabled = isDisabled;
|
||||
}
|
||||
|
||||
linkProps.className = classNames(
|
||||
className,
|
||||
styles.link,
|
||||
to && styles.to,
|
||||
isDisabled && 'isDisabled'
|
||||
);
|
||||
|
||||
const props = {
|
||||
...otherProps,
|
||||
...linkProps
|
||||
};
|
||||
|
||||
props.onClick = this.onClick;
|
||||
|
||||
return (
|
||||
React.createElement(el, props)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Link.propTypes = {
|
||||
className: PropTypes.string,
|
||||
component: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
||||
to: PropTypes.string,
|
||||
target: PropTypes.string,
|
||||
isDisabled: PropTypes.bool,
|
||||
noRouter: PropTypes.bool,
|
||||
onPress: PropTypes.func
|
||||
};
|
||||
|
||||
Link.defaultProps = {
|
||||
component: 'button',
|
||||
noRouter: false
|
||||
};
|
||||
|
||||
export default Link;
|
37
frontend/src/Components/Link/SpinnerButton.css
Normal file
37
frontend/src/Components/Link/SpinnerButton.css
Normal file
|
@ -0,0 +1,37 @@
|
|||
.button {
|
||||
composes: button from 'Components/Link/Button.css';
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.spinnerContainer {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -100%;
|
||||
display: inline-flex;
|
||||
visibility: hidden;
|
||||
transition: left $defaultSpeed;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.label {
|
||||
position: relative;
|
||||
left: 0;
|
||||
transition: left $defaultSpeed, opacity $defaultSpeed;
|
||||
}
|
||||
|
||||
.isSpinning {
|
||||
.spinnerContainer {
|
||||
left: 50%;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.label {
|
||||
left: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
59
frontend/src/Components/Link/SpinnerButton.js
Normal file
59
frontend/src/Components/Link/SpinnerButton.js
Normal file
|
@ -0,0 +1,59 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from './Button';
|
||||
import styles from './SpinnerButton.css';
|
||||
|
||||
function SpinnerButton(props) {
|
||||
const {
|
||||
className,
|
||||
isSpinning,
|
||||
isDisabled,
|
||||
spinnerIcon,
|
||||
children,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={classNames(
|
||||
className,
|
||||
styles.button,
|
||||
isSpinning && styles.isSpinning
|
||||
)}
|
||||
isDisabled={isDisabled || isSpinning}
|
||||
{...otherProps}
|
||||
>
|
||||
<span className={styles.spinnerContainer}>
|
||||
<Icon
|
||||
className={styles.spinner}
|
||||
name={classNames(
|
||||
spinnerIcon,
|
||||
'fa-spin'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className={styles.label}>
|
||||
{children}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
SpinnerButton.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
isSpinning: PropTypes.bool.isRequired,
|
||||
isDisabled: PropTypes.bool,
|
||||
spinnerIcon: PropTypes.string.isRequired,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
SpinnerButton.defaultProps = {
|
||||
className: styles.button,
|
||||
spinnerIcon: icons.SPINNER
|
||||
};
|
||||
|
||||
export default SpinnerButton;
|
23
frontend/src/Components/Link/SpinnerErrorButton.css
Normal file
23
frontend/src/Components/Link/SpinnerErrorButton.css
Normal file
|
@ -0,0 +1,23 @@
|
|||
.iconContainer {
|
||||
composes: spinnerContainer from 'Components/Link/SpinnerButton.css';
|
||||
}
|
||||
|
||||
.icon {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.label {
|
||||
composes: label from 'Components/Link/SpinnerButton.css';
|
||||
}
|
||||
|
||||
.showIcon {
|
||||
.iconContainer {
|
||||
left: 50%;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.label {
|
||||
left: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
162
frontend/src/Components/Link/SpinnerErrorButton.js
Normal file
162
frontend/src/Components/Link/SpinnerErrorButton.js
Normal file
|
@ -0,0 +1,162 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import styles from './SpinnerErrorButton.css';
|
||||
|
||||
function getTestResult(error) {
|
||||
if (!error) {
|
||||
return {
|
||||
wasSuccessful: true,
|
||||
hasWarning: false,
|
||||
hasError: false
|
||||
};
|
||||
}
|
||||
|
||||
if (error.status !== 400) {
|
||||
return {
|
||||
wasSuccessful: false,
|
||||
hasWarning: false,
|
||||
hasError: true
|
||||
};
|
||||
}
|
||||
|
||||
const failures = error.responseJSON;
|
||||
|
||||
const hasWarning = _.some(failures, { isWarning: true });
|
||||
const hasError = _.some(failures, (failure) => !failure.isWarning);
|
||||
|
||||
return {
|
||||
wasSuccessful: false,
|
||||
hasWarning,
|
||||
hasError
|
||||
};
|
||||
}
|
||||
|
||||
class SpinnerErrorButton extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._testResultTimeout = null;
|
||||
|
||||
this.state = {
|
||||
wasSuccessful: false,
|
||||
hasWarning: false,
|
||||
hasError: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
isSpinning,
|
||||
error
|
||||
} = this.props;
|
||||
|
||||
if (prevProps.isSpinning && !isSpinning) {
|
||||
const testResult = getTestResult(error);
|
||||
|
||||
this.setState(testResult, () => {
|
||||
const {
|
||||
wasSuccessful,
|
||||
hasWarning,
|
||||
hasError
|
||||
} = testResult;
|
||||
|
||||
if (wasSuccessful || hasWarning || hasError) {
|
||||
this._testResultTimeout = setTimeout(this.resetState, 3000);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._testResultTimeout) {
|
||||
clearTimeout(this._testResultTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
resetState = () => {
|
||||
this.setState({
|
||||
wasSuccessful: false,
|
||||
hasWarning: false,
|
||||
hasError: false
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isSpinning,
|
||||
error,
|
||||
children,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
wasSuccessful,
|
||||
hasWarning,
|
||||
hasError
|
||||
} = this.state;
|
||||
|
||||
const showIcon = wasSuccessful || hasWarning || hasError;
|
||||
|
||||
let iconName = icons.CHECK;
|
||||
let iconKind = kinds.SUCCESS;
|
||||
|
||||
if (hasWarning) {
|
||||
iconName = icons.WARNING;
|
||||
iconKind = kinds.WARNING;
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
iconName = icons.DANGER;
|
||||
iconKind = kinds.DANGER;
|
||||
}
|
||||
|
||||
return (
|
||||
<SpinnerButton
|
||||
isSpinning={isSpinning}
|
||||
{...otherProps}
|
||||
>
|
||||
<span className={showIcon && styles.showIcon}>
|
||||
{
|
||||
showIcon &&
|
||||
<span className={styles.iconContainer}>
|
||||
<Icon
|
||||
name={iconName}
|
||||
kind={iconKind}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
|
||||
{
|
||||
<span className={styles.label}>
|
||||
{
|
||||
children
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</SpinnerButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SpinnerErrorButton.propTypes = {
|
||||
isSpinning: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
export default SpinnerErrorButton;
|
37
frontend/src/Components/Link/SpinnerIconButton.js
Normal file
37
frontend/src/Components/Link/SpinnerIconButton.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import IconButton from './IconButton';
|
||||
|
||||
function SpinnerIconButton(props) {
|
||||
const {
|
||||
name,
|
||||
spinningName,
|
||||
isDisabled,
|
||||
isSpinning,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
name={isSpinning ? `${spinningName || name} fa-spin` : name}
|
||||
isDisabled={isDisabled || isSpinning}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
SpinnerIconButton.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
spinningName: PropTypes.string.isRequired,
|
||||
isDisabled: PropTypes.bool.isRequired,
|
||||
isSpinning: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
SpinnerIconButton.defaultProps = {
|
||||
spinningName: icons.SPINNER,
|
||||
isDisabled: false,
|
||||
isSpinning: false
|
||||
};
|
||||
|
||||
export default SpinnerIconButton;
|
65
frontend/src/Components/Loading/LoadingIndicator.css
Normal file
65
frontend/src/Components/Loading/LoadingIndicator.css
Normal file
|
@ -0,0 +1,65 @@
|
|||
.loading {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rippleContainer {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ripple:nth-child(0) {
|
||||
animation-delay: -0.8s;
|
||||
}
|
||||
|
||||
.ripple:nth-child(1) {
|
||||
animation-delay: -0.6s;
|
||||
}
|
||||
|
||||
.ripple:nth-child(2) {
|
||||
animation-delay: -0.4s;
|
||||
}
|
||||
|
||||
.ripple:nth-child(3) {
|
||||
animation-delay: -0.2s;
|
||||
}
|
||||
|
||||
.ripple {
|
||||
position: absolute;
|
||||
border: 2px solid #3a3f51;
|
||||
border-radius: 100%;
|
||||
animation-fill-mode: both;
|
||||
animation: rippleContainer 1.25s 0s infinite cubic-bezier(0.21, 0.53, 0.56, 0.8);
|
||||
}
|
||||
|
||||
@-webkit-keyframes rippleContainer {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(0.1);
|
||||
}
|
||||
|
||||
70% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rippleContainer {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(0.1);
|
||||
}
|
||||
|
||||
70% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
48
frontend/src/Components/Loading/LoadingIndicator.js
Normal file
48
frontend/src/Components/Loading/LoadingIndicator.js
Normal file
|
@ -0,0 +1,48 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './LoadingIndicator.css';
|
||||
|
||||
function LoadingIndicator({ className, size }) {
|
||||
const sizeInPx = `${size}px`;
|
||||
const width = sizeInPx;
|
||||
const height = sizeInPx;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{ height }}
|
||||
>
|
||||
<div
|
||||
className={styles.rippleContainer}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<div
|
||||
className={styles.ripple}
|
||||
style={{ width, height }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={styles.ripple}
|
||||
style={{ width, height }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={styles.ripple}
|
||||
style={{ width, height }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LoadingIndicator.propTypes = {
|
||||
className: PropTypes.string,
|
||||
size: PropTypes.number
|
||||
};
|
||||
|
||||
LoadingIndicator.defaultProps = {
|
||||
className: styles.loading,
|
||||
size: 50
|
||||
};
|
||||
|
||||
export default LoadingIndicator;
|
6
frontend/src/Components/Loading/LoadingMessage.css
Normal file
6
frontend/src/Components/Loading/LoadingMessage.css
Normal file
|
@ -0,0 +1,6 @@
|
|||
.loadingMessage {
|
||||
margin: 50px 10px 0;
|
||||
text-align: center;
|
||||
font-weight: 300;
|
||||
font-size: 36px;
|
||||
}
|
36
frontend/src/Components/Loading/LoadingMessage.js
Normal file
36
frontend/src/Components/Loading/LoadingMessage.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import styles from './LoadingMessage.css';
|
||||
|
||||
const messages = [
|
||||
'Downloading more RAM',
|
||||
'Now in Technicolor',
|
||||
'Previously on Sonarr...',
|
||||
'Bleep Bloop.',
|
||||
'Locating the required gigapixels to render...',
|
||||
'Spinning up the hamster wheel...',
|
||||
'At least you\'re not on hold',
|
||||
'Hum something loud while others stare',
|
||||
'Loading humorous message... Please Wait',
|
||||
'I could\'ve been faster in Python',
|
||||
'Don\'t forget to rewind your episodes',
|
||||
'Congratulations! you are the 1000th visitor.',
|
||||
'HELP!, I\'m being held hostage and forced to write these stupid lines!',
|
||||
'RE-calibrating the internet...',
|
||||
'I\'ll be here all week',
|
||||
'Don\'t forget to tip your waitress',
|
||||
'Apply directly to the forehead',
|
||||
'Loading Battlestation'
|
||||
];
|
||||
|
||||
function LoadingMessage() {
|
||||
const index = Math.floor(Math.random() * messages.length);
|
||||
const message = messages[index];
|
||||
|
||||
return (
|
||||
<div className={styles.loadingMessage}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingMessage;
|
9
frontend/src/Components/Menu/FilterMenu.css
Normal file
9
frontend/src/Components/Menu/FilterMenu.css
Normal file
|
@ -0,0 +1,9 @@
|
|||
.filterMenu {
|
||||
composes: menu from './Menu.css';
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.filterMenu {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
44
frontend/src/Components/Menu/FilterMenu.js
Normal file
44
frontend/src/Components/Menu/FilterMenu.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Menu from 'Components/Menu/Menu';
|
||||
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
|
||||
import styles from './FilterMenu.css';
|
||||
|
||||
class FilterMenu extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
children,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className={className}
|
||||
{...otherProps}
|
||||
>
|
||||
<ToolbarMenuButton
|
||||
iconName={icons.FILTER}
|
||||
text="Filter"
|
||||
/>
|
||||
{children}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FilterMenu.propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
FilterMenu.defaultProps = {
|
||||
className: styles.filterMenu
|
||||
};
|
||||
|
||||
export default FilterMenu;
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue