Initial Commit Rework

This commit is contained in:
Qstick 2017-09-03 22:20:56 -04:00
parent 74a4cc048c
commit 95051cbd63
2483 changed files with 101351 additions and 111396 deletions

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

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

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

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

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

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

View file

@ -0,0 +1,4 @@
.descriptionList {
margin-top: 0;
margin-bottom: 20px;
}

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

View file

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

View file

@ -0,0 +1,13 @@
.description {
line-height: 1.528571429;
}
.description {
margin-left: 0;
}
@media (min-width: 768px) {
.description {
margin-left: 180px;
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
.dragLayer {
position: fixed;
top: 0;
left: 0;
z-index: 9999;
width: 100%;
height: 100%;
pointer-events: none;
}

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

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

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

View file

@ -0,0 +1,5 @@
.modal {
composes: modal from 'Components/Modal/Modal.css';
height: 600px;
}

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

View file

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

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

View file

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

View file

@ -0,0 +1,5 @@
.type {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 32px;
}

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

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

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,3 @@
.selectedValue {
flex: 1 1 auto;
}

View file

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

View file

@ -0,0 +1,11 @@
.form {
}
.error {
color: $dangerColor;
}
.warning {
color: $warningColor;
}

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

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

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

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

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

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

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

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

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

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

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

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

View file

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

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

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

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

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

View file

@ -0,0 +1,13 @@
import React from 'react';
import TextInput from './TextInput';
function PasswordInput(props) {
return (
<TextInput
type="password"
{...props}
/>
);
}
export default PasswordInput;

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

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

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

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

View file

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

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

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

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

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

View file

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

View file

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

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

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

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,4 @@
.heart {
margin-right: 5px;
color: $themeRed;
}

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

View file

@ -0,0 +1,15 @@
.danger {
color: $dangerColor;
}
.default {
color: inherit;
}
.success {
color: $successColor;
}
.warning {
color: $warningColor;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,6 @@
.loadingMessage {
margin: 50px 10px 0;
text-align: center;
font-weight: 300;
font-size: 36px;
}

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

View file

@ -0,0 +1,9 @@
.filterMenu {
composes: menu from './Menu.css';
}
@media only screen and (max-width: $breakpointSmall) {
.filterMenu {
margin-right: 10px;
}
}

View 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